//! Custom-page rendering for the user-pages host (`u.makenot.work`). //! //! This host serves creator-authored HTML/CSS for profiles and project pages, //! and the default item layout wearing the parent project's styling. It is //! deliberately isolated from the apex: //! //! - **Cookieless.** The dispatch middleware short-circuits before the session //! layer, so no session cookie is ever set or read here. A sanitizer bypass //! cannot reach a logged-in session. //! - **Strict CSP.** `default-src 'none'`, no script at all, styles inline-only, //! media from self + CDN. Applied to every response from this host. //! - **Read-only.** Only GETs; all transactional actions link back to the apex. //! //! Routing on this host (path under `u.makenot.work`): //! `/{handle}` -> profile, `/{handle}/{project}` -> project, //! `/{handle}/{project}/{item}` -> item. `/static/*` falls through to the //! normal app so chrome assets and primitives resolve. //! //! Sanitization happens on render (Phase 2). The columns hold the creator's //! original source; a future write-time cache can pre-sanitize, but rendering //! through [`crate::custom_pages`] every time is the safe default and sits //! behind a 5-minute edge cache. use askama::Template; use axum::{ body::Body, extract::State, http::{header, HeaderMap, HeaderValue, Request, StatusCode}, middleware::Next, response::{Html, IntoResponse, Response}, }; use crate::{ custom_pages, db::{self, PricingKind, Slug, Username}, AppState, }; /// One entry in a project's file-list system slot. struct SlotItem { title: String, url: String, } #[derive(Template)] #[template(path = "custom/user.html")] struct UserPageTemplate { page_title: String, apex_url: String, canonical_url: String, creator_label: String, canvas_id: String, sanitized_css: String, sanitized_html: String, } #[derive(Template)] #[template(path = "custom/project.html")] struct ProjectPageTemplate { page_title: String, apex_url: String, canonical_url: String, creator_label: String, canvas_id: String, sanitized_css: String, sanitized_html: String, price_label: String, buy_url: String, items: Vec, } #[derive(Template)] #[template(path = "custom/item.html")] struct ItemPageTemplate { page_title: String, apex_url: String, canonical_url: String, creator_label: String, canvas_id: String, sanitized_css: String, item_title: String, item_description: Option, price_label: String, buy_url: String, } /// Dispatch middleware: intercept the user-pages host, pass everything else /// (and `/static`) through to the normal app. Placed outermost so it runs /// before the session and access-gate layers -- custom pages never touch them. pub async fn dispatch(State(state): State, req: Request, next: Next) -> Response { let host = extract_host(req.headers()); if host.as_deref() != Some(&*state.config.user_pages_host) { return next.run(req).await; } let path = req.uri().path().to_string(); // Chrome assets, primitives, favicon: serve from the normal static mount. if path.starts_with("/static/") || path == "/favicon.ico" || path == "/robots.txt" { return next.run(req).await; } serve(&state, &path).await } /// Render a custom page for `path` and stamp the strict CSP + security headers. async fn serve(state: &AppState, path: &str) -> Response { let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); // Editor preview: an unguessable draft id renders the in-progress page. let is_preview = matches!(segments.as_slice(), [first, _] if *first == "preview"); let result = match segments.as_slice() { // Bare host with no handle: send visitors to the apex. [] => return redirect_to_apex(state), [first, draft_id] if *first == "preview" => render_preview(state, draft_id).await, [handle] => render_user(state, handle).await, [handle, project] => render_project(state, handle, project).await, [handle, project, item] => render_item(state, handle, project, item).await, _ => Err(StatusCode::NOT_FOUND), }; let mut response = match result { Ok(resp) => resp, Err(code) => (code, "Not found").into_response(), }; apply_security_headers(response.headers_mut(), state, is_preview); if is_preview { // Previews are per-keystroke; never cache them. response .headers_mut() .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); } response } fn redirect_to_apex(state: &AppState) -> Response { let mut response = axum::response::Redirect::temporary(&state.config.host_url).into_response(); apply_security_headers(response.headers_mut(), state, false); response } async fn render_user(state: &AppState, handle: &str) -> Result { let username = Username::new(handle).map_err(|_| StatusCode::NOT_FOUND)?; let user = db::users::get_user_by_username(&state.db, &username) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? .ok_or(StatusCode::NOT_FOUND)?; let apex_url = state.config.host_url.to_string(); let canonical_url = format!("{apex_url}/u/{}", user.username); let creator_label = display_name(&user); // Locked (moderation) or empty -> render chrome + default-empty canvas only. let (sanitized_html, sanitized_css) = if user.custom_pages_locked { (String::new(), String::new()) } else { sanitize_user_page(state, &user) }; render_html(user_template(&user, apex_url, canonical_url, creator_label, sanitized_html, sanitized_css)) } fn user_template( user: &db::DbUser, apex_url: String, canonical_url: String, creator_label: String, sanitized_html: String, sanitized_css: String, ) -> UserPageTemplate { UserPageTemplate { page_title: format!("{creator_label} - makenot.work"), apex_url, canonical_url, creator_label, canvas_id: user.id.to_string(), sanitized_css, sanitized_html, } } fn render_html(template: impl Template) -> Result { template .render() .map(|h| Html(h).into_response()) .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) } async fn render_project(state: &AppState, handle: &str, project_slug: &str) -> Result { let username = Username::new(handle).map_err(|_| StatusCode::NOT_FOUND)?; let user = db::users::get_user_by_username(&state.db, &username) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? .ok_or(StatusCode::NOT_FOUND)?; let slug = Slug::new(project_slug).map_err(|_| StatusCode::NOT_FOUND)?; let project = db::projects::get_public_project_by_user_and_slug(&state.db, user.id, &slug) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? .ok_or(StatusCode::NOT_FOUND)?; let apex_url = state.config.host_url.to_string(); let canonical_url = format!("{apex_url}/p/{}", project.slug); // A locked owner falls back to the platform default everywhere. let (sanitized_html, sanitized_css) = if user.custom_pages_locked || project.custom_pages_updated_at.is_none() { (String::new(), String::new()) } else { sanitize_project_page(state, &project) }; let items = project_slot_items(state, &project, &apex_url).await; render_html(project_template(&user, &project, apex_url, canonical_url, sanitized_html, sanitized_css, items)) } /// The project's published items as file-list slot entries (links to the apex). async fn project_slot_items(state: &AppState, project: &db::DbProject, apex_url: &str) -> Vec { db::items::get_public_items_by_project(&state.db, project.id) .await .unwrap_or_default() .into_iter() .map(|it| SlotItem { title: it.title, url: format!("{apex_url}/i/{}", it.id), }) .collect() } #[allow(clippy::too_many_arguments)] fn project_template( user: &db::DbUser, project: &db::DbProject, apex_url: String, canonical_url: String, sanitized_html: String, sanitized_css: String, items: Vec, ) -> ProjectPageTemplate { ProjectPageTemplate { page_title: format!("{} - makenot.work", project.title), apex_url, creator_label: display_name(user), canvas_id: project.id.to_string(), sanitized_css, sanitized_html, price_label: project_price_label(project), buy_url: canonical_url.clone(), canonical_url, items, } } /// Render an editor draft preview (capability URL keyed by draft id). Branches /// on the draft's page kind and renders the same templates the live page uses, /// with the draft's sanitized content. async fn render_preview(state: &AppState, draft_id: &str) -> Result { let id = uuid::Uuid::parse_str(draft_id).map_err(|_| StatusCode::NOT_FOUND)?; let draft = db::custom_pages::get_draft(&state.db, id) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? .ok_or(StatusCode::NOT_FOUND)?; let apex_url = state.config.host_url.to_string(); let policy = state.config.custom_pages_policy(); match draft.page_kind.as_str() { db::custom_pages::KIND_USER => { let user = db::users::get_user_by_id(&state.db, db::UserId::from_uuid(draft.page_id)) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? .ok_or(StatusCode::NOT_FOUND)?; let (html, css) = match &policy { Some(p) => { let (h, c, _) = custom_pages::sanitize_page(&draft.custom_html, &draft.custom_css, &user.id.to_string(), p); (h, c) } None => (String::new(), String::new()), }; let canonical_url = format!("{apex_url}/u/{}", user.username); let label = display_name(&user); render_html(user_template(&user, apex_url, canonical_url, label, html, css)) } db::custom_pages::KIND_PROJECT => { let project = db::projects::get_project_by_id(&state.db, db::ProjectId::from_uuid(draft.page_id)) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? .ok_or(StatusCode::NOT_FOUND)?; let user = db::users::get_user_by_id(&state.db, project.user_id) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? .ok_or(StatusCode::NOT_FOUND)?; let (html, css) = match &policy { Some(p) => { let (h, c, _) = custom_pages::sanitize_page(&draft.custom_html, &draft.custom_css, &project.id.to_string(), p); (h, c) } None => (String::new(), String::new()), }; let canonical_url = format!("{apex_url}/p/{}", project.slug); let items = project_slot_items(state, &project, &apex_url).await; render_html(project_template(&user, &project, apex_url, canonical_url, html, css, items)) } _ => Err(StatusCode::NOT_FOUND), } } async fn render_item( state: &AppState, handle: &str, project_slug: &str, item_slug: &str, ) -> Result { let username = Username::new(handle).map_err(|_| StatusCode::NOT_FOUND)?; let user = db::users::get_user_by_username(&state.db, &username) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? .ok_or(StatusCode::NOT_FOUND)?; let slug = Slug::new(project_slug).map_err(|_| StatusCode::NOT_FOUND)?; let project = db::projects::get_public_project_by_user_and_slug(&state.db, user.id, &slug) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? .ok_or(StatusCode::NOT_FOUND)?; let item = db::items::get_item_by_project_and_slug(&state.db, project.id, item_slug) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? .ok_or(StatusCode::NOT_FOUND)?; if !item.is_public { return Err(StatusCode::NOT_FOUND); } let apex_url = state.config.host_url.to_string(); let canonical_url = format!("{apex_url}/i/{}", item.id); // Item pages inherit the parent project's CSS, re-scoped to the item canvas. // A locked owner falls back to the platform default everywhere. let sanitized_css = if user.custom_pages_locked || project.custom_pages_updated_at.is_none() { String::new() } else { sanitize_item_css(state, &project) }; let price_label = item_price_label(&item); let html = ItemPageTemplate { page_title: format!("{} - makenot.work", item.title), apex_url, canonical_url: canonical_url.clone(), creator_label: display_name(&user), canvas_id: project.id.to_string(), sanitized_css, item_title: item.title, item_description: item.description, price_label, buy_url: canonical_url, } .render() .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok(Html(html).into_response()) } // ── Sanitization (on render) ──────────────────────────────────────────────── fn sanitize_user_page(state: &AppState, user: &db::DbUser) -> (String, String) { let Some(policy) = state.config.custom_pages_policy() else { return (String::new(), String::new()); }; let (html, css, _rej) = custom_pages::sanitize_page(&user.custom_html, &user.custom_css, &user.id.to_string(), &policy); (html, css) } fn sanitize_project_page(state: &AppState, project: &db::DbProject) -> (String, String) { let Some(policy) = state.config.custom_pages_policy() else { return (String::new(), String::new()); }; let (html, css, _rej) = custom_pages::sanitize_page( &project.custom_html, &project.custom_css, &project.id.to_string(), &policy, ); (html, css) } fn sanitize_item_css(state: &AppState, project: &db::DbProject) -> String { let Some(policy) = state.config.custom_pages_policy() else { return String::new(); }; let (css, _rej) = custom_pages::sanitize_item_css(&project.custom_css, &project.id.to_string(), &policy); css } // ── Helpers ───────────────────────────────────────────────────────────────── fn display_name(user: &db::DbUser) -> String { user.display_name .clone() .filter(|n| !n.trim().is_empty()) .unwrap_or_else(|| user.username.to_string()) } fn project_price_label(project: &db::DbProject) -> String { match project.pricing_model { PricingKind::Free => "Free".to_string(), PricingKind::Subscription => "Subscription".to_string(), PricingKind::Pwyw => match project.pwyw_min_cents { Some(min) if min > 0 => format!("Pay what you want (from {})", dollars(min)), _ => "Pay what you want".to_string(), }, PricingKind::BuyOnce => dollars(project.price_cents), } } fn item_price_label(item: &db::DbItem) -> String { if item.pwyw_enabled { return "Pay what you want".to_string(); } if item.price_cents <= 0 { return "Free".to_string(); } dollars(item.price_cents) } fn dollars(cents: i32) -> String { let cents = cents.max(0); format!("${}.{:02}", cents / 100, cents % 100) } /// Bare hostname from the Host header, lowercased, port stripped. fn extract_host(headers: &HeaderMap) -> Option { headers .get(header::HOST) .and_then(|v| v.to_str().ok()) .map(|h| h.split(':').next().unwrap_or(h).to_ascii_lowercase()) } /// The strict CSP + hardening headers for every user-pages response. /// /// Public pages forbid framing entirely (`frame-ancestors 'none'`). A preview, /// though, must be embeddable in the apex editor iframe, so it allows exactly /// the apex origin to frame it -- nothing else. fn apply_security_headers(headers: &mut HeaderMap, state: &AppState, is_preview: bool) { let cdn = state.config.cdn_base_url.as_deref().unwrap_or(""); let media_src = if cdn.is_empty() { "'self'".to_string() } else { format!("'self' {cdn}") }; let frame_ancestors = if is_preview { format!("'self' {}", state.config.host_url) } else { "'none'".to_string() }; let csp = format!( "default-src 'none'; \ style-src 'self' 'unsafe-inline'; \ img-src {media_src}; \ media-src {media_src}; \ font-src 'self'; \ connect-src 'none'; \ base-uri 'none'; \ form-action 'none'; \ frame-ancestors {frame_ancestors}" ); if let Ok(value) = HeaderValue::from_str(&csp) { headers.insert( header::HeaderName::from_static("content-security-policy"), value, ); } // X-Frame-Options can't name an allowed origin, so for previews we omit it // and let CSP frame-ancestors govern (it permits only the apex editor). if !is_preview { headers.insert(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY")); } headers.insert(header::X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff")); headers.insert( header::REFERRER_POLICY, HeaderValue::from_static("strict-origin-when-cross-origin"), ); headers.insert( header::STRICT_TRANSPORT_SECURITY, HeaderValue::from_static("max-age=31536000; includeSubDomains"), ); headers.insert( header::HeaderName::from_static("permissions-policy"), HeaderValue::from_static("camera=(), microphone=(), geolocation=()"), ); // Live pages are edge-cached briefly; invalidation is implicit via content. // Previews are capability URLs that must always reflect the latest draft, so // they are never cached — `public, max-age` would both serve a creator stale // edits for up to the TTL and let a shared edge hand the draft to anyone who // replayed the URL during the window. let cache_control = if is_preview { "no-store" } else { "public, max-age=300" }; headers.insert( header::CACHE_CONTROL, HeaderValue::from_static(cache_control), ); } #[cfg(test)] mod tests { use super::*; #[test] fn dollars_formats_cents() { assert_eq!(dollars(0), "$0.00"); assert_eq!(dollars(500), "$5.00"); assert_eq!(dollars(1299), "$12.99"); assert_eq!(dollars(7), "$0.07"); } #[test] fn extract_host_strips_port_and_lowercases() { let mut h = HeaderMap::new(); h.insert(header::HOST, "U.MakeNot.Work:443".parse().unwrap()); assert_eq!(extract_host(&h), Some("u.makenot.work".to_string())); } }