//! Custom-page editors (Custom Pages, Phase 3). //! //! Two split-pane editors -- one for the creator's profile, one per project -- //! sharing all logic via [`Target`]. Each has: HTML + CSS textareas, a live //! preview iframe pointing at the user-pages host, and a blocked-references //! panel fed by the sanitizer. Keystrokes debounce-save to a draft (so the //! preview updates without touching the live page); **Save** promotes the draft //! to the published columns; **Reset** clears back to the platform default. //! //! Routes (registered in `dashboard::dashboard_routes`): //! - `GET/POST /dashboard/custom-page` (+ `/draft`, `/reset`) -- profile //! - `GET/POST /dashboard/project/{slug}/custom-page` (+ `/draft`, `/reset`) //! //! Project routes resolve the project by `(owner, slug)`, so a non-owner gets a //! 404 and never reaches the editor. use askama::Template; use axum::{ extract::{Path, State}, response::{Html, IntoResponse, Redirect, Response}, Form, }; use serde::Deserialize; use tower_sessions::Session; use uuid::Uuid; use crate::{ auth::AuthUser, custom_pages::{self, RejectionKind}, db::{self, custom_pages::{KIND_PROJECT, KIND_USER}, Slug}, error::{AppError, Result}, helpers::get_csrf_token, AppState, }; /// App-side size caps, matching the DB `octet_length` backstops. const MAX_HTML: usize = 16 * 1024; const MAX_CSS: usize = 32 * 1024; #[derive(Debug, Deserialize)] pub struct CustomPageForm { #[serde(default)] pub custom_html: String, #[serde(default)] pub custom_css: String, #[serde(default, rename = "_csrf")] pub _csrf: Option, } /// A stripped reference, shaped for the editor panel. struct RejectionView { kind_label: &'static str, location: String, original_value: String, reason: String, } #[derive(Template)] #[template(path = "dashboard/custom_page_editor.html")] struct EditorTemplate { csrf_token: Option, heading: String, html_value: String, css_value: String, base_path: String, preview_url: String, live_url: String, locked: bool, rejections: Vec, } #[derive(Template)] #[template(path = "partials/custom_page_blocked.html")] struct BlockedPanelTemplate { rejections: Vec, } /// Resolved editing context, shared by the profile and project editors. struct Target { page_kind: &'static str, owner_id: db::UserId, page_id: Uuid, heading: String, current_html: String, current_css: String, base_path: String, live_url: String, /// Moderation kill switch on the owner: while true the editor is read-only. owner_locked: bool, } // ── Route handlers ─────────────────────────────────────────────────────────── pub async fn user_editor( State(state): State, AuthUser(user): AuthUser, session: Session, ) -> Result { let target = user_target(&state, user.id, user.username.as_ref()).await?; render_editor(&state, &session, target).await } pub async fn user_save( State(state): State, AuthUser(user): AuthUser, Form(form): Form, ) -> Result { let target = user_target(&state, user.id, user.username.as_ref()).await?; save(&state, target, form).await } pub async fn user_autosave( State(state): State, AuthUser(user): AuthUser, Form(form): Form, ) -> Result { let target = user_target(&state, user.id, user.username.as_ref()).await?; autosave(&state, target, form).await } pub async fn user_reset(State(state): State, AuthUser(user): AuthUser) -> Result { let target = user_target(&state, user.id, user.username.as_ref()).await?; reset(&state, target).await } pub async fn project_editor( State(state): State, AuthUser(user): AuthUser, session: Session, Path(slug): Path, ) -> Result { let target = project_target(&state, user.id, user.username.as_ref(), &slug).await?; render_editor(&state, &session, target).await } pub async fn project_save( State(state): State, AuthUser(user): AuthUser, Path(slug): Path, Form(form): Form, ) -> Result { let target = project_target(&state, user.id, user.username.as_ref(), &slug).await?; save(&state, target, form).await } pub async fn project_autosave( State(state): State, AuthUser(user): AuthUser, Path(slug): Path, Form(form): Form, ) -> Result { let target = project_target(&state, user.id, user.username.as_ref(), &slug).await?; autosave(&state, target, form).await } pub async fn project_reset( State(state): State, AuthUser(user): AuthUser, Path(slug): Path, ) -> Result { let target = project_target(&state, user.id, user.username.as_ref(), &slug).await?; reset(&state, target).await } // ── Target resolution ──────────────────────────────────────────────────────── async fn user_target(state: &AppState, user_id: db::UserId, handle: &str) -> Result { let user = db::users::get_user_by_id(&state.db, user_id) .await? .ok_or(AppError::NotFound)?; Ok(Target { page_kind: KIND_USER, owner_id: user.id, page_id: *user.id.as_uuid(), heading: "Profile page".to_string(), base_path: "/dashboard/custom-page".to_string(), live_url: format!("{}/{}", user_pages_origin(state), handle), owner_locked: user.custom_pages_locked, current_html: user.custom_html, current_css: user.custom_css, }) } async fn project_target(state: &AppState, user_id: db::UserId, handle: &str, slug: &str) -> Result { let slug = Slug::new(slug).map_err(|_| AppError::NotFound)?; // Owner-scoped lookup: a non-owner (or unknown slug) gets NotFound here. let project = db::projects::get_project_by_user_and_slug(&state.db, user_id, &slug) .await? .ok_or(AppError::NotFound)?; // The kill switch is per-creator; a locked owner can't edit any of their pages. let owner_locked = db::users::get_user_by_id(&state.db, user_id) .await? .map(|u| u.custom_pages_locked) .unwrap_or(false); Ok(Target { page_kind: KIND_PROJECT, owner_id: user_id, page_id: *project.id.as_uuid(), heading: project.title.clone(), current_html: project.custom_html, current_css: project.custom_css, base_path: format!("/dashboard/project/{}/custom-page", project.slug), live_url: format!("{}/{}/{}", user_pages_origin(state), handle, project.slug), owner_locked, }) } // ── Shared flows ───────────────────────────────────────────────────────────── async fn render_editor(state: &AppState, session: &Session, target: Target) -> Result { // Resume an in-progress draft, or seed one from the live source. let draft = db::custom_pages::get_or_create_draft( &state.db, target.owner_id, target.page_kind, target.page_id, &target.current_html, &target.current_css, ) .await?; let rejections = sanitize_rejections(state, &target, &draft.custom_html, &draft.custom_css); let preview_url = format!("{}/preview/{}", user_pages_origin(state), draft.id); let csrf_token = get_csrf_token(session).await; EditorTemplate { csrf_token, heading: target.heading, html_value: draft.custom_html, css_value: draft.custom_css, base_path: target.base_path, preview_url, live_url: target.live_url, locked: target.owner_locked, rejections, } .render() .map(|h| Html(h).into_response()) .map_err(|_| AppError::Internal(anyhow::anyhow!("template render failed"))) } async fn save(state: &AppState, target: Target, form: CustomPageForm) -> Result { if target.owner_locked { return Ok(status_html(false, "Custom pages are locked by moderation.")); } if let Some(msg) = oversize_message(&form) { return Ok(status_html(false, &msg)); } // Count what the sanitizer strips from the published page (by kind). if let Some(policy) = state.config.custom_pages_policy() { let (_h, _c, rejections) = custom_pages::sanitize_page(&form.custom_html, &form.custom_css, &target.page_id.to_string(), &policy); for r in &rejections { crate::metrics::record_sanitizer_rejection(metric_kind(&r.kind)); } } // Publish the page and clear its draft in one transaction: a crash between // the two previously left the page published but the stale draft alive, so // the editor reopened showing pre-save content over the saved page. let mut tx = state.db.begin().await?; match target.page_kind { KIND_USER => { db::users::update_user_custom_page(&mut *tx, target.owner_id, &form.custom_html, &form.custom_css).await?; } _ => { db::projects::update_project_custom_page( &mut *tx, db::ProjectId::from_uuid(target.page_id), target.owner_id, &form.custom_html, &form.custom_css, ) .await?; } } // Promote the draft: clear it so the next visit reflects the published page. db::custom_pages::delete_draft(&mut *tx, target.owner_id, target.page_kind, target.page_id).await?; tx.commit().await?; Ok(status_html(true, "Saved and published.")) } async fn autosave(state: &AppState, target: Target, form: CustomPageForm) -> Result { if target.owner_locked { let panel = BlockedPanelTemplate { rejections: vec![RejectionView { kind_label: "Locked", location: "page".to_string(), original_value: String::new(), reason: "Custom pages are locked by moderation.".to_string(), }], } .render() .map_err(|_| AppError::Internal(anyhow::anyhow!("template render failed")))?; return Ok(Html(panel).into_response()); } if let Some(msg) = oversize_message(&form) { // Surface the size error in the blocked panel without writing a draft. let panel = BlockedPanelTemplate { rejections: vec![RejectionView { kind_label: "Too large", location: "page".to_string(), original_value: String::new(), reason: msg, }], } .render() .map_err(|_| AppError::Internal(anyhow::anyhow!("template render failed")))?; return Ok(Html(panel).into_response()); } let draft = db::custom_pages::upsert_draft( &state.db, target.owner_id, target.page_kind, target.page_id, &form.custom_html, &form.custom_css, ) .await?; let rejections = sanitize_rejections(state, &target, &draft.custom_html, &draft.custom_css); let panel = BlockedPanelTemplate { rejections } .render() .map_err(|_| AppError::Internal(anyhow::anyhow!("template render failed")))?; // Out-of-band swap forces the preview iframe to reload the fresh draft. let preview_url = format!("{}/preview/{}", user_pages_origin(state), draft.id); let bust = chrono::Utc::now().timestamp_millis(); let oob = format!( "" ); Ok(Html(format!("{panel}{oob}")).into_response()) } async fn reset(state: &AppState, target: Target) -> Result { if target.owner_locked { return Ok(Redirect::to(&target.base_path).into_response()); } match target.page_kind { KIND_USER => db::users::reset_user_custom_page(&state.db, target.owner_id).await?, _ => { db::projects::reset_project_custom_page( &state.db, db::ProjectId::from_uuid(target.page_id), target.owner_id, ) .await? } } db::custom_pages::delete_draft(&state.db, target.owner_id, target.page_kind, target.page_id).await?; Ok(Redirect::to(&target.base_path).into_response()) } // ── Helpers ────────────────────────────────────────────────────────────────── /// Scheme + user-pages host, e.g. `https://u.makenot.work`. fn user_pages_origin(state: &AppState) -> String { let scheme = if state.config.host_url.starts_with("https") { "https" } else { "http" }; format!("{scheme}://{}", state.config.user_pages_host) } fn oversize_message(form: &CustomPageForm) -> Option { if form.custom_html.len() > MAX_HTML { Some(format!("HTML is too large ({} bytes; max {}).", form.custom_html.len(), MAX_HTML)) } else if form.custom_css.len() > MAX_CSS { Some(format!("CSS is too large ({} bytes; max {}).", form.custom_css.len(), MAX_CSS)) } else { None } } fn sanitize_rejections(state: &AppState, target: &Target, html: &str, css: &str) -> Vec { let Some(policy) = state.config.custom_pages_policy() else { return Vec::new(); }; let (_html, _css, rejections) = custom_pages::sanitize_page(html, css, &target.page_id.to_string(), &policy); rejections.into_iter().map(RejectionView::from).collect() } impl From for RejectionView { fn from(r: custom_pages::Rejection) -> Self { RejectionView { kind_label: kind_label(&r.kind), location: r.location, original_value: r.original_value, reason: r.reason, } } } /// Stable snake_case metric label per rejection kind (Prometheus dimension). fn metric_kind(kind: &RejectionKind) -> &'static str { match kind { RejectionKind::ExternalUrl => "external_url", RejectionKind::DisallowedScheme => "disallowed_scheme", RejectionKind::MalformedUrl => "malformed_url", RejectionKind::BlockedAtRule => "blocked_at_rule", RejectionKind::BlockedFunction => "blocked_function", RejectionKind::HidingProperty => "hiding_property", RejectionKind::AnimationBudget => "animation_budget", RejectionKind::ComplexityLimit => "complexity_limit", RejectionKind::MalformedCss => "malformed_css", } } fn kind_label(kind: &RejectionKind) -> &'static str { match kind { RejectionKind::ExternalUrl => "Off-platform link", RejectionKind::DisallowedScheme => "Blocked scheme", RejectionKind::MalformedUrl => "Bad URL", RejectionKind::BlockedAtRule => "Blocked CSS rule", RejectionKind::BlockedFunction => "Blocked CSS function", RejectionKind::HidingProperty => "Can't hide system slot", RejectionKind::AnimationBudget => "Animation too fast", RejectionKind::ComplexityLimit => "Too complex", RejectionKind::MalformedCss => "Invalid CSS", } } fn status_html(ok: bool, message: &str) -> Response { let class = if ok { "cp-status cp-ok" } else { "cp-status cp-err" }; Html(format!("{message}")).into_response() }