Compare commits

..

2 Commits

Author SHA1 Message Date
bc7431943a fix: make storefront API referer extraction platform-aware and fix script loading
Some checks failed
CI / ruff (push) Successful in 12s
CI / validate (push) Successful in 26s
CI / dependency-scanning (push) Successful in 29s
CI / pytest (push) Failing after 3h11m9s
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
Two bugs causing "Program Not Available" on storefront enrollment:

1. extract_store_from_referer() was not platform-aware — used
   settings.main_domain (wizard.lu) instead of platform.domain
   (rewardflow.lu) for subdomain detection, and restricted path-based
   extraction to localhost only. Now mirrors the platform-aware logic
   from _detect_store_from_host_and_path(): checks platform.domain for
   subdomain detection (fashionhub.rewardflow.lu → fashionhub) and
   allows path-based extraction on platform domains
   (rewardflow.lu/storefront/FASHIONHUB/... → FASHIONHUB).

2. Storefront JS scripts (enroll, dashboard, history) were missing
   defer attribute, causing them to execute before log-config.js and
   crash on window.LogConfig access. Also fix quote escaping in
   server-side rendered x-text expressions for French translations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 20:01:07 +01:00
adec17cd02 docs(deployment): add future scaling section for 50+ custom domains
Document two strategies for scaling beyond manual Caddyfile management:
- Caddy on-demand TLS (simple, no Cloudflare protection)
- Cloudflare for SaaS / Custom Hostnames (recommended, full protection)
- Infrastructure scaling notes for 1,000+ sites

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 19:41:35 +01:00
5 changed files with 103 additions and 18 deletions

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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"

View File

@@ -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"]