From cff0af31be8dbb86b55da3ccb644241549886bb7 Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Thu, 2 Apr 2026 22:41:34 +0200 Subject: [PATCH] feat(hosting): signed preview URLs for POC sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the standalone POC viewer (duplicate rendering) with signed JWT preview tokens that bypass StorefrontAccessMiddleware: Architecture: 1. Admin clicks Preview → route generates signed JWT 2. Redirects to /storefront/{subdomain}/homepage?_preview=token 3. Middleware validates token signature + expiry + store_id 4. Sets request.state.is_preview = True, skips subscription check 5. Full storefront renders with HostWizard preview banner injected New files: - app/core/preview_token.py: create_preview_token/verify_preview_token Changes: - middleware/storefront_access.py: preview token bypass before sub check - storefront/base.html: preview banner injection via is_preview state - hosting/routes/pages/public.py: redirect with signed token (was direct render) - hosting/routes/api/admin_sites.py: GET /sites/{id}/preview-url endpoint Removed: - hosting/templates/hosting/public/poc-viewer.html (replaced by storefront) Benefits: one rendering path, all section types work, shareable 24h links. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/core/preview_token.py | 54 ++++++ app/modules/hosting/routes/api/admin_sites.py | 25 +++ app/modules/hosting/routes/pages/public.py | 94 ++++----- .../templates/hosting/public/poc-viewer.html | 183 ------------------ app/templates/storefront/base.html | 12 ++ middleware/storefront_access.py | 15 ++ 6 files changed, 139 insertions(+), 244 deletions(-) create mode 100644 app/core/preview_token.py delete mode 100644 app/modules/hosting/templates/hosting/public/poc-viewer.html diff --git a/app/core/preview_token.py b/app/core/preview_token.py new file mode 100644 index 00000000..2ed02475 --- /dev/null +++ b/app/core/preview_token.py @@ -0,0 +1,54 @@ +# app/core/preview_token.py +""" +Signed preview tokens for POC site previews. + +Generates time-limited JWT tokens that allow viewing storefront pages +for stores without active subscriptions (POC sites). The token is +validated by StorefrontAccessMiddleware to bypass the subscription gate. +""" + +import logging +from datetime import UTC, datetime, timedelta + +from jose import JWTError, jwt + +from app.core.config import settings + +logger = logging.getLogger(__name__) + +PREVIEW_TOKEN_HOURS = 24 +ALGORITHM = "HS256" + + +def create_preview_token(store_id: int, store_code: str, site_id: int) -> str: + """Create a signed preview token for a POC site. + + Token is valid for PREVIEW_TOKEN_HOURS (default 24h) and is tied + to a specific store_id. Shareable with clients for preview access. + """ + payload = { + "sub": f"preview:{store_id}", + "store_id": store_id, + "store_code": store_code, + "site_id": site_id, + "preview": True, + "exp": datetime.now(UTC) + timedelta(hours=PREVIEW_TOKEN_HOURS), + "iat": datetime.now(UTC), + } + return jwt.encode(payload, settings.jwt_secret_key, algorithm=ALGORITHM) + + +def verify_preview_token(token: str, store_id: int) -> bool: + """Verify a preview token is valid and matches the store. + + Returns True if: + - Token signature is valid + - Token has not expired + - Token has preview=True claim + - Token store_id matches the requested store + """ + try: + payload = jwt.decode(token, settings.jwt_secret_key, algorithms=[ALGORITHM]) + return payload.get("preview") is True and payload.get("store_id") == store_id + except JWTError: + return False diff --git a/app/modules/hosting/routes/api/admin_sites.py b/app/modules/hosting/routes/api/admin_sites.py index 9c3d73ff..f2cd1c6f 100644 --- a/app/modules/hosting/routes/api/admin_sites.py +++ b/app/modules/hosting/routes/api/admin_sites.py @@ -44,6 +44,31 @@ def list_templates( ) +class PreviewUrlResponse(BaseModel): + """Response with signed preview URL.""" + + preview_url: str + expires_in_hours: int = 24 + + +@router.get("/sites/{site_id}/preview-url", response_model=PreviewUrlResponse) +def get_preview_url( + site_id: int = Path(...), + db: Session = Depends(get_db), + current_admin: UserContext = Depends(get_current_admin_api), +): + """Generate a signed preview URL for a hosted site.""" + from app.core.preview_token import create_preview_token + + site = hosted_site_service.get_by_id(db, site_id) + store = site.store + subdomain = store.subdomain or store.store_code + token = create_preview_token(store.id, subdomain, site.id) + return PreviewUrlResponse( + preview_url=f"/storefront/{subdomain}/?_preview={token}", + ) + + class BuildPocRequest(BaseModel): """Request to build a POC site from prospect + template.""" diff --git a/app/modules/hosting/routes/pages/public.py b/app/modules/hosting/routes/pages/public.py index 673f429d..5396edcd 100644 --- a/app/modules/hosting/routes/pages/public.py +++ b/app/modules/hosting/routes/pages/public.py @@ -2,43 +2,40 @@ """ Hosting Public Page Routes. -Public-facing routes for POC site viewing: -- POC Viewer - Shows the Store's storefront with a HostWizard preview banner +POC site preview via signed URL redirect to the storefront. +The StorefrontAccessMiddleware validates the preview token and +allows rendering without an active subscription. """ -from fastapi import APIRouter, Depends, Path, Request -from fastapi.responses import HTMLResponse +from fastapi import APIRouter, Depends, Path, Query +from fastapi.responses import HTMLResponse, RedirectResponse from sqlalchemy.orm import Session from app.core.database import get_db -from app.templates_config import templates +from app.core.preview_token import create_preview_token router = APIRouter() @router.get( "/hosting/sites/{site_id}/preview", - response_class=HTMLResponse, include_in_schema=False, ) async def poc_site_viewer( - request: Request, site_id: int = Path(..., description="Hosted Site ID"), + page: str = Query("homepage", description="Page slug to preview"), db: Session = Depends(get_db), ): - """Render POC site viewer with HostWizard preview banner. + """Redirect to storefront with signed preview token. - Renders CMS content directly (not via iframe to storefront) to - bypass the subscription access gate for pre-launch POC sites. + Generates a time-limited JWT and redirects to the store's + storefront URL. The StorefrontAccessMiddleware validates the + token and bypasses the subscription check. """ - from app.modules.cms.models.content_page import ContentPage - from app.modules.cms.models.store_theme import StoreTheme from app.modules.hosting.models import HostedSite, HostedSiteStatus - from app.modules.tenancy.models import StorePlatform site = db.query(HostedSite).filter(HostedSite.id == site_id).first() - # Only allow viewing for poc_ready or proposal_sent sites if not site or site.status not in ( HostedSiteStatus.POC_READY, HostedSiteStatus.PROPOSAL_SENT, @@ -47,56 +44,31 @@ async def poc_site_viewer( return HTMLResponse(content="

Site not available for preview

", status_code=404) store = site.store + if not store: + return HTMLResponse(content="

Store not found

", status_code=404) + + # Generate signed preview token — use subdomain for URL routing + subdomain = store.subdomain or store.store_code + token = create_preview_token(store.id, subdomain, site.id) + + # Get platform code for dev-mode URL prefix + from app.core.config import settings + from app.modules.tenancy.models import StorePlatform - # Get platform_id for CMS query store_platform = ( db.query(StorePlatform) .filter(StorePlatform.store_id == store.id) .first() - ) if store else None - platform_id = store_platform.platform_id if store_platform else None - - # Load homepage (slug='homepage' from POC builder) - page = None - if platform_id: - page = ( - db.query(ContentPage) - .filter( - ContentPage.platform_id == platform_id, - ContentPage.store_id == store.id, - ContentPage.slug == "homepage", - ContentPage.is_published.is_(True), - ) - .first() - ) - - # Load all nav pages for this store - nav_pages = [] - if platform_id: - nav_pages = ( - db.query(ContentPage) - .filter( - ContentPage.platform_id == platform_id, - ContentPage.store_id == store.id, - ContentPage.is_published.is_(True), - ContentPage.show_in_header.is_(True), - ) - .order_by(ContentPage.display_order) - .all() - ) - - # Load theme - theme = db.query(StoreTheme).filter(StoreTheme.store_id == store.id).first() if store else None - - context = { - "request": request, - "site": site, - "store": store, - "page": page, - "nav_pages": nav_pages, - "theme": theme, - } - return templates.TemplateResponse( - "hosting/public/poc-viewer.html", - context, ) + + # In dev mode, storefront needs /platforms/{code}/ prefix + if settings.debug and store_platform and store_platform.platform: + platform_code = store_platform.platform.code + base_url = f"/platforms/{platform_code}/storefront/{subdomain}" + else: + base_url = f"/storefront/{subdomain}" + + # Append page slug — storefront needs /{slug} (root has no catch-all) + base_url += f"/{page}" + + return RedirectResponse(f"{base_url}?_preview={token}", status_code=302) diff --git a/app/modules/hosting/templates/hosting/public/poc-viewer.html b/app/modules/hosting/templates/hosting/public/poc-viewer.html deleted file mode 100644 index 352a94e9..00000000 --- a/app/modules/hosting/templates/hosting/public/poc-viewer.html +++ /dev/null @@ -1,183 +0,0 @@ - - - - - - {{ site.business_name }} - Preview by HostWizard - {% if page and page.meta_description %} - - {% endif %} - - - - - - - {# HostWizard Preview Banner #} -
-
- HostWizard - Preview for {{ site.business_name }} -
-
- {% for np in nav_pages %} - {{ np.title }} - {% endfor %} - hostwizard.lu -
-
- - {# Main Content — rendered directly, no iframe #} -
- {% if page and page.sections %} - {# Section-based rendering (from POC builder templates) #} - {% set lang = 'fr' %} - {% set default_lang = 'fr' %} - - {% if page.sections.hero %} - {% set hero = page.sections.hero %} -
-
- {% set t = hero.get('title', {}) %} - {% set tr = t.get('translations', {}) if t is mapping else {} %} -

{{ tr.get(lang) or tr.get(default_lang) or site.business_name }}

- {% set st = hero.get('subtitle', {}) %} - {% set str_ = st.get('translations', {}) if st is mapping else {} %} - {% set subtitle_text = str_.get(lang) or str_.get(default_lang, '') %} - {% if subtitle_text %} -

{{ subtitle_text }}

- {% endif %} - {% set hero_buttons = hero.get('buttons', []) %} - {% if hero_buttons %} -
- {% for btn in hero_buttons %} - {% set bl = btn.get('label', {}) %} - {% set bltr = bl.get('translations', {}) if bl is mapping else {} %} - {% set btn_label = bltr.get(lang) or bltr.get(default_lang, 'Learn More') %} - - {{ btn_label }} - - {% endfor %} -
- {% endif %} -
-
- {% endif %} - - {% if page.sections.features %} - {% set features = page.sections.features %} -
-
- {% set ft = features.get('title', {}) %} - {% set ftr = ft.get('translations', {}) if ft is mapping else {} %} - {% set title = ftr.get(lang) or ftr.get(default_lang, '') %} - {% if title %} -

{{ title }}

- {% endif %} - {% set feature_items = features.get('items', []) %} - {% if feature_items %} -
- {% for item in feature_items %} - {% set it = item.get('title', {}) %} - {% set itr = it.get('translations', {}) if it is mapping else {} %} - {% set item_title = itr.get(lang) or itr.get(default_lang, '') %} - {% set id_ = item.get('description', {}) %} - {% set idr = id_.get('translations', {}) if id_ is mapping else {} %} - {% set item_desc = idr.get(lang) or idr.get(default_lang, '') %} -
-
- {{ item_title[0]|upper if item_title else '?' }} -
-

{{ item_title }}

-

{{ item_desc }}

-
- {% endfor %} -
- {% endif %} -
-
- {% endif %} - - {% if page.sections.testimonials %} - {% set testimonials = page.sections.testimonials %} -
-
- {% set tt = testimonials.get('title', {}) %} - {% set ttr = tt.get('translations', {}) if tt is mapping else {} %} - {% set title = ttr.get(lang) or ttr.get(default_lang, '') %} - {% if title %} -

{{ title }}

- {% endif %} -

Coming soon

-
-
- {% endif %} - - {% if page.sections.cta %} - {% set cta = page.sections.cta %} -
-
- {% set ct = cta.get('title', {}) %} - {% set ctr = ct.get('translations', {}) if ct is mapping else {} %} -

{{ ctr.get(lang) or ctr.get(default_lang, '') }}

- {% set cta_buttons = cta.get('buttons', []) %} - {% if cta_buttons %} -
- {% for btn in cta_buttons %} - {% set bl = btn.get('label', {}) %} - {% set bltr = bl.get('translations', {}) if bl is mapping else {} %} - - {{ bltr.get(lang) or bltr.get(default_lang, 'Contact') }} - - {% endfor %} -
- {% endif %} -
-
- {% endif %} - - {% elif page and page.content %} - {# Plain HTML content #} -
- {{ page.content | safe }} -
- {% else %} -
-

No content yet. Build a POC to populate this site.

-
- {% endif %} -
- - {# Footer #} -
-
-

{{ site.business_name }}

- {% if site.contact_email %} -

{{ site.contact_email }}

- {% endif %} - {% if site.contact_phone %} -

{{ site.contact_phone }}

- {% endif %} -

Powered by HostWizard

-
-
- - - diff --git a/app/templates/storefront/base.html b/app/templates/storefront/base.html index c626d3ef..e3f51e02 100644 --- a/app/templates/storefront/base.html +++ b/app/templates/storefront/base.html @@ -210,6 +210,18 @@ + {# Preview mode banner (POC site previews via signed URL) #} + {% if request.state.is_preview|default(false) %} +
+
+ HostWizard + Preview Mode +
+ hostwizard.lu +
+ + {% endif %} + {# Mobile menu panel #}