Skip to main content

max / makenotwork

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