Skip to main content

max / makenotwork

16.1 KB · 510 lines History Blame Raw
1 //! Custom-page editors (Custom Pages, Phase 3).
2 //!
3 //! Two split-pane editors: one for the creator's profile, one per project,
4 //! sharing all logic via [`Target`]. Each has: HTML + CSS textareas, a live
5 //! preview iframe pointing at the user-pages host, and a blocked-references
6 //! panel fed by the sanitizer. Keystrokes debounce-save to a draft (so the
7 //! preview updates without touching the live page); **Save** promotes the draft
8 //! to the published columns; **Reset** clears back to the platform default.
9 //!
10 //! Routes (registered in `dashboard::dashboard_routes`):
11 //! - `GET/POST /dashboard/custom-page` (+ `/draft`, `/reset`): profile
12 //! - `GET/POST /dashboard/project/{slug}/custom-page` (+ `/draft`, `/reset`)
13 //!
14 //! Project routes resolve the project by `(owner, slug)`, so a non-owner gets a
15 //! 404 and never reaches the editor.
16
17 use askama::Template;
18 use axum::{
19 Form,
20 extract::{Path, State},
21 response::{Html, IntoResponse, Redirect, Response},
22 };
23 use serde::Deserialize;
24 use tower_sessions::Session;
25 use uuid::Uuid;
26
27 use crate::{
28 auth::AuthUser,
29 config::Config,
30 custom_pages::{self, RejectionKind},
31 db::{
32 self, Slug,
33 custom_pages::{KIND_PROJECT, KIND_USER},
34 },
35 error::{AppError, Result},
36 helpers::get_csrf_token,
37 };
38 use sqlx::PgPool;
39
40 /// App-side size caps, matching the DB `octet_length` backstops.
41 const MAX_HTML: usize = 16 * 1024;
42 const MAX_CSS: usize = 32 * 1024;
43
44 #[derive(Debug, Deserialize)]
45 pub(super) struct CustomPageForm {
46 #[serde(default)]
47 pub custom_html: String,
48 #[serde(default)]
49 pub custom_css: String,
50 #[serde(default, rename = "_csrf")]
51 pub _csrf: Option<String>,
52 }
53
54 /// A stripped reference, shaped for the editor panel.
55 struct RejectionView {
56 kind_label: &'static str,
57 location: String,
58 original_value: String,
59 reason: String,
60 }
61
62 #[derive(Template)]
63 #[template(path = "dashboard/custom_page_editor.html")]
64 struct EditorTemplate {
65 csrf_token: Option<String>,
66 heading: String,
67 html_value: String,
68 css_value: String,
69 base_path: String,
70 preview_url: String,
71 live_url: String,
72 locked: bool,
73 rejections: Vec<RejectionView>,
74 }
75
76 #[derive(Template)]
77 #[template(path = "partials/custom_page_blocked.html")]
78 struct BlockedPanelTemplate {
79 rejections: Vec<RejectionView>,
80 }
81
82 /// Resolved editing context, shared by the profile and project editors.
83 struct Target {
84 page_kind: &'static str,
85 owner_id: db::UserId,
86 page_id: Uuid,
87 heading: String,
88 current_html: String,
89 current_css: String,
90 base_path: String,
91 live_url: String,
92 /// Moderation kill switch on the owner: while true the editor is read-only.
93 owner_locked: bool,
94 }
95
96 // --- Route handlers ---
97
98 #[tracing::instrument(skip_all, name = "custom_page::user_editor")]
99 pub(super) async fn user_editor(
100 State(db): State<PgPool>,
101 State(config): State<Config>,
102 AuthUser(user): AuthUser,
103 session: Session,
104 ) -> Result<Response> {
105 let target = user_target(&db, &config, user.id, user.username.as_ref()).await?;
106 render_editor(&db, &config, &session, target).await
107 }
108
109 #[tracing::instrument(skip_all, name = "custom_page::user_save")]
110 pub(super) async fn user_save(
111 State(db): State<PgPool>,
112 State(config): State<Config>,
113 AuthUser(user): AuthUser,
114 Form(form): Form<CustomPageForm>,
115 ) -> Result<Response> {
116 let target = user_target(&db, &config, user.id, user.username.as_ref()).await?;
117 save(&db, &config, target, form).await
118 }
119
120 #[tracing::instrument(skip_all, name = "custom_page::user_autosave")]
121 pub(super) async fn user_autosave(
122 State(db): State<PgPool>,
123 State(config): State<Config>,
124 AuthUser(user): AuthUser,
125 Form(form): Form<CustomPageForm>,
126 ) -> Result<Response> {
127 let target = user_target(&db, &config, user.id, user.username.as_ref()).await?;
128 autosave(&db, &config, target, form).await
129 }
130
131 #[tracing::instrument(skip_all, name = "custom_page::user_reset")]
132 pub(super) async fn user_reset(
133 State(db): State<PgPool>,
134 State(config): State<Config>,
135 AuthUser(user): AuthUser,
136 ) -> Result<Response> {
137 let target = user_target(&db, &config, user.id, user.username.as_ref()).await?;
138 reset(&db, target).await
139 }
140
141 #[tracing::instrument(skip_all, name = "custom_page::project_editor")]
142 pub(super) async fn project_editor(
143 State(db): State<PgPool>,
144 State(config): State<Config>,
145 AuthUser(user): AuthUser,
146 session: Session,
147 Path(slug): Path<String>,
148 ) -> Result<Response> {
149 let target = project_target(&db, &config, user.id, user.username.as_ref(), &slug).await?;
150 render_editor(&db, &config, &session, target).await
151 }
152
153 #[tracing::instrument(skip_all, name = "custom_page::project_save")]
154 pub(super) async fn project_save(
155 State(db): State<PgPool>,
156 State(config): State<Config>,
157 AuthUser(user): AuthUser,
158 Path(slug): Path<String>,
159 Form(form): Form<CustomPageForm>,
160 ) -> Result<Response> {
161 let target = project_target(&db, &config, user.id, user.username.as_ref(), &slug).await?;
162 save(&db, &config, target, form).await
163 }
164
165 #[tracing::instrument(skip_all, name = "custom_page::project_autosave")]
166 pub(super) async fn project_autosave(
167 State(db): State<PgPool>,
168 State(config): State<Config>,
169 AuthUser(user): AuthUser,
170 Path(slug): Path<String>,
171 Form(form): Form<CustomPageForm>,
172 ) -> Result<Response> {
173 let target = project_target(&db, &config, user.id, user.username.as_ref(), &slug).await?;
174 autosave(&db, &config, target, form).await
175 }
176
177 #[tracing::instrument(skip_all, name = "custom_page::project_reset")]
178 pub(super) async fn project_reset(
179 State(db): State<PgPool>,
180 State(config): State<Config>,
181 AuthUser(user): AuthUser,
182 Path(slug): Path<String>,
183 ) -> Result<Response> {
184 let target = project_target(&db, &config, user.id, user.username.as_ref(), &slug).await?;
185 reset(&db, target).await
186 }
187
188 // --- Target resolution ---
189
190 async fn user_target(
191 db: &PgPool,
192 config: &Config,
193 user_id: db::UserId,
194 handle: &str,
195 ) -> Result<Target> {
196 let user = db::users::get_user_by_id(db, user_id)
197 .await?
198 .ok_or(AppError::NotFound)?;
199 Ok(Target {
200 page_kind: KIND_USER,
201 owner_id: user.id,
202 page_id: *user.id.as_uuid(),
203 heading: "Profile page".to_string(),
204 base_path: "/dashboard/custom-page".to_string(),
205 live_url: format!("{}/{}", user_pages_origin(config), handle),
206 owner_locked: user.custom_pages_locked,
207 current_html: user.custom_html,
208 current_css: user.custom_css,
209 })
210 }
211
212 async fn project_target(
213 db: &PgPool,
214 config: &Config,
215 user_id: db::UserId,
216 handle: &str,
217 slug: &str,
218 ) -> Result<Target> {
219 let slug = Slug::new(slug).map_err(|_| AppError::NotFound)?;
220 // Owner-scoped lookup: a non-owner (or unknown slug) gets NotFound here.
221 let project = db::projects::get_project_by_user_and_slug(db, user_id, &slug)
222 .await?
223 .ok_or(AppError::NotFound)?;
224 // The kill switch is per-creator; a locked owner can't edit any of their pages.
225 let owner_locked = db::users::get_user_by_id(db, user_id)
226 .await?
227 .is_some_and(|u| u.custom_pages_locked);
228 Ok(Target {
229 page_kind: KIND_PROJECT,
230 owner_id: user_id,
231 page_id: *project.id.as_uuid(),
232 heading: project.title.clone(),
233 current_html: project.custom_html,
234 current_css: project.custom_css,
235 base_path: format!("/dashboard/project/{}/custom-page", project.slug),
236 live_url: format!("{}/{}/{}", user_pages_origin(config), handle, project.slug),
237 owner_locked,
238 })
239 }
240
241 // --- Shared flows ---
242
243 async fn render_editor(
244 db: &PgPool,
245 config: &Config,
246 session: &Session,
247 target: Target,
248 ) -> Result<Response> {
249 // Resume an in-progress draft, or seed one from the live source.
250 let draft = db::custom_pages::get_or_create_draft(
251 db,
252 target.owner_id,
253 target.page_kind,
254 target.page_id,
255 &target.current_html,
256 &target.current_css,
257 )
258 .await?;
259
260 let rejections = sanitize_rejections(config, &target, &draft.custom_html, &draft.custom_css);
261 let preview_url = format!("{}/preview/{}", user_pages_origin(config), draft.id);
262 let csrf_token = get_csrf_token(session).await;
263
264 EditorTemplate {
265 csrf_token,
266 heading: target.heading,
267 html_value: draft.custom_html,
268 css_value: draft.custom_css,
269 base_path: target.base_path,
270 preview_url,
271 live_url: target.live_url,
272 locked: target.owner_locked,
273 rejections,
274 }
275 .render()
276 .map(|h| Html(h).into_response())
277 .map_err(|_| AppError::Internal(anyhow::anyhow!("template render failed")))
278 }
279
280 async fn save(
281 db: &PgPool,
282 config: &Config,
283 target: Target,
284 form: CustomPageForm,
285 ) -> Result<Response> {
286 if target.owner_locked {
287 return Ok(status_html(false, "Custom pages are locked by moderation."));
288 }
289 if let Some(msg) = oversize_message(&form) {
290 return Ok(status_html(false, &msg));
291 }
292
293 // Count what the sanitizer strips from the published page (by kind).
294 if let Some(policy) = config.custom_pages_policy() {
295 let (_h, _c, rejections) = custom_pages::sanitize_page(
296 &form.custom_html,
297 &form.custom_css,
298 &target.page_id.to_string(),
299 &policy,
300 );
301 for r in &rejections {
302 crate::metrics::record_sanitizer_rejection(metric_kind(&r.kind));
303 }
304 }
305
306 // Publish the page and clear its draft in one transaction: a crash between
307 // the two previously left the page published but the stale draft alive, so
308 // the editor reopened showing pre-save content over the saved page.
309 let mut tx = db.begin().await?;
310 match target.page_kind {
311 KIND_USER => {
312 db::users::update_user_custom_page(
313 &mut *tx,
314 target.owner_id,
315 &form.custom_html,
316 &form.custom_css,
317 )
318 .await?;
319 }
320 _ => {
321 db::projects::update_project_custom_page(
322 &mut *tx,
323 db::ProjectId::from_uuid(target.page_id),
324 target.owner_id,
325 &form.custom_html,
326 &form.custom_css,
327 )
328 .await?;
329 }
330 }
331 // Promote the draft: clear it so the next visit reflects the published page.
332 db::custom_pages::delete_draft(&mut *tx, target.owner_id, target.page_kind, target.page_id)
333 .await?;
334 tx.commit().await?;
335
336 Ok(status_html(true, "Saved and published."))
337 }
338
339 async fn autosave(
340 db: &PgPool,
341 config: &Config,
342 target: Target,
343 form: CustomPageForm,
344 ) -> Result<Response> {
345 if target.owner_locked {
346 let panel = BlockedPanelTemplate {
347 rejections: vec![RejectionView {
348 kind_label: "Locked",
349 location: "page".to_string(),
350 original_value: String::new(),
351 reason: "Custom pages are locked by moderation.".to_string(),
352 }],
353 }
354 .render()
355 .map_err(|_| AppError::Internal(anyhow::anyhow!("template render failed")))?;
356 return Ok(Html(panel).into_response());
357 }
358 if let Some(msg) = oversize_message(&form) {
359 // Surface the size error in the blocked panel without writing a draft.
360 let panel = BlockedPanelTemplate {
361 rejections: vec![RejectionView {
362 kind_label: "Too large",
363 location: "page".to_string(),
364 original_value: String::new(),
365 reason: msg,
366 }],
367 }
368 .render()
369 .map_err(|_| AppError::Internal(anyhow::anyhow!("template render failed")))?;
370 return Ok(Html(panel).into_response());
371 }
372
373 let draft = db::custom_pages::upsert_draft(
374 db,
375 target.owner_id,
376 target.page_kind,
377 target.page_id,
378 &form.custom_html,
379 &form.custom_css,
380 )
381 .await?;
382
383 let rejections = sanitize_rejections(config, &target, &draft.custom_html, &draft.custom_css);
384 let panel = BlockedPanelTemplate { rejections }
385 .render()
386 .map_err(|_| AppError::Internal(anyhow::anyhow!("template render failed")))?;
387
388 // Out-of-band swap forces the preview iframe to reload the fresh draft.
389 let preview_url = format!("{}/preview/{}", user_pages_origin(config), draft.id);
390 let bust = chrono::Utc::now().timestamp_millis();
391 let oob = format!(
392 "<iframe id=\"cp-preview\" class=\"cp-preview\" title=\"Live preview\" \
393 hx-swap-oob=\"true\" src=\"{preview_url}?t={bust}\"></iframe>"
394 );
395
396 Ok(Html(format!("{panel}{oob}")).into_response())
397 }
398
399 async fn reset(db: &PgPool, target: Target) -> Result<Response> {
400 if target.owner_locked {
401 return Ok(Redirect::to(&target.base_path).into_response());
402 }
403 match target.page_kind {
404 KIND_USER => db::users::reset_user_custom_page(db, target.owner_id).await?,
405 _ => {
406 db::projects::reset_project_custom_page(
407 db,
408 db::ProjectId::from_uuid(target.page_id),
409 target.owner_id,
410 )
411 .await?;
412 }
413 }
414 db::custom_pages::delete_draft(db, target.owner_id, target.page_kind, target.page_id).await?;
415 Ok(Redirect::to(&target.base_path).into_response())
416 }
417
418 // --- Helpers ---
419
420 /// Scheme + user-pages host, e.g. `https://u.makenot.work`.
421 fn user_pages_origin(config: &Config) -> String {
422 let scheme = if config.host_url.starts_with("https") {
423 "https"
424 } else {
425 "http"
426 };
427 format!("{scheme}://{}", config.user_pages_host)
428 }
429
430 fn oversize_message(form: &CustomPageForm) -> Option<String> {
431 if form.custom_html.len() > MAX_HTML {
432 Some(format!(
433 "HTML is too large ({} bytes; max {}).",
434 form.custom_html.len(),
435 MAX_HTML
436 ))
437 } else if form.custom_css.len() > MAX_CSS {
438 Some(format!(
439 "CSS is too large ({} bytes; max {}).",
440 form.custom_css.len(),
441 MAX_CSS
442 ))
443 } else {
444 None
445 }
446 }
447
448 fn sanitize_rejections(
449 config: &Config,
450 target: &Target,
451 html: &str,
452 css: &str,
453 ) -> Vec<RejectionView> {
454 let Some(policy) = config.custom_pages_policy() else {
455 return Vec::new();
456 };
457 let (_html, _css, rejections) =
458 custom_pages::sanitize_page(html, css, &target.page_id.to_string(), &policy);
459 rejections.into_iter().map(RejectionView::from).collect()
460 }
461
462 impl From<custom_pages::Rejection> for RejectionView {
463 fn from(r: custom_pages::Rejection) -> Self {
464 RejectionView {
465 kind_label: kind_label(&r.kind),
466 location: r.location,
467 original_value: r.original_value,
468 reason: r.reason,
469 }
470 }
471 }
472
473 /// Stable snake_case metric label per rejection kind (Prometheus dimension).
474 fn metric_kind(kind: &RejectionKind) -> &'static str {
475 match kind {
476 RejectionKind::ExternalUrl => "external_url",
477 RejectionKind::DisallowedScheme => "disallowed_scheme",
478 RejectionKind::MalformedUrl => "malformed_url",
479 RejectionKind::BlockedAtRule => "blocked_at_rule",
480 RejectionKind::BlockedFunction => "blocked_function",
481 RejectionKind::HidingProperty => "hiding_property",
482 RejectionKind::AnimationBudget => "animation_budget",
483 RejectionKind::ComplexityLimit => "complexity_limit",
484 RejectionKind::MalformedCss => "malformed_css",
485 }
486 }
487
488 fn kind_label(kind: &RejectionKind) -> &'static str {
489 match kind {
490 RejectionKind::ExternalUrl => "Off-platform link",
491 RejectionKind::DisallowedScheme => "Blocked scheme",
492 RejectionKind::MalformedUrl => "Bad URL",
493 RejectionKind::BlockedAtRule => "Blocked CSS rule",
494 RejectionKind::BlockedFunction => "Blocked CSS function",
495 RejectionKind::HidingProperty => "Can't hide system slot",
496 RejectionKind::AnimationBudget => "Animation too fast",
497 RejectionKind::ComplexityLimit => "Too complex",
498 RejectionKind::MalformedCss => "Invalid CSS",
499 }
500 }
501
502 fn status_html(ok: bool, message: &str) -> Response {
503 let class = if ok {
504 "cp-status cp-ok"
505 } else {
506 "cp-status cp-err"
507 };
508 Html(format!("<span class=\"{class}\">{message}</span>")).into_response()
509 }
510