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:
@@ -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."""
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user