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

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)