Skip to main content

max / makenotwork

19.7 KB · 567 lines History Blame Raw
1 //! Custom-page rendering for the user-pages host (`u.makenot.work`).
2 //!
3 //! This host serves creator-authored HTML/CSS for profiles and project pages,
4 //! and the default item layout wearing the parent project's styling. It is
5 //! deliberately isolated from the apex:
6 //!
7 //! - **Cookieless.** The dispatch middleware short-circuits before the session
8 //! layer, so no session cookie is ever set or read here. A sanitizer bypass
9 //! cannot reach a logged-in session.
10 //! - **Strict CSP.** `default-src 'none'`, no script at all, styles inline-only,
11 //! media from self + CDN. Applied to every response from this host.
12 //! - **Read-only.** Only GETs; all transactional actions link back to the apex.
13 //!
14 //! Routing on this host (path under `u.makenot.work`):
15 //! `/{handle}` -> profile, `/{handle}/{project}` -> project,
16 //! `/{handle}/{project}/{item}` -> item. `/static/*` falls through to the
17 //! normal app, which is what serves a creator's own uploads and the favicon.
18 //! The pages themselves link nothing from it: the strip's styling is inline
19 //! and no script is loaded at all.
20 //!
21 //! Sanitization happens on render (Phase 2). The columns hold the creator's
22 //! original source; a future write-time cache can pre-sanitize, but rendering
23 //! through [`crate::custom_pages`] every time is the safe default and sits
24 //! behind a 5-minute edge cache.
25 //!
26 //! The three documents are described rather than templated, as of quasicoherent
27 //! `48a6e9e5`. This module resolves and sanitizes; `crate::quasi::custom_page`
28 //! says what the pages are and draws them. `templates/custom/` is gone with the
29 //! change, the chrome partials included.
30
31 use axum::{
32 body::Body,
33 extract::State,
34 http::{HeaderMap, HeaderValue, Request, StatusCode, header},
35 middleware::Next,
36 response::{Html, IntoResponse, Response},
37 };
38
39 use crate::{
40 config::Config,
41 custom_pages,
42 db::{self, PricingKind, Slug, Username},
43 quasi::custom_page,
44 };
45 use sqlx::PgPool;
46
47 /// Dispatch middleware: intercept the user-pages host, pass everything else
48 /// (and `/static`) through to the normal app. Placed outermost so it runs
49 /// before the session and access-gate layers, custom pages never touch them.
50 pub async fn dispatch(
51 State(db): State<PgPool>,
52 State(config): State<Config>,
53 req: Request<Body>,
54 next: Next,
55 ) -> Response {
56 let host = extract_host(req.headers());
57 if host.as_deref() != Some(&*config.user_pages_host) {
58 return next.run(req).await;
59 }
60
61 let path = req.uri().path().to_string();
62 // Chrome assets, primitives, favicon: serve from the normal static mount.
63 if path.starts_with("/static/") || path == "/favicon.ico" || path == "/robots.txt" {
64 return next.run(req).await;
65 }
66
67 serve(&db, &config, &path).await
68 }
69
70 /// Render a custom page for `path` and stamp the strict CSP + security headers.
71 async fn serve(db: &PgPool, config: &Config, path: &str) -> Response {
72 let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
73
74 // Editor preview: an unguessable draft id renders the in-progress page.
75 let is_preview = matches!(segments.as_slice(), [first, _] if *first == "preview");
76
77 let result = match segments.as_slice() {
78 // Bare host with no handle: send visitors to the apex.
79 [] => return redirect_to_apex(config),
80 [first, draft_id] if *first == "preview" => render_preview(db, config, draft_id).await,
81 [handle] => render_user(db, config, handle).await,
82 [handle, project] => render_project(db, config, handle, project).await,
83 [handle, project, item] => render_item(db, config, handle, project, item).await,
84 _ => Err(StatusCode::NOT_FOUND),
85 };
86
87 let mut response = match result {
88 Ok(resp) => resp,
89 Err(code) => (code, "Not found").into_response(),
90 };
91 apply_security_headers(response.headers_mut(), config, is_preview);
92 if is_preview {
93 // Previews are per-keystroke; never cache them.
94 response
95 .headers_mut()
96 .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
97 }
98 response
99 }
100
101 fn redirect_to_apex(config: &Config) -> Response {
102 let mut response = axum::response::Redirect::temporary(&config.host_url).into_response();
103 apply_security_headers(response.headers_mut(), config, false);
104 response
105 }
106
107 async fn render_user(db: &PgPool, config: &Config, handle: &str) -> Result<Response, StatusCode> {
108 let username = Username::new(handle).map_err(|_| StatusCode::NOT_FOUND)?;
109 let user = db::users::get_user_by_username(db, &username)
110 .await
111 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
112 .ok_or(StatusCode::NOT_FOUND)?;
113
114 let apex_url = config.host_url.to_string();
115 let canonical_url = format!("{apex_url}/u/{}", user.username);
116 let creator_label = display_name(&user);
117
118 // Locked (moderation) or empty -> render chrome + default-empty canvas only.
119 let (sanitized_html, sanitized_css) = if user.custom_pages_locked {
120 (String::new(), String::new())
121 } else {
122 sanitize_user_page(config, &user)
123 };
124
125 render(&custom_page::user(&user_view(
126 &user,
127 apex_url,
128 canonical_url,
129 creator_label,
130 sanitized_html,
131 sanitized_css,
132 )))
133 }
134
135 /// What a profile page is, as the description wants it.
136 ///
137 /// Also the whole of what a project page's strip and canvas need, which is why
138 /// [`custom_page::ProjectView`] carries one rather than repeating its seven
139 /// fields.
140 fn user_view(
141 user: &db::DbUser,
142 apex_url: String,
143 canonical_url: String,
144 creator_label: String,
145 sanitized_html: String,
146 sanitized_css: String,
147 ) -> custom_page::UserView {
148 custom_page::UserView {
149 page_title: format!("{creator_label} - makenot.work"),
150 apex_url,
151 canonical_url,
152 creator_label,
153 canvas_id: user.id.to_string(),
154 sanitized_css,
155 sanitized_html,
156 }
157 }
158
159 /// The described screen as a response.
160 ///
161 /// Infallible, unlike the `render_html` it replaces: a description is drawn
162 /// rather than rendered from a template that can fail to parse, so there is no
163 /// error branch left to map to a 500.
164 fn render(screen: &quasi_router::Screen) -> Result<Response, StatusCode> {
165 Ok(Html(custom_page::document(screen)).into_response())
166 }
167
168 async fn render_project(
169 db: &PgPool,
170 config: &Config,
171 handle: &str,
172 project_slug: &str,
173 ) -> Result<Response, StatusCode> {
174 let username = Username::new(handle).map_err(|_| StatusCode::NOT_FOUND)?;
175 let user = db::users::get_user_by_username(db, &username)
176 .await
177 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
178 .ok_or(StatusCode::NOT_FOUND)?;
179 let slug = Slug::new(project_slug).map_err(|_| StatusCode::NOT_FOUND)?;
180 let project = db::projects::get_public_project_by_user_and_slug(db, user.id, &slug)
181 .await
182 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
183 .ok_or(StatusCode::NOT_FOUND)?;
184
185 let apex_url = config.host_url.to_string();
186 let canonical_url = format!("{apex_url}/p/{}", project.slug);
187
188 // A locked owner falls back to the platform default everywhere.
189 let (sanitized_html, sanitized_css) =
190 if user.custom_pages_locked || project.custom_pages_updated_at.is_none() {
191 (String::new(), String::new())
192 } else {
193 sanitize_project_page(config, &project)
194 };
195
196 let files = project_files(db, &project, &apex_url).await;
197 render(&custom_page::project(&project_view(
198 &user,
199 &project,
200 apex_url,
201 canonical_url,
202 sanitized_html,
203 sanitized_css,
204 files,
205 )))
206 }
207
208 /// The project's published items as file-list entries (links to the apex).
209 async fn project_files(
210 db: &PgPool,
211 project: &db::DbProject,
212 apex_url: &str,
213 ) -> Vec<custom_page::File> {
214 db::items::get_public_items_by_project(db, project.id)
215 .await
216 .unwrap_or_default()
217 .into_iter()
218 .map(|it| custom_page::File {
219 title: it.title,
220 url: format!("{apex_url}/i/{}", it.id),
221 })
222 .collect()
223 }
224
225 /// What a project page is, as the description wants it.
226 ///
227 /// The title is the project's and the canvas id is the project's, so this is
228 /// not the profile's view with two fields added: `canvas_id` in particular
229 /// scopes the *project's* stylesheet, and passing the owner's would unstyle
230 /// every project page.
231 #[allow(clippy::too_many_arguments)]
232 fn project_view(
233 user: &db::DbUser,
234 project: &db::DbProject,
235 apex_url: String,
236 canonical_url: String,
237 sanitized_html: String,
238 sanitized_css: String,
239 files: Vec<custom_page::File>,
240 ) -> custom_page::ProjectView {
241 custom_page::ProjectView {
242 page: custom_page::UserView {
243 page_title: format!("{} - makenot.work", project.title),
244 apex_url,
245 canonical_url: canonical_url.clone(),
246 creator_label: display_name(user),
247 canvas_id: project.id.to_string(),
248 sanitized_css,
249 sanitized_html,
250 },
251 price_label: project_price_label(project),
252 buy_url: canonical_url,
253 files,
254 }
255 }
256
257 /// Render an editor draft preview (capability URL keyed by draft id). Branches
258 /// on the draft's page kind and renders the same templates the live page uses,
259 /// with the draft's sanitized content.
260 async fn render_preview(
261 db: &PgPool,
262 config: &Config,
263 draft_id: &str,
264 ) -> Result<Response, StatusCode> {
265 let id = uuid::Uuid::parse_str(draft_id).map_err(|_| StatusCode::NOT_FOUND)?;
266 let draft = db::custom_pages::get_draft(db, id)
267 .await
268 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
269 .ok_or(StatusCode::NOT_FOUND)?;
270
271 let apex_url = config.host_url.to_string();
272 let policy = config.custom_pages_policy();
273
274 match draft.page_kind.as_str() {
275 db::custom_pages::KIND_USER => {
276 let user = db::users::get_user_by_id(db, db::UserId::from_uuid(draft.page_id))
277 .await
278 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
279 .ok_or(StatusCode::NOT_FOUND)?;
280 let (html, css) = match &policy {
281 Some(p) => {
282 let (h, c, _) = custom_pages::sanitize_page(
283 &draft.custom_html,
284 &draft.custom_css,
285 &user.id.to_string(),
286 p,
287 );
288 (h, c)
289 }
290 None => (String::new(), String::new()),
291 };
292 let canonical_url = format!("{apex_url}/u/{}", user.username);
293 let label = display_name(&user);
294 render(&custom_page::user(&user_view(
295 &user,
296 apex_url,
297 canonical_url,
298 label,
299 html,
300 css,
301 )))
302 }
303 db::custom_pages::KIND_PROJECT => {
304 let project =
305 db::projects::get_project_by_id(db, db::ProjectId::from_uuid(draft.page_id))
306 .await
307 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
308 .ok_or(StatusCode::NOT_FOUND)?;
309 let user = db::users::get_user_by_id(db, project.user_id)
310 .await
311 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
312 .ok_or(StatusCode::NOT_FOUND)?;
313 let (html, css) = match &policy {
314 Some(p) => {
315 let (h, c, _) = custom_pages::sanitize_page(
316 &draft.custom_html,
317 &draft.custom_css,
318 &project.id.to_string(),
319 p,
320 );
321 (h, c)
322 }
323 None => (String::new(), String::new()),
324 };
325 let canonical_url = format!("{apex_url}/p/{}", project.slug);
326 let files = project_files(db, &project, &apex_url).await;
327 render(&custom_page::project(&project_view(
328 &user,
329 &project,
330 apex_url,
331 canonical_url,
332 html,
333 css,
334 files,
335 )))
336 }
337 _ => Err(StatusCode::NOT_FOUND),
338 }
339 }
340
341 async fn render_item(
342 db: &PgPool,
343 config: &Config,
344 handle: &str,
345 project_slug: &str,
346 item_slug: &str,
347 ) -> Result<Response, StatusCode> {
348 let username = Username::new(handle).map_err(|_| StatusCode::NOT_FOUND)?;
349 let user = db::users::get_user_by_username(db, &username)
350 .await
351 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
352 .ok_or(StatusCode::NOT_FOUND)?;
353 let slug = Slug::new(project_slug).map_err(|_| StatusCode::NOT_FOUND)?;
354 let project = db::projects::get_public_project_by_user_and_slug(db, user.id, &slug)
355 .await
356 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
357 .ok_or(StatusCode::NOT_FOUND)?;
358 let item = db::items::get_item_by_project_and_slug(db, project.id, item_slug)
359 .await
360 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
361 .ok_or(StatusCode::NOT_FOUND)?;
362
363 if !item.is_public {
364 return Err(StatusCode::NOT_FOUND);
365 }
366
367 let apex_url = config.host_url.to_string();
368 let canonical_url = format!("{apex_url}/i/{}", item.id);
369
370 // Item pages inherit the parent project's CSS, re-scoped to the item canvas.
371 // A locked owner falls back to the platform default everywhere.
372 let sanitized_css = if user.custom_pages_locked || project.custom_pages_updated_at.is_none() {
373 String::new()
374 } else {
375 sanitize_item_css(config, &project)
376 };
377
378 render(&custom_page::item(&custom_page::ItemView {
379 page_title: format!("{} - makenot.work", item.title),
380 apex_url,
381 canonical_url: canonical_url.clone(),
382 creator_label: display_name(&user),
383 // The *project's* id: an item page wears the parent's stylesheet,
384 // re-scoped to the item canvas by `sanitize_item_css`.
385 canvas_id: project.id.to_string(),
386 sanitized_css,
387 price_label: item_price_label(&item),
388 item_title: item.title,
389 item_description: item.description,
390 buy_url: canonical_url,
391 }))
392 }
393
394 // --- Sanitization (on render) ---
395
396 fn sanitize_user_page(config: &Config, user: &db::DbUser) -> (String, String) {
397 let Some(policy) = config.custom_pages_policy() else {
398 return (String::new(), String::new());
399 };
400 let (html, css, _rej) = custom_pages::sanitize_page(
401 &user.custom_html,
402 &user.custom_css,
403 &user.id.to_string(),
404 &policy,
405 );
406 (html, css)
407 }
408
409 fn sanitize_project_page(config: &Config, project: &db::DbProject) -> (String, String) {
410 let Some(policy) = config.custom_pages_policy() else {
411 return (String::new(), String::new());
412 };
413 let (html, css, _rej) = custom_pages::sanitize_page(
414 &project.custom_html,
415 &project.custom_css,
416 &project.id.to_string(),
417 &policy,
418 );
419 (html, css)
420 }
421
422 fn sanitize_item_css(config: &Config, project: &db::DbProject) -> String {
423 let Some(policy) = config.custom_pages_policy() else {
424 return String::new();
425 };
426 let (css, _rej) =
427 custom_pages::sanitize_item_css(&project.custom_css, &project.id.to_string(), &policy);
428 css
429 }
430
431 // --- Helpers ---
432
433 fn display_name(user: &db::DbUser) -> String {
434 user.display_name
435 .clone()
436 .filter(|n| !n.trim().is_empty())
437 .unwrap_or_else(|| user.username.to_string())
438 }
439
440 fn project_price_label(project: &db::DbProject) -> String {
441 match project.pricing_model {
442 PricingKind::Free => "Free".to_string(),
443 PricingKind::Subscription => "Subscription".to_string(),
444 PricingKind::Pwyw => match project.pwyw_min_cents {
445 Some(min) if min > 0 => format!("Pay what you want (from {})", dollars(min)),
446 _ => "Pay what you want".to_string(),
447 },
448 PricingKind::BuyOnce => dollars(project.price_cents),
449 }
450 }
451
452 fn item_price_label(item: &db::DbItem) -> String {
453 if item.pwyw_enabled {
454 return "Pay what you want".to_string();
455 }
456 if item.price_cents <= 0 {
457 return "Free".to_string();
458 }
459 dollars(item.price_cents)
460 }
461
462 fn dollars(cents: i32) -> String {
463 // Route through the canonical formatter so the cents→dollars arithmetic has a
464 // single source of truth (Run 9). Clamp negatives to 0, custom pages never
465 // show a negative price.
466 format!("${}", crate::formatting::format_dollars_plain(cents.max(0)))
467 }
468
469 /// Bare hostname from the Host header, lowercased, port stripped.
470 fn extract_host(headers: &HeaderMap) -> Option<String> {
471 headers
472 .get(header::HOST)
473 .and_then(|v| v.to_str().ok())
474 .map(|h| h.split(':').next().unwrap_or(h).to_ascii_lowercase())
475 }
476
477 /// The strict CSP + hardening headers for every user-pages response.
478 ///
479 /// Public pages forbid framing entirely (`frame-ancestors 'none'`). A preview,
480 /// though, must be embeddable in the apex editor iframe, so it allows exactly
481 /// the apex origin to frame it, nothing else.
482 fn apply_security_headers(headers: &mut HeaderMap, config: &Config, is_preview: bool) {
483 let cdn = config.cdn_base_url.as_str();
484 let media_src = if cdn.is_empty() {
485 "'self'".to_string()
486 } else {
487 format!("'self' {cdn}")
488 };
489 let frame_ancestors = if is_preview {
490 format!("'self' {}", config.host_url)
491 } else {
492 "'none'".to_string()
493 };
494 let csp = format!(
495 "default-src 'none'; \
496 style-src 'self' 'unsafe-inline'; \
497 img-src {media_src}; \
498 media-src {media_src}; \
499 font-src 'self'; \
500 connect-src 'none'; \
501 base-uri 'none'; \
502 form-action 'none'; \
503 frame-ancestors {frame_ancestors}"
504 );
505 if let Ok(value) = HeaderValue::from_str(&csp) {
506 headers.insert(
507 header::HeaderName::from_static("content-security-policy"),
508 value,
509 );
510 }
511 // X-Frame-Options can't name an allowed origin, so for previews we omit it
512 // and let CSP frame-ancestors govern (it permits only the apex editor).
513 if !is_preview {
514 headers.insert(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY"));
515 }
516 headers.insert(
517 header::X_CONTENT_TYPE_OPTIONS,
518 HeaderValue::from_static("nosniff"),
519 );
520 headers.insert(
521 header::REFERRER_POLICY,
522 HeaderValue::from_static("strict-origin-when-cross-origin"),
523 );
524 headers.insert(
525 header::STRICT_TRANSPORT_SECURITY,
526 HeaderValue::from_static("max-age=31536000; includeSubDomains"),
527 );
528 headers.insert(
529 header::HeaderName::from_static("permissions-policy"),
530 HeaderValue::from_static("camera=(), microphone=(), geolocation=()"),
531 );
532 // Live pages are edge-cached briefly; invalidation is implicit via content.
533 // Previews are capability URLs that must always reflect the latest draft, so
534 // they are never cached, `public, max-age` would both serve a creator stale
535 // edits for up to the TTL and let a shared edge hand the draft to anyone who
536 // replayed the URL during the window.
537 let cache_control = if is_preview {
538 "no-store"
539 } else {
540 "public, max-age=300"
541 };
542 headers.insert(
543 header::CACHE_CONTROL,
544 HeaderValue::from_static(cache_control),
545 );
546 }
547
548 #[cfg(test)]
549 mod tests {
550 use super::*;
551
552 #[test]
553 fn dollars_formats_cents() {
554 assert_eq!(dollars(0), "$0.00");
555 assert_eq!(dollars(500), "$5.00");
556 assert_eq!(dollars(1299), "$12.99");
557 assert_eq!(dollars(7), "$0.07");
558 }
559
560 #[test]
561 fn extract_host_strips_port_and_lowercases() {
562 let mut h = HeaderMap::new();
563 h.insert(header::HOST, "U.MakeNot.Work:443".parse().unwrap());
564 assert_eq!(extract_host(&h), Some("u.makenot.work".to_string()));
565 }
566 }
567