Compare commits
2 Commits
a28d5d1de5
...
bc7431943a
| Author | SHA1 | Date | |
|---|---|---|---|
| bc7431943a | |||
| adec17cd02 |
@@ -230,5 +230,5 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_scripts %}
|
{% block extra_scripts %}
|
||||||
<script src="{{ url_for('loyalty_static', path='storefront/js/loyalty-dashboard.js') }}"></script>
|
<script defer src="{{ url_for('loyalty_static', path='storefront/js/loyalty-dashboard.js') }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -119,7 +119,7 @@
|
|||||||
class="w-full py-3 px-4 text-white font-semibold rounded-lg transition-colors disabled:opacity-50"
|
class="w-full py-3 px-4 text-white font-semibold rounded-lg transition-colors disabled:opacity-50"
|
||||||
:style="'background-color: ' + (program?.card_color || 'var(--color-primary)')">
|
:style="'background-color: ' + (program?.card_color || 'var(--color-primary)')">
|
||||||
<span x-show="enrolling" x-html="$icon('spinner', 'w-5 h-5 inline animate-spin mr-2')"></span>
|
<span x-show="enrolling" x-html="$icon('spinner', 'w-5 h-5 inline animate-spin mr-2')"></span>
|
||||||
<span x-text="enrolling ? '{{ _('loyalty.enrollment.form.joining') }}' : '{{ _('loyalty.enrollment.form.join_button', points=program.welcome_bonus_points if program else 0) }}'"></span>
|
<span x-text="enrolling ? '{{ _('loyalty.enrollment.form.joining')|replace("'", "\\'") }}' : '{{ _('loyalty.enrollment.form.join_button', points=program.welcome_bonus_points if program else 0)|replace("'", "\\'") }}'"></span>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -170,5 +170,5 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_scripts %}
|
{% block extra_scripts %}
|
||||||
<script src="{{ url_for('loyalty_static', path='storefront/js/loyalty-enroll.js') }}"></script>
|
<script defer src="{{ url_for('loyalty_static', path='storefront/js/loyalty-enroll.js') }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -105,5 +105,5 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_scripts %}
|
{% block extra_scripts %}
|
||||||
<script src="{{ url_for('loyalty_static', path='storefront/js/loyalty-history.js') }}"></script>
|
<script defer src="{{ url_for('loyalty_static', path='storefront/js/loyalty-history.js') }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -968,6 +968,53 @@ curl -I https://newplatform.lu/
|
|||||||
curl -I https://teststore.newplatform.lu/
|
curl -I https://teststore.newplatform.lu/
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Future: Scaling Custom Domains Beyond 50
|
||||||
|
|
||||||
|
The manual Caddyfile approach (one block per custom domain + Cloudflare origin cert) works well up to ~50 custom store domains. Beyond that, managing Caddyfile blocks and origin certs becomes tedious. Two scaling strategies:
|
||||||
|
|
||||||
|
##### Option 1: Caddy On-Demand TLS (simple, no Cloudflare protection)
|
||||||
|
|
||||||
|
A single catch-all Caddy block replaces all custom domain blocks. Caddy auto-provisions Let's Encrypt certs on first request and validates domains against the database:
|
||||||
|
|
||||||
|
```caddy
|
||||||
|
https:// {
|
||||||
|
tls {
|
||||||
|
on_demand
|
||||||
|
ask http://localhost:8001/api/v1/internal/verify-domain
|
||||||
|
}
|
||||||
|
reverse_proxy localhost:8001
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `/verify-domain` endpoint checks the `store_domains` table — returns 200 (provision cert) or 404 (reject). Adding a new custom domain becomes a database insert only, no Caddy or Cloudflare changes needed.
|
||||||
|
|
||||||
|
**Limitation**: Custom domains point directly to the server (no Cloudflare proxy), so they lose DDoS protection, WAF, bot mitigation, and CDN caching. A DDoS attack on any custom domain impacts all sites on the server.
|
||||||
|
|
||||||
|
Let's Encrypt rate limits (50 certs/registered domain/week, 300 new orders/3 hours) are not an issue since each custom domain is unique. Caddy handles 5,000+ certs comfortably in memory (~50-100MB).
|
||||||
|
|
||||||
|
##### Option 2: Cloudflare for SaaS (recommended for production scale)
|
||||||
|
|
||||||
|
[Cloudflare for SaaS](https://developers.cloudflare.com/cloudflare-for-platforms/cloudflare-for-saas/) (Custom Hostnames) is how Shopify, Webflow, and major SaaS platforms handle custom domains at scale. Every custom domain gets full Cloudflare protection:
|
||||||
|
|
||||||
|
1. Configure a **fallback origin** in your Cloudflare account (e.g., `customers.omsflow.lu → 91.99.65.229`)
|
||||||
|
2. Customer sets a CNAME: `wizamart.com → customers.omsflow.lu`
|
||||||
|
3. Cloudflare proxies `wizamart.com` through your account — DDoS, WAF, bot protection, CDN all included
|
||||||
|
4. SSL is automatic (no Let's Encrypt, no origin certs to manage)
|
||||||
|
5. Adding a domain = database insert + one Cloudflare API call (automatable)
|
||||||
|
|
||||||
|
**Cost**: Available on Cloudflare Pro ($20/month) with per-hostname pricing (~$0.10/month each at volume). At 5,000 domains × $0.10 = ~$500/month for full Cloudflare protection on every customer domain.
|
||||||
|
|
||||||
|
**Hybrid approach**: Platform domains (`*.omsflow.lu`, `rewardflow.lu`, etc.) stay on the current Cloudflare setup with origin certs. Only customer custom domains use Cloudflare for SaaS.
|
||||||
|
|
||||||
|
##### Infrastructure scaling at 1,000+ sites
|
||||||
|
|
||||||
|
At this scale, the 4GB Hetzner VPS becomes the bottleneck before Caddy does. Plan for:
|
||||||
|
|
||||||
|
- Horizontal scaling: multiple app servers behind a load balancer
|
||||||
|
- Dedicated PostgreSQL server
|
||||||
|
- Dedicated Redis instance
|
||||||
|
- CDN for static assets (Cloudflare, already in place)
|
||||||
|
|
||||||
## Step 15: Gitea Actions Runner
|
## Step 15: Gitea Actions Runner
|
||||||
|
|
||||||
!!! warning "ARM64 architecture"
|
!!! warning "ARM64 architecture"
|
||||||
|
|||||||
@@ -295,13 +295,17 @@ class StoreContextManager:
|
|||||||
Extract store context from Referer header.
|
Extract store context from Referer header.
|
||||||
|
|
||||||
Used for storefront API requests where store context comes from the page
|
Used for storefront API requests where store context comes from the page
|
||||||
that made the API call (e.g., JavaScript on /stores/orion/storefront/products
|
that made the API call (e.g., JavaScript on /storefront/FASHIONHUB/loyalty/join
|
||||||
calling /api/v1/storefront/products).
|
calling /api/v1/storefront/loyalty/program).
|
||||||
|
|
||||||
|
Platform-aware: uses request.state.platform.domain (e.g. rewardflow.lu)
|
||||||
|
for subdomain detection, not just settings.main_domain (wizard.lu).
|
||||||
|
|
||||||
Extracts store from Referer URL patterns:
|
Extracts store from Referer URL patterns:
|
||||||
- http://localhost:8000/stores/orion/storefront/... → orion
|
- localhost:8000/platforms/loyalty/storefront/FASHIONHUB/... → FASHIONHUB (dev path)
|
||||||
- http://orion.platform.com/storefront/... → orion (subdomain) # noqa
|
- rewardflow.lu/storefront/FASHIONHUB/... → FASHIONHUB (prod platform domain path)
|
||||||
- http://custom-domain.com/storefront/... → custom-domain.com # noqa
|
- fashionhub.rewardflow.lu/... → fashionhub (prod platform subdomain)
|
||||||
|
- custom-domain.com/... → custom-domain.com (prod custom domain)
|
||||||
|
|
||||||
Returns store context dict or None if unable to extract.
|
Returns store context dict or None if unable to extract.
|
||||||
"""
|
"""
|
||||||
@@ -331,20 +335,34 @@ class StoreContextManager:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Method 1: Path-based detection from referer path (local hosts only)
|
# Determine platform domain for platform-aware detection
|
||||||
# /platforms/oms/storefront/WIZATECH/products → WIZATECH
|
# (e.g. rewardflow.lu from the loyalty platform object)
|
||||||
# /stores/orion/storefront/products → orion
|
platform = getattr(request.state, "platform", None)
|
||||||
# /storefront/WIZATECH/products → WIZATECH
|
platform_own_domain = getattr(platform, "domain", None) if platform else None
|
||||||
# Note: For subdomain/custom domain hosts, the store code is NOT in the path
|
is_platform_domain = (
|
||||||
# (e.g., orion.platform.com/storefront/products — "products" is a page, not a store)
|
platform_own_domain and referer_host == platform_own_domain
|
||||||
|
)
|
||||||
|
is_subdomain_of_platform = (
|
||||||
|
platform_own_domain
|
||||||
|
and referer_host != platform_own_domain
|
||||||
|
and referer_host.endswith(f".{platform_own_domain}")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Method 1: Path-based detection from referer path
|
||||||
|
# Works on localhost (dev) AND on platform domains (prod path-based routing)
|
||||||
|
# /platforms/oms/storefront/WIZATECH/products → WIZATECH (dev)
|
||||||
|
# /storefront/FASHIONHUB/loyalty/join → FASHIONHUB (prod platform domain)
|
||||||
|
# Note: For subdomain hosts, path segments after /storefront/ are pages, not store codes
|
||||||
is_local_referer = referer_host in ("localhost", "127.0.0.1", "testserver")
|
is_local_referer = referer_host in ("localhost", "127.0.0.1", "testserver")
|
||||||
if is_local_referer and referer_path.startswith("/platforms/"):
|
use_path_detection = is_local_referer or is_platform_domain
|
||||||
|
|
||||||
|
if use_path_detection and referer_path.startswith("/platforms/"):
|
||||||
# Strip /platforms/{code}/ to get clean path
|
# Strip /platforms/{code}/ to get clean path
|
||||||
after_platforms = referer_path[11:] # Remove "/platforms/"
|
after_platforms = referer_path[11:] # Remove "/platforms/"
|
||||||
parts = after_platforms.split("/", 1)
|
parts = after_platforms.split("/", 1)
|
||||||
referer_path = "/" + parts[1] if len(parts) > 1 and parts[1] else "/"
|
referer_path = "/" + parts[1] if len(parts) > 1 and parts[1] else "/"
|
||||||
|
|
||||||
if is_local_referer and referer_path.startswith(("/stores/", "/store/", "/storefront/")):
|
if use_path_detection and referer_path.startswith(("/stores/", "/store/", "/storefront/")):
|
||||||
if referer_path.startswith("/storefront/"):
|
if referer_path.startswith("/storefront/"):
|
||||||
prefix = "/storefront/"
|
prefix = "/storefront/"
|
||||||
elif referer_path.startswith("/stores/"):
|
elif referer_path.startswith("/stores/"):
|
||||||
@@ -371,7 +389,25 @@ class StoreContextManager:
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Method 2: Subdomain detection from referer host
|
# Method 2: Subdomain detection from referer host
|
||||||
# orion.platform.com → orion
|
# fashionhub.rewardflow.lu → fashionhub (platform subdomain)
|
||||||
|
# store1.wizard.lu → store1 (main domain subdomain)
|
||||||
|
if is_subdomain_of_platform:
|
||||||
|
subdomain = referer_host.split(".")[0]
|
||||||
|
logger.debug(
|
||||||
|
f"[STORE] Extracted store from Referer platform subdomain: {subdomain}",
|
||||||
|
extra={
|
||||||
|
"subdomain": subdomain,
|
||||||
|
"method": "referer_platform_subdomain",
|
||||||
|
"platform_domain": platform_own_domain,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"subdomain": subdomain,
|
||||||
|
"detection_method": "referer_subdomain",
|
||||||
|
"host": referer_host,
|
||||||
|
"referer": referer,
|
||||||
|
}
|
||||||
|
|
||||||
main_domain = getattr(settings, "main_domain", "platform.com")
|
main_domain = getattr(settings, "main_domain", "platform.com")
|
||||||
if "." in referer_host:
|
if "." in referer_host:
|
||||||
parts = referer_host.split(".")
|
parts = referer_host.split(".")
|
||||||
@@ -397,6 +433,8 @@ class StoreContextManager:
|
|||||||
# custom-shop.com → custom-shop.com
|
# custom-shop.com → custom-shop.com
|
||||||
is_custom_domain = (
|
is_custom_domain = (
|
||||||
referer_host
|
referer_host
|
||||||
|
and not is_platform_domain
|
||||||
|
and not is_subdomain_of_platform
|
||||||
and not referer_host.endswith(f".{main_domain}")
|
and not referer_host.endswith(f".{main_domain}")
|
||||||
and referer_host != main_domain
|
and referer_host != main_domain
|
||||||
and referer_host not in ["localhost", "127.0.0.1"]
|
and referer_host not in ["localhost", "127.0.0.1"]
|
||||||
|
|||||||
Reference in New Issue
Block a user