Skip to main content

max / makenotwork

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