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:
54
app/core/preview_token.py
Normal file
54
app/core/preview_token.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user