Skip to main content

max / makenotwork

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