feat(hosting): signed preview URLs for POC sites

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) <noreply@anthropic.com>
This commit is contained in:
2026-04-02 22:41:34 +02:00
parent e492e5f71c
commit cff0af31be
6 changed files with 139 additions and 244 deletions

54
app/core/preview_token.py Normal file
View File

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

View File

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

View File

@@ -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="<h1>Site not available for preview</h1>", status_code=404)
store = site.store
if not store:
return HTMLResponse(content="<h1>Store not found</h1>", 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)

View File

@@ -1,183 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ site.business_name }} - Preview by HostWizard</title>
{% if page and page.meta_description %}
<meta name="description" content="{{ page.meta_description }}">
{% endif %}
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: '{{ theme.colors.primary if theme and theme.colors else "#3b82f6" }}',
'primary-dark': '{{ theme.colors.secondary if theme and theme.colors else "#1e40af" }}',
accent: '{{ theme.colors.accent if theme and theme.colors else "#f59e0b" }}',
}
}
}
}
</script>
<style>
@import url('https://fonts.googleapis.com/css2?family={{ (theme.font_family_heading or "Inter")|replace(" ", "+") }}:wght@400;600;700;800&family={{ (theme.font_family_body or "Inter")|replace(" ", "+") }}:wght@300;400;500;600&display=swap');
body { font-family: '{{ theme.font_family_body if theme else "Inter" }}', sans-serif; }
h1, h2, h3, h4, h5, h6 { font-family: '{{ theme.font_family_heading if theme else "Inter" }}', sans-serif; }
.gradient-primary { background: linear-gradient(135deg, {{ theme.colors.primary if theme and theme.colors else "#3b82f6" }}, {{ theme.colors.secondary if theme and theme.colors else "#1e40af" }}); }
</style>
</head>
<body class="bg-white text-gray-900">
{# HostWizard Preview Banner #}
<div style="position:fixed;top:0;left:0;right:0;z-index:9999;background:linear-gradient(135deg,#0D9488,#14B8A6);color:white;padding:10px 20px;display:flex;align-items:center;justify-content:space-between;font-family:system-ui;font-size:14px;box-shadow:0 2px 8px rgba(0,0,0,0.15);">
<div style="display:flex;align-items:center;gap:12px;">
<span style="font-weight:700;font-size:16px;">HostWizard</span>
<span style="opacity:0.9;">Preview for {{ site.business_name }}</span>
</div>
<div style="display:flex;align-items:center;gap:12px;">
{% for np in nav_pages %}
<a href="/hosting/sites/{{ site.id }}/preview?page={{ np.slug }}" style="color:white;text-decoration:none;padding:4px 12px;border:1px solid rgba(255,255,255,0.3);border-radius:6px;font-size:13px;">{{ np.title }}</a>
{% endfor %}
<a href="https://hostwizard.lu" style="color:white;text-decoration:none;padding:6px 16px;border:1px solid rgba(255,255,255,0.4);border-radius:6px;font-size:13px;" target="_blank">hostwizard.lu</a>
</div>
</div>
{# Main Content — rendered directly, no iframe #}
<div style="margin-top:48px;">
{% 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 %}
<section class="relative py-24 lg:py-32 gradient-primary text-white">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
{% set t = hero.get('title', {}) %}
{% set tr = t.get('translations', {}) if t is mapping else {} %}
<h1 class="text-4xl md:text-6xl font-bold mb-6">{{ tr.get(lang) or tr.get(default_lang) or site.business_name }}</h1>
{% 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 %}
<p class="text-xl md:text-2xl opacity-90 mb-10 max-w-2xl mx-auto">{{ subtitle_text }}</p>
{% endif %}
{% set hero_buttons = hero.get('buttons', []) %}
{% if hero_buttons %}
<div class="flex flex-wrap justify-center gap-4">
{% 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') %}
<a href="{{ btn.get('url', '#') }}" class="px-8 py-4 rounded-lg font-semibold text-lg {{ 'bg-white text-gray-900 hover:bg-gray-100' if btn.get('style') == 'primary' else 'border-2 border-white text-white hover:bg-white/10' }} transition-colors">
{{ btn_label }}
</a>
{% endfor %}
</div>
{% endif %}
</div>
</section>
{% endif %}
{% if page.sections.features %}
{% set features = page.sections.features %}
<section class="py-16 lg:py-24 bg-white">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{% 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 %}
<h2 class="text-3xl md:text-4xl font-bold text-center mb-12">{{ title }}</h2>
{% endif %}
{% set feature_items = features.get('items', []) %}
{% if feature_items %}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-{{ [feature_items|length, 4]|min }} gap-8">
{% 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, '') %}
<div class="bg-gray-50 rounded-xl p-8 text-center">
<div class="w-16 h-16 mx-auto mb-4 rounded-full gradient-primary flex items-center justify-center">
<span class="text-white text-2xl font-bold">{{ item_title[0]|upper if item_title else '?' }}</span>
</div>
<h3 class="text-lg font-bold mb-2">{{ item_title }}</h3>
<p class="text-gray-600">{{ item_desc }}</p>
</div>
{% endfor %}
</div>
{% endif %}
</div>
</section>
{% endif %}
{% if page.sections.testimonials %}
{% set testimonials = page.sections.testimonials %}
<section class="py-16 bg-gray-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
{% 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 %}
<h2 class="text-3xl font-bold mb-12">{{ title }}</h2>
{% endif %}
<p class="text-gray-400">Coming soon</p>
</div>
</section>
{% endif %}
{% if page.sections.cta %}
{% set cta = page.sections.cta %}
<section class="py-16 gradient-primary text-white">
<div class="max-w-4xl mx-auto px-4 text-center">
{% set ct = cta.get('title', {}) %}
{% set ctr = ct.get('translations', {}) if ct is mapping else {} %}
<h2 class="text-3xl font-bold mb-8">{{ ctr.get(lang) or ctr.get(default_lang, '') }}</h2>
{% set cta_buttons = cta.get('buttons', []) %}
{% if cta_buttons %}
<div class="flex flex-wrap justify-center gap-4">
{% for btn in cta_buttons %}
{% set bl = btn.get('label', {}) %}
{% set bltr = bl.get('translations', {}) if bl is mapping else {} %}
<a href="{{ btn.get('url', '#') }}" class="px-8 py-4 bg-white text-gray-900 rounded-lg font-semibold text-lg hover:bg-gray-100 transition-colors">
{{ bltr.get(lang) or bltr.get(default_lang, 'Contact') }}
</a>
{% endfor %}
</div>
{% endif %}
</div>
</section>
{% endif %}
{% elif page and page.content %}
{# Plain HTML content #}
<div class="max-w-4xl mx-auto px-4 py-16 prose lg:prose-lg">
{{ page.content | safe }}
</div>
{% else %}
<div class="max-w-4xl mx-auto px-4 py-16 text-center text-gray-400">
<p>No content yet. Build a POC to populate this site.</p>
</div>
{% endif %}
</div>
{# Footer #}
<footer class="bg-gray-900 text-gray-400 py-12">
<div class="max-w-7xl mx-auto px-4 text-center">
<p class="text-lg font-semibold text-white mb-2">{{ site.business_name }}</p>
{% if site.contact_email %}
<p>{{ site.contact_email }}</p>
{% endif %}
{% if site.contact_phone %}
<p>{{ site.contact_phone }}</p>
{% endif %}
<p class="mt-6 text-sm text-gray-500">Powered by <a href="https://hostwizard.lu" class="text-teal-400 hover:underline" target="_blank">HostWizard</a></p>
</div>
</footer>
</body>
</html>

View File

@@ -210,6 +210,18 @@
</div>
</header>
{# Preview mode banner (POC site previews via signed URL) #}
{% if request.state.is_preview|default(false) %}
<div style="position:fixed;top:0;left:0;right:0;z-index:9999;background:linear-gradient(135deg,#0D9488,#14B8A6);color:white;padding:10px 20px;display:flex;align-items:center;justify-content:space-between;font-family:system-ui;font-size:14px;box-shadow:0 2px 8px rgba(0,0,0,0.15);">
<div style="display:flex;align-items:center;gap:12px;">
<span style="font-weight:700;font-size:16px;">HostWizard</span>
<span style="opacity:0.9;">Preview Mode</span>
</div>
<a href="https://hostwizard.lu" style="color:white;text-decoration:none;padding:6px 16px;border:1px solid rgba(255,255,255,0.4);border-radius:6px;font-size:13px;" target="_blank">hostwizard.lu</a>
</div>
<style>header { margin-top: 48px !important; }</style>
{% endif %}
{# Mobile menu panel #}
<div x-show="mobileMenuOpen"
x-cloak

View File

@@ -128,6 +128,21 @@ class StorefrontAccessMiddleware(BaseHTTPMiddleware):
store = getattr(request.state, "store", None)
# Case 0: Preview token bypass (POC site previews)
preview_token = request.query_params.get("_preview")
if preview_token and store:
from app.core.preview_token import verify_preview_token
if verify_preview_token(preview_token, store.id):
request.state.is_preview = True
request.state.subscription = None
request.state.subscription_tier = None
logger.info(
"[STOREFRONT_ACCESS] Preview token valid for store '%s'",
store.subdomain,
)
return await call_next(request)
# Case 1: No store detected at all
if not store:
return self._render_unavailable(request, "not_found")