Skip to main content

max / makenotwork

21.9 KB · 556 lines History Blame Raw
1 //! The custom-pages host, described.
2 //!
3 //! <!-- wiki: quasi-overview -->
4 //!
5 //! `u.makenot.work` serves a creator's own HTML and CSS with a platform strip
6 //! around it. Both halves are hard to describe: the creator's markup is opaque,
7 //! and the stylesheet that goes with it is per request. [`Node::Canvas`] and
8 //! [`Document::style`] are what say them.
9 //!
10 //! # What this host is, and why its shell is its own
11 //!
12 //! Read `crate::routes::user_pages` for the isolation rules; they are unchanged
13 //! by the conversion and they are what shapes the shell here:
14 //!
15 //! - **No script, at all.** The CSP is `default-src 'none'`, so a `<script>`
16 //! in the head is a console error rather than a feature.
17 //! [`Shell::without_scripts`] is the whole of it. Nothing on these pages
18 //! calls a route: every control is a link to the apex.
19 //! - **No linked stylesheet.** Not because the CSP forbids it -- `style-src`
20 //! allows `'self'` -- but because linking the platform sheet would pull
21 //! every platform class into scope on the one surface built for creator
22 //! control. [`CHROME_CSS`] arrives
23 //! through [`Shell::with_head_first`], the way an embed's design system
24 //! does.
25 //! - **No chrome and no session.** These are cookieless documents for readers
26 //! who are not signed in, so `Chrome::new()` and ordinary axum handlers, as
27 //! in [`super::embeds`].
28 //!
29 //! # The strip is content here, not `Chrome`
30 //!
31 //! A [`quasi_router::Chrome`] is built once and held beside the router, and
32 //! every link in this strip is per request: the canonical URL and the creator's
33 //! name change with the page. So the header and footer are regions on the
34 //! screen, [`RegionKind::Band`] each, which is what a band is -- a full-width
35 //! strip with a title and an actions cluster.
36 //!
37 //! They are siblings of the canvas rather than ancestors, which is the whole of
38 //! the isolation guarantee and is unchanged: `css_sanitizer` re-emits every
39 //! creator rule nested inside `.user-canvas#uc-{owner}`, so a creator selector
40 //! naming anything in the strip is emitted under the canvas and matches
41 //! nothing. No specificity contest, no layer, no `!important`. See
42 //! `css_sanitizer::platform_chrome_is_unreachable_from_creator_css`.
43 //!
44 //! # Every link out of here is [`Action::leaving`]
45 //!
46 //! quasi 0.93.0. These pages are a satellite of the apex and the strip exists
47 //! to send readers to it, so the links replace the page rather than opening
48 //! tabs, which is what they did as Askama and what `Destination::External`
49 //! could not say.
50 //!
51 //! # The published class names survive the conversion
52 //!
53 //! `site-docs/public/guide/custom-pages.md` tells creators that the buy block
54 //! is `.mnw-buy`, the file list `.mnw-files` and the item block `.mnw-item`,
55 //! and gives worked CSS against them. Those are a contract with people outside
56 //! this codebase, so they are carried on [`Node::Canvas`] scopes -- described
57 //! content under a name the app has promised, which is the second use that
58 //! member documents. Every class the strip itself uses is this renderer's and
59 //! [`CHROME_CSS`] is written against them, because nobody outside was ever
60 //! promised those.
61 //!
62 //! What did change is the markup *inside* those blocks, and it had to: a price
63 //! is a described node now rather than a hand-written div. The one place that
64 //! is visible to a creator is the call to action, which was an `<a class=
65 //! "mnw-buy-cta">` and is now that class on the element around the link. The
66 //! guide's own example sets `color` on it, and a `color` on an ancestor does
67 //! not reach an anchor -- the UA sheet beats inheritance -- so [`CHROME_CSS`]
68 //! says `color: inherit` for a link inside one. Without that line the documented
69 //! example would quietly stop working.
70
71 use quasi_axum::Serves as _;
72 use quasi_router::screen::{Canvas, Document, Row};
73 use quasi_router::{Action, Chrome, Node, RegionKind, Screen, Slot, layout};
74 use quasi_webview::{Shell, Webview};
75
76 /// The platform strip's styling, and the system slots' defaults.
77 ///
78 /// Inline rather than linked, and the argument is `crate::routes::user_pages`'s
79 /// rather than this module's: ~1.3KB against a second request, and a fetch that
80 /// fails leaves the strip unstyled while the creator's inline CSS still renders.
81 /// Inline cannot fail that way.
82 ///
83 /// Written against the classes and ids quasi emits, with two exceptions that
84 /// are deliberate and are named in the module header: the `mnw-` names are a
85 /// published contract with creators, and `#chrome-top` / `#chrome-bottom` are
86 /// region ids this module chose.
87 const CHROME_CSS: &str = "\
88 <style>\
89 html,body{margin:0;padding:0}\
90 body{font-family:system-ui,-apple-system,\"Segoe UI\",sans-serif}\
91 #chrome-top,#chrome-bottom{background:#161616;color:#e9e9e9;font-size:.85rem;line-height:1.4}\
92 #chrome-top{display:flex;justify-content:space-between;align-items:center;gap:1rem;\
93 padding:.55rem 1rem;border-bottom:1px solid #2c2c2c}\
94 #chrome-top a,#chrome-bottom a{color:#e9e9e9;text-decoration:none}\
95 #chrome-top a:hover,#chrome-bottom a:hover{text-decoration:underline}\
96 #chrome-top a:first-child{font-weight:600;letter-spacing:.01em}\
97 #chrome-bottom{padding:1rem;text-align:center;border-top:1px solid #2c2c2c;color:#9a9a9a}\
98 .mnw-buy,.mnw-item{margin:1.5rem 0;padding:1rem;border:1px solid currentColor;border-radius:4px}\
99 .mnw-buy-cta,.mnw-item-cta{display:inline-block;margin-top:.5rem;padding:.4rem .9rem;\
100 border:1px solid currentColor;border-radius:4px;font-weight:600}\
101 .mnw-buy-cta a,.mnw-item-cta a{color:inherit;text-decoration:none}\
102 .mnw-files ul{margin:0;padding:0;list-style:none}\
103 .mnw-files{margin:1.5rem 0}\
104 .mnw-files li{padding:.35rem 0;border-bottom:1px solid currentColor}\
105 .mnw-price{font-weight:600}\
106 </style>";
107
108 /// One entry in a project's file list.
109 pub struct File {
110 /// What the file is called.
111 pub title: String,
112 /// Where it is on the apex.
113 pub url: String,
114 }
115
116 /// Everything a creator profile draws from.
117 pub struct UserView {
118 /// The document's title.
119 pub page_title: String,
120 /// The apex, for the brand link.
121 pub apex_url: String,
122 /// This page's home on the apex.
123 pub canonical_url: String,
124 /// What to call the creator.
125 pub creator_label: String,
126 /// The owner id the sanitiser scoped the CSS to.
127 pub canvas_id: String,
128 /// The creator's stylesheet, sanitised.
129 pub sanitized_css: String,
130 /// The creator's markup, sanitised.
131 pub sanitized_html: String,
132 }
133
134 /// Everything a project page draws from.
135 pub struct ProjectView {
136 /// The profile half, which a project page carries all of.
137 pub page: UserView,
138 /// What the project costs, as a sentence.
139 pub price_label: String,
140 /// Where buying happens.
141 pub buy_url: String,
142 /// The project's published items.
143 pub files: Vec<File>,
144 }
145
146 /// Everything an item page draws from.
147 ///
148 /// No creator markup of any kind: an item page is the trustworthy default
149 /// layout wearing the parent project's CSS, re-scoped to the item canvas. It
150 /// takes the head half of the conversion and no part of [`Node::Canvas`]'s
151 /// markup, which is why it was the one to convert first.
152 pub struct ItemView {
153 /// The document's title.
154 pub page_title: String,
155 /// The apex, for the brand link.
156 pub apex_url: String,
157 /// This page's home on the apex.
158 pub canonical_url: String,
159 /// What to call the creator.
160 pub creator_label: String,
161 /// The project id the parent's CSS was re-scoped to.
162 pub canvas_id: String,
163 /// The parent project's stylesheet, re-scoped to the item canvas.
164 pub sanitized_css: String,
165 /// The item's name.
166 pub item_title: String,
167 /// What the creator said about it, if anything.
168 pub item_description: Option<String>,
169 /// What the item costs, as a sentence.
170 pub price_label: String,
171 /// Where buying happens.
172 pub buy_url: String,
173 }
174
175 /// The whole document for one of these screens.
176 ///
177 /// [`super::embeds::document`]'s shape and for its reasons, with one addition:
178 /// the screen carries a stylesheet of its own, which the renderer writes last
179 /// in the head and outside the layer statement. That is what puts the creator's
180 /// CSS on top of [`CHROME_CSS`] without either of them naming a layer.
181 #[must_use]
182 pub fn document(screen: &Screen) -> String {
183 let shell = Shell::default()
184 .without_scripts()
185 .with_chrome(Chrome::new())
186 .with_head_first(CHROME_CSS);
187 Webview::new().with_shell(shell).screen(screen)
188 }
189
190 /// A creator's profile.
191 #[must_use]
192 pub fn user(view: &UserView) -> Screen {
193 strip(
194 Screen::single(&view.page_title)
195 .documented(Document::default().styled(&view.sanitized_css)),
196 view,
197 )
198 .with(Slot::new("canvas", RegionKind::Pane).with(Node::Canvas(Box::new(canvas(view)))))
199 .with(footer(view))
200 }
201
202 /// A creator's project page: their markup, then the platform's own blocks
203 /// inside the same scope.
204 #[must_use]
205 pub fn project(view: &ProjectView) -> Screen {
206 let page = &view.page;
207 // Inside the canvas and after the markup, which is what the Askama template
208 // did and is what lets a creator's sheet reach these blocks at all. Outside
209 // would be a different page, quietly.
210 let mut scope = canvas(page).with(slot_block(
211 "mnw-buy",
212 "mnw-buy-cta",
213 &view.price_label,
214 &view.buy_url,
215 ));
216 if !view.files.is_empty() {
217 scope = scope.with(Node::Canvas(Box::new(
218 Canvas::default().classed("mnw-files").with(Node::List {
219 rows: view
220 .files
221 .iter()
222 .map(|file| Row::new(&file.title).activate(Action::leaving(&file.url)))
223 .collect(),
224 more: None,
225 }),
226 )));
227 }
228
229 strip(
230 Screen::single(&page.page_title)
231 .documented(Document::default().styled(&page.sanitized_css)),
232 page,
233 )
234 .with(Slot::new("canvas", RegionKind::Pane).with(Node::Canvas(Box::new(scope))))
235 .with(footer(page))
236 }
237
238 /// An item page, which is the default layout in the parent project's clothes.
239 #[must_use]
240 pub fn item(view: &ItemView) -> Screen {
241 let mut block = Canvas::default().classed("mnw-item").with(Node::Heading {
242 level: layout::Heading::Page,
243 text: view.item_title.clone(),
244 });
245 if let Some(description) = &view.item_description {
246 block = block.with(Node::text(description));
247 }
248 let block = block
249 .with(price(&view.price_label))
250 .with(cta("mnw-item-cta", &view.buy_url));
251
252 let page = UserView {
253 page_title: view.page_title.clone(),
254 apex_url: view.apex_url.clone(),
255 canonical_url: view.canonical_url.clone(),
256 creator_label: view.creator_label.clone(),
257 canvas_id: view.canvas_id.clone(),
258 sanitized_css: view.sanitized_css.clone(),
259 sanitized_html: String::new(),
260 };
261
262 strip(
263 Screen::single(&view.page_title)
264 .documented(Document::default().styled(&view.sanitized_css)),
265 &page,
266 )
267 // `item-canvas` and `ic-`, which is what `sanitize_item_css` scopes to. The
268 // two names travel together and neither is this module's to choose.
269 .with(
270 Slot::new("canvas", RegionKind::Pane).with(Node::Canvas(Box::new(
271 Canvas::default()
272 .classed("item-canvas")
273 .identified(format!("ic-{}", view.canvas_id))
274 .with(Node::Canvas(Box::new(block))),
275 ))),
276 )
277 .with(footer(&page))
278 }
279
280 /// The creator's markup under the scope its stylesheet was rewritten for.
281 ///
282 /// `.user-canvas#uc-{owner}` is `css_sanitizer::sanitize_css`'s, not this
283 /// module's: the class and the id are what every creator rule was nested
284 /// inside, so a name changed here silently unstyles every custom page.
285 fn canvas(view: &UserView) -> Canvas {
286 Canvas::new(&view.sanitized_html)
287 .classed("user-canvas")
288 .identified(format!("uc-{}", view.canvas_id))
289 }
290
291 /// The platform strip across the top.
292 fn strip(screen: Screen, view: &UserView) -> Screen {
293 screen.with(
294 Slot::new("chrome-top", RegionKind::Band)
295 .with(Node::Link {
296 text: "makenot.work".into(),
297 action: Action::leaving(&view.apex_url),
298 })
299 .with(Node::Link {
300 text: "View on makenot.work".into(),
301 action: Action::leaving(&view.canonical_url),
302 }),
303 )
304 }
305
306 /// The platform strip across the bottom.
307 fn footer(view: &UserView) -> Slot {
308 Slot::new("chrome-bottom", RegionKind::Band).with(Node::Link {
309 text: format!("{} on makenot.work", view.creator_label),
310 action: Action::leaving(&view.canonical_url),
311 })
312 }
313
314 /// A system slot: a price and the way to buy, under a published class name.
315 fn slot_block(block_class: &str, cta_class: &str, price_label: &str, buy_url: &str) -> Node {
316 Node::Canvas(Box::new(
317 Canvas::default()
318 .classed(block_class)
319 .with(price(price_label))
320 .with(cta(cta_class, buy_url)),
321 ))
322 }
323
324 /// What it costs, under the class the guide names.
325 fn price(label: &str) -> Node {
326 Node::Canvas(Box::new(
327 Canvas::default()
328 .classed("mnw-price")
329 .with(Node::text(label)),
330 ))
331 }
332
333 /// The way to buy, under the class the guide names.
334 fn cta(class: &str, buy_url: &str) -> Node {
335 Node::Canvas(Box::new(Canvas::default().classed(class).with(
336 Node::Link {
337 text: "Get on makenot.work".into(),
338 action: Action::leaving(buy_url),
339 },
340 )))
341 }
342
343 #[cfg(test)]
344 mod tests {
345 use super::*;
346
347 /// Everything from the open `<body>` tag on.
348 ///
349 /// Every assertion about what is *drawn* uses this rather than the whole
350 /// document, because `CHROME_CSS` names `.mnw-files` and `.mnw-price` in
351 /// the head: a search over the document finds the rule and concludes the
352 /// block is on the page.
353 fn body(html: &str) -> &str {
354 &html[html.find("<body").expect("a document has a body")..]
355 }
356
357 fn a_user() -> UserView {
358 UserView {
359 page_title: "Ada - makenot.work".into(),
360 apex_url: "https://makenot.work".into(),
361 canonical_url: "https://makenot.work/u/ada".into(),
362 creator_label: "Ada".into(),
363 canvas_id: "11111111".into(),
364 sanitized_css: ".user-canvas#uc-11111111 p{color:red}".into(),
365 sanitized_html: "<h1>Ada</h1>".into(),
366 }
367 }
368
369 #[test]
370 fn the_canvas_carries_the_scope_the_sanitiser_rewrote_every_rule_for() {
371 // `css_sanitizer` nests every creator rule inside
372 // `.user-canvas#uc-{owner}`. If the element stops carrying both halves,
373 // every custom page silently loses its styling, so this is the
374 // load-bearing assertion in the module.
375 let html = document(&user(&a_user()));
376
377 assert!(
378 html.contains("<div class=\"user-canvas\" id=\"uc-11111111\"><h1>Ada</h1></div>"),
379 "{html}"
380 );
381 }
382
383 #[test]
384 fn the_creators_stylesheet_is_in_the_head_after_the_platforms() {
385 // Both are inline and neither is layered, so the later one wins. The
386 // creator's is the screen's own and the renderer writes it last.
387 let html = document(&user(&a_user()));
388 let chrome = html.find("#chrome-top").expect("the strip is styled");
389 let creator = html
390 .find(".user-canvas#uc-11111111 p{color:red}")
391 .expect("the creator's sheet is written");
392 let head_end = html.find("</head>").expect("the head closes");
393
394 assert!(chrome < creator, "{html}");
395 assert!(creator < head_end, "{html}");
396 }
397
398 #[test]
399 fn the_strip_is_a_sibling_of_the_canvas_and_not_an_ancestor() {
400 // The whole isolation guarantee. A creator selector naming anything in
401 // the strip is emitted under the canvas and matches nothing, which only
402 // holds while the strip is outside it.
403 let html = document(&user(&a_user()));
404 let body = body(&html);
405 let top = body.find("id=\"chrome-top\"").expect("the top strip");
406 let canvas = body.find("class=\"user-canvas\"").expect("the canvas");
407 let bottom = body.find("id=\"chrome-bottom\"").expect("the bottom strip");
408
409 assert!(top < canvas && canvas < bottom, "{body}");
410 // Not nested: the canvas region closes before the footer opens.
411 assert!(!body[canvas..bottom].contains("id=\"chrome-"), "{body}");
412 }
413
414 #[test]
415 fn no_script_reaches_a_page_whose_csp_forbids_every_one() {
416 // `default-src 'none'`. A script element here is a console error.
417 let html = document(&user(&a_user()));
418
419 assert!(!html.contains("<script"), "{html}");
420 assert!(!html.contains("<link"), "{html}");
421 }
422
423 #[test]
424 fn every_way_off_this_page_replaces_it_rather_than_opening_a_tab() {
425 // These pages are a satellite of the apex and the strip exists to send
426 // readers to it. This is what the Askama version did.
427 let html = document(&user(&a_user()));
428
429 assert!(!html.contains("target=\"_blank\""), "{html}");
430 assert!(html.contains("href=\"https://makenot.work\""), "{html}");
431 }
432
433 #[test]
434 fn the_published_system_slot_names_survive_the_conversion() {
435 // `site-docs/public/guide/custom-pages.md` names these to creators and
436 // gives worked CSS against them, so they are a contract with people
437 // outside this codebase rather than class names this module picked.
438 let view = ProjectView {
439 page: a_user(),
440 price_label: "$12".into(),
441 buy_url: "https://makenot.work/p/thing".into(),
442 files: vec![File {
443 title: "one.wav".into(),
444 url: "https://makenot.work/i/1".into(),
445 }],
446 };
447 let html = document(&project(&view));
448
449 assert!(html.contains("class=\"mnw-buy\""), "{html}");
450 assert!(html.contains("class=\"mnw-buy-cta\""), "{html}");
451 assert!(html.contains("class=\"mnw-price\""), "{html}");
452 assert!(html.contains("class=\"mnw-files\""), "{html}");
453 }
454
455 #[test]
456 fn the_platform_blocks_sit_inside_the_creators_scope_after_their_markup() {
457 // What the Askama template did, and what lets a creator's sheet reach
458 // the buy block at all. Outside would be a different page, quietly.
459 let view = ProjectView {
460 page: a_user(),
461 price_label: "$12".into(),
462 buy_url: "https://makenot.work/p/thing".into(),
463 files: Vec::new(),
464 };
465 let html = document(&project(&view));
466 let canvas = html
467 .find("class=\"user-canvas\"")
468 .expect("the canvas opens");
469 let markup = html.find("<h1>Ada</h1>").expect("the creator's markup");
470 let buy = html.find("class=\"mnw-buy\"").expect("the buy block");
471
472 assert!(canvas < markup && markup < buy, "{html}");
473 }
474
475 #[test]
476 fn a_project_with_no_published_items_draws_no_file_list() {
477 // The template guarded on `items.is_empty()`, and an empty bordered box
478 // is worse than no box.
479 let view = ProjectView {
480 page: a_user(),
481 price_label: "Free".into(),
482 buy_url: "https://makenot.work/p/thing".into(),
483 files: Vec::new(),
484 };
485
486 assert!(!body(&document(&project(&view))).contains("mnw-files"));
487 }
488
489 #[test]
490 fn an_item_page_wears_the_parents_stylesheet_under_the_item_canvas() {
491 // `sanitize_item_css` scopes to `.item-canvas#ic-{project}`, so the two
492 // names travel together and neither is this module's to choose. An item
493 // page has no creator markup of its own.
494 let html = document(&item(&ItemView {
495 page_title: "Track - makenot.work".into(),
496 apex_url: "https://makenot.work".into(),
497 canonical_url: "https://makenot.work/i/9".into(),
498 creator_label: "Ada".into(),
499 canvas_id: "22222222".into(),
500 sanitized_css: ".item-canvas#ic-22222222 p{color:red}".into(),
501 item_title: "Track".into(),
502 item_description: Some("A track.".into()),
503 price_label: "$3".into(),
504 buy_url: "https://makenot.work/i/9".into(),
505 }));
506
507 assert!(
508 html.contains("<div class=\"item-canvas\" id=\"ic-22222222\">"),
509 "{html}"
510 );
511 assert!(html.contains("class=\"mnw-item\""), "{html}");
512 assert!(html.contains("class=\"mnw-item-cta\""), "{html}");
513 assert!(!html.contains("user-canvas"), "{html}");
514 }
515
516 #[test]
517 fn an_item_with_no_description_draws_none() {
518 let html = document(&item(&ItemView {
519 page_title: "Track - makenot.work".into(),
520 apex_url: "https://makenot.work".into(),
521 canonical_url: "https://makenot.work/i/9".into(),
522 creator_label: "Ada".into(),
523 canvas_id: "22222222".into(),
524 sanitized_css: String::new(),
525 item_title: "Track".into(),
526 item_description: None,
527 price_label: "$3".into(),
528 buy_url: "https://makenot.work/i/9".into(),
529 }));
530
531 // The heading, the price and the call to action, and nothing between
532 // the first two.
533 let drawn = body(&html);
534 let heading = drawn.find("<h1").expect("the item is named");
535 let price = drawn.find("mnw-price").expect("the price is drawn");
536 assert!(!drawn[heading..price].contains("class=\"text\""), "{drawn}");
537 }
538
539 #[test]
540 fn a_locked_creator_gets_the_strip_and_an_empty_canvas() {
541 // The route passes empty strings for a locked owner, and the page still
542 // has to be a page.
543 let mut view = a_user();
544 view.sanitized_html = String::new();
545 view.sanitized_css = String::new();
546 let html = document(&user(&view));
547
548 assert!(
549 html.contains("<div class=\"user-canvas\" id=\"uc-11111111\">"),
550 "{html}"
551 );
552 assert!(html.contains("id=\"chrome-top\""), "{html}");
553 assert!(html.contains("id=\"chrome-bottom\""), "{html}");
554 }
555 }
556