Skip to main content

max / makenotwork

Say which prose is this server's own, and stop nofollowing our own links quasi 0.94 splits Node::Rich's richness from its trust, so a screen can carry prose with a followed link in it without also asking for the permissive treatment of raw HTML. This takes it. quasi::own_prose is the one combination this server reaches for often enough to name - prose, ours - and its doc carries the test for using it: authorship, not tidiness. The string has to be a literal in this repository, or interpolated from a value that cannot carry markup. A creator's description reaching a screen through the database stays Node::rich however well-behaved it has been. Thirteen sites across eight modules, all of them the server's own copy. The one that interpolates says why it is still safe: git_repos puts a Username in a code fence, and validate_username allows letters, digits and underscores. Nine internal links stop being nofollowed: /policy to the documents it exists to point at, /creators to the pages that close the sale, /fan-plus to /join and /login, /git to the notes documentation. Both fan_plus assertions and the policy one now check the absence of nofollow, which is the seal on the trust axis being set; ammonia's rel=noopener noreferrer survives and is left alone, since it suppresses the referrer rather than the crawl. Pins moved to 0.94 here, in audiofiles and in goingson.
Author: Max Johnson <me@maxj.phd> · 2026-09-01 14:38 UTC
Signed with PGP, not checked
Commit: fe702b41f6e4486f0c52ee97a6d14ce633d1be6d
Parent: 51a0087
12 files changed, +116 insertions, -71 deletions
M server/Cargo.lock +25 -25
@@ -2875,7 +2875,7 @@
2875 2875 checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
2876 2876 dependencies = [
2877 2877 "libc",
2878 - "windows-sys 0.59.0",
2878 + "windows-sys 0.52.0",
2879 2879 ]
2880 2880
2881 2881 [[package]]
@@ -4811,7 +4811,7 @@
4811 4811 source = "registry+https://github.com/rust-lang/crates.io-index"
4812 4812 checksum = "82cb6a9f675da968c63b6208c641b9dca58fc0133ae53375736b1767b0cab8bd"
4813 4813 dependencies = [
4814 - "windows-sys 0.59.0",
4814 + "windows-sys 0.61.2",
4815 4815 ]
4816 4816
4817 4817 [[package]]
@@ -5722,7 +5722,7 @@
5722 5722 source = "registry+https://github.com/rust-lang/crates.io-index"
5723 5723 checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
5724 5724 dependencies = [
5725 - "windows-sys 0.59.0",
5725 + "windows-sys 0.61.2",
5726 5726 ]
5727 5727
5728 5728 [[package]]
@@ -6636,7 +6636,7 @@
6636 6636
6637 6637 [[package]]
6638 6638 name = "quasi-axum"
6639 - version = "0.93.0"
6639 + version = "0.94.0"
6640 6640 dependencies = [
6641 6641 "axum",
6642 6642 "http 1.4.2",
@@ -6647,7 +6647,7 @@
6647 6647
6648 6648 [[package]]
6649 6649 name = "quasi-basics"
6650 - version = "0.93.0"
6650 + version = "0.94.0"
6651 6651 dependencies = [
6652 6652 "makeover-layout",
6653 6653 "quasi-router",
@@ -6655,7 +6655,7 @@
6655 6655
6656 6656 [[package]]
6657 6657 name = "quasi-http"
6658 - version = "0.93.0"
6658 + version = "0.94.0"
6659 6659 dependencies = [
6660 6660 "form_urlencoded",
6661 6661 "http 1.4.2",
@@ -6664,7 +6664,7 @@
6664 6664
6665 6665 [[package]]
6666 6666 name = "quasi-router"
6667 - version = "0.93.0"
6667 + version = "0.94.0"
6668 6668 dependencies = [
6669 6669 "makeover-layout",
6670 6670 ]
@@ -6687,7 +6687,7 @@
6687 6687
6688 6688 [[package]]
6689 6689 name = "quasi-webview"
6690 - version = "0.93.0"
6690 + version = "0.94.0"
6691 6691 dependencies = [
6692 6692 "docengine",
6693 6693 "makeover-layout",
@@ -6763,7 +6763,7 @@
6763 6763 "once_cell",
6764 6764 "socket2",
6765 6765 "tracing",
6766 - "windows-sys 0.59.0",
6766 + "windows-sys 0.52.0",
6767 6767 ]
6768 6768
6769 6769 [[package]]
@@ -7326,7 +7326,7 @@
7326 7326 "errno",
7327 7327 "libc",
7328 7328 "linux-raw-sys 0.4.15",
7329 - "windows-sys 0.59.0",
7329 + "windows-sys 0.52.0",
7330 7330 ]
7331 7331
7332 7332 [[package]]
@@ -7339,7 +7339,7 @@
7339 7339 "errno",
7340 7340 "libc",
7341 7341 "linux-raw-sys 0.12.1",
7342 - "windows-sys 0.59.0",
7342 + "windows-sys 0.52.0",
7343 7343 ]
7344 7344
7345 7345 [[package]]
@@ -7419,7 +7419,7 @@
7419 7419 "security-framework 3.7.0",
7420 7420 "security-framework-sys",
7421 7421 "webpki-root-certs",
7422 - "windows-sys 0.59.0",
7422 + "windows-sys 0.52.0",
7423 7423 ]
7424 7424
7425 7425 [[package]]
@@ -7483,7 +7483,7 @@
7483 7483
7484 7484 [[package]]
7485 7485 name = "s3-storage"
7486 - version = "0.1.1"
7486 + version = "0.1.2"
7487 7487 dependencies = [
7488 7488 "aws-config",
7489 7489 "aws-sdk-s3",
@@ -8545,7 +8545,7 @@
8545 8545 "getrandom 0.4.3",
8546 8546 "once_cell",
8547 8547 "rustix 1.1.4",
8548 - "windows-sys 0.59.0",
8548 + "windows-sys 0.52.0",
8549 8549 ]
8550 8550
8551 8551 [[package]]
@@ -10759,21 +10759,13 @@
10759 10759 "pkg-config",
10760 10760 ]
10761 10761
10762 - [[patch.unused]]
10763 - name = "synckit-client"
10764 - version = "0.10.0"
10765 -
10766 - [[patch.unused]]
10767 - name = "synckit-config"
10768 - version = "0.2.0"
10769 -
10770 10762 [[patch.unused]]
10771 10763 name = "quasi-immediate"
10772 - version = "0.93.0"
10764 + version = "0.94.0"
10773 10765
10774 10766 [[patch.unused]]
10775 10767 name = "quasi-notifs"
10776 - version = "0.93.0"
10768 + version = "0.94.0"
10777 10769
10778 10770 [[patch.unused]]
10779 10771 name = "quasi-store"
@@ -10781,7 +10773,7 @@
10781 10773
10782 10774 [[patch.unused]]
10783 10775 name = "quasi-tauri"
10784 - version = "0.93.0"
10776 + version = "0.94.0"
10785 10777
10786 10778 [[patch.unused]]
10787 10779 name = "kberg"
@@ -10794,3 +10786,11 @@
10794 10786 [[patch.unused]]
10795 10787 name = "painhours"
10796 10788 version = "0.1.0"
10789 +
10790 + [[patch.unused]]
10791 + name = "synckit-client"
10792 + version = "0.10.0"
10793 +
10794 + [[patch.unused]]
10795 + name = "synckit-config"
10796 + version = "0.2.0"
@@ -163,7 +163,7 @@
163 163 # through quasi beside its Askama equivalent, so the cost of describing a screen
164 164 # here is measured rather than argued. Not load-bearing for any shipped route.
165 165 # See wiki look-wave-2, tier G.
166 - quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.93" }
166 + quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.94" }
167 167 # The description vocabulary quasi's screen types are built from. Pinned here
168 168 # rather than reached through quasi-router's re-export because a described
169 169 # screen names FieldKind and Tone directly; it has to track what quasi-router
@@ -179,13 +179,13 @@
179 179 # For the request head the per-viewer state factory reads. axum re-exports it,
180 180 # but the factory's signature is quasi-axum's and names `http::request::Parts`.
181 181 http = "1.3.1"
182 - quasi-axum = { git = "https://makenot.work/git/max/quasi.git", version = "0.93" }
183 - quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.93" }
182 + quasi-axum = { git = "https://makenot.work/git/max/quasi.git", version = "0.94" }
183 + quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.94" }
184 184 # The first-party widget set. The carousel was proved here against three pages
185 185 # and then moved there; what is left in `quasi/widgets/carousel.rs` is the
186 186 # Askama glue. Not `makeover-basics`: a widget is an assembly of `Node`s, so it
187 187 # sits above quasi-router rather than inside the description suite.
188 - quasi-basics = { git = "https://makenot.work/git/max/quasi.git", version = "0.93" }
188 + quasi-basics = { git = "https://makenot.work/git/max/quasi.git", version = "0.94" }
189 189
190 190 # Git source browser
191 191 gix = { version = "0.86", default-features = false, features = ["blame", "mailmap", "revision", "sha1", "max-performance-safe"] }
@@ -277,7 +277,7 @@
277 277 # Written from the crate rather than copied into the repo for the reason the
278 278 # constants exist: a stale copy in a static directory is a silently dead bulk
279 279 # bar, and the version has to be the one the emitter agrees with.
280 - quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.93" }
280 + quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.94" }
281 281
282 282 [dev-dependencies]
283 283 tower = { version = "0.5.3", features = ["util"] }
@@ -131,7 +131,7 @@
131 131 in private alpha; we're approving applications one cohort at a time.",
132 132 ))
133 133 .with(Node::section("How It Works"))
134 - .with(Node::rich(
134 + .with(super::own_prose(
135 135 "1. **Sign up** and verify your email\n\
136 136 2. **Apply** from your dashboard: tell us what you make and which tier fits\n\
137 137 3. **Get approved**: we review applications individually, usually within a few days\n\
@@ -155,7 +155,7 @@
155 155 the payment processor's fee (~3%).",
156 156 ))
157 157 .with(tier_table(prices))
158 - .with(Node::rich(
158 + .with(super::own_prose(
159 159 "Every tier is the complete platform: `/u/username` profile, project and item pages, \
160 160 project forum, Discover listing, memberships, pay-what-you-want, promo codes, RSS, \
161 161 analytics, full data export, 2FA/passkeys. The tier picks the file-size envelope, \
@@ -164,13 +164,13 @@
164 164 supported country. [Full tier details](/docs/tiers) | \
165 165 [Pricing models](/docs/pricing)",
166 166 ))
167 - .with(Node::rich(
167 + .with(super::own_prose(
168 168 "**Not ready to commit?** Request a **free trial** (2-6 weeks, no credit card) when \
169 169 you apply. Or [try sandbox mode](/sandbox) to explore the dashboard without signing \
170 170 up.",
171 171 ))
172 172 .with(Node::section("Who Runs This"))
173 - .with(Node::rich(
173 + .with(super::own_prose(
174 174 "Makenotwork is built and operated by one person. No investors, no board, no outside \
175 175 pressure. Decisions are fast and aligned with creators, but there's no large team \
176 176 behind the scenes. Read the full picture in our \
@@ -42,14 +42,12 @@
42 42 //! redirect. Filed against quasicoherent rather than worked around with a
43 43 //! hand-written attribute inside a described form.
44 44 //!
45 - //! # The visitor's sentence carries `nofollow`, and should not
45 + //! # The visitor's sentence is the page's own, and says so
46 46 //!
47 - //! `Node::rich` renders through `docengine::render_strict`, the preset written
48 - //! for user-generated content, so the two links in that sentence come out with
49 - //! `rel="noopener noreferrer nofollow"`. They point at `/join` and `/login`:
50 - //! our own pages, telling crawlers not to follow them. quasi has one `Rich` and
51 - //! treats every source as untrusted, which is right for a forum post and wrong
52 - //! for a page's own copy. quasicoherent `24a3b1df`.
47 + //! [`super::own_prose`] rather than `Node::rich`. The two links in it point at
48 + //! `/join` and `/login`, our own pages, and an untrusted source would have them
49 + //! carrying `nofollow` -- which is what shipped until quasi grew a trust axis
50 + //! separate from its richness one (quasicoherent `24a3b1df`, quasi 0.94).
53 51
54 52 use makeover_layout as layout;
55 53 use quasi_router::screen::{Figure, Row};
@@ -167,7 +165,7 @@
167 165 // says which link is for whom and a pair of buttons does not, and
168 166 // that distinction is the whole point of the finding above. It is
169 167 // prose, so it is `Node::rich`, on `/policy`'s rule.
170 - pitch(page).with(Node::rich(
168 + pitch(page).with(super::own_prose(
171 169 "[Create an account](/join) to join, or [log in](/login) if you already have one.",
172 170 ))
173 171 }
@@ -258,18 +256,24 @@
258 256 // site header carries `/join` and `/login` on every page, so an href
259 257 // alone would pass whatever this block said.
260 258 //
261 - // Matched loosely across the anchor's attributes because `Node::rich`
262 - // hardens links as untrusted content and adds
263 - // `rel="noopener noreferrer nofollow"` (quasicoherent `24a3b1df`). The
264 - // exact-markup form belongs back here when a screen can say its prose
265 - // is its own.
259 + // Each link's own copy, and neither of them nofollowed. The `nofollow`
260 + // half is the seal on this being `own_prose`: an untrusted source is
261 + // hardened by the renderer and both anchors would carry it.
262 + //
263 + // `rel="noopener noreferrer"` does survive, from ammonia's default, and
264 + // is left alone: it suppresses the referrer and the opener handle, not
265 + // the crawl, so it costs nothing an internal link needs.
266 266 assert!(html.contains(r#"href="/join""#), "{html}");
267 - assert!(html.contains("Create an account</a> to join"), "{html}");
267 + assert!(html.contains(">Create an account</a> to join"), "{html}");
268 268 assert!(html.contains(r#"href="/login""#), "{html}");
269 269 assert!(
270 - html.contains("log in</a> if you already have one"),
270 + html.contains(">log in</a> if you already have one"),
271 271 "{html}"
272 272 );
273 + assert!(
274 + !html.contains("nofollow"),
275 + "the page nofollowed its own links: {html}"
276 + );
273 277 assert!(
274 278 !html.contains(SUBSCRIBE),
275 279 "a visitor cannot subscribe, so the control must not be drawn: {html}"
@@ -172,7 +172,7 @@
172 172 /// word in it that goes somewhere, and splitting it into text, an act and more
173 173 /// text is how a sentence stops reading as a sentence in every host.
174 174 fn upstream_line(base: &str) -> Node {
175 - Node::rich(format!(
175 + super::own_prose(format!(
176 176 "Your memberships across [Multithreaded]({base}) forum communities."
177 177 ))
178 178 }
@@ -116,7 +116,7 @@
116 116 // Notes are the one thing this browser does that no other forge does,
117 117 // and nothing on a repository page says so to somebody who has never
118 118 // seen one. The landing page is where that sentence reaches everybody.
119 - .with(Node::rich(
119 + .with(super::own_prose(
120 120 "Every repository here renders [git notes](/docs/git-notes): annotation attached \
121 121 to a commit without rewriting it, stored in the repository and carried by a clone.",
122 122 ));
@@ -124,7 +124,7 @@
124 124 // The only route to an annotation whose target repository is gone: nothing
125 125 // else links to it once there is no commit page to link from.
126 126 if loaded.signed_in {
127 - page = page.with(Node::rich(
127 + page = page.with(super::own_prose(
128 128 "[Your annotations](/git/my-annotations), private to you, across every repository \
129 129 you have read here.",
130 130 ));
@@ -145,7 +145,12 @@
145 145 return page;
146 146 }
147 147
148 - page.with(Node::rich(format!(
148 + // `own_prose` rather than `Node::rich`, and the interpolation is the reason
149 + // to say why: `owner` is a `Username`, which `validate_username` restricts
150 + // to letters, digits and underscores, so it cannot carry markup into a
151 + // source the renderer no longer hardens. A value that could would want
152 + // `Node::rich` instead, whatever else is in the string.
153 + page.with(super::own_prose(format!(
149 154 "Push a new repository:\n\
150 155 \n\
151 156 ```\n\
@@ -714,6 +714,29 @@
714 714 quasi_axum::Adapter::new(router, state, std::sync::Arc::new(pricing::renderer())).into_router()
715 715 }
716 716
717 + /// This server's own copy, as markdown.
718 + ///
719 + /// `Node::rich` means "markdown somebody else wrote": quasi hardens it, so its
720 + /// links carry `nofollow`, its raw markup is dropped and fetchable schemes are
721 + /// filtered. That is right for a forum post and wrong for a page's own
722 + /// sentence, and until quasi grew the trust axis every described page here was
723 + /// telling crawlers not to follow its own links (quasicoherent `24a3b1df`).
724 + ///
725 + /// The two axes stay separate in quasi -- `Richness` is what the format may
726 + /// express, `Trust` is who wrote it -- and this is the one combination this
727 + /// server reaches for often enough to name: prose, ours. A screen wanting
728 + /// tables says so with `Node::richness`, and a screen carrying a reader's
729 + /// markdown keeps `Node::rich`.
730 + ///
731 + /// **The test for using it is authorship, not tidiness.** The string has to be
732 + /// a literal in this repository, or interpolated from a value that cannot carry
733 + /// markup. A creator's description reaching a screen through the database is
734 + /// `Node::rich` however well-behaved it has been.
735 + #[must_use]
736 + pub fn own_prose(source: impl Into<String>) -> quasi_router::Node {
737 + quasi_router::Node::rich(source).trust(quasi_router::Trust::Trusted)
738 + }
739 +
717 740 /// A handler, spelled once so the screens and the mount agree about it.
718 741 pub type Screen =
719 742 fn(&Viewer, quasi_router::Request) -> Result<quasi_router::Response, quasi_router::RouteError>;
@@ -16,11 +16,16 @@
16 16 //! rows would silently flatten both, and neither loss is visible in a test that
17 17 //! checks the text is present.
18 18 //!
19 - //! So each section's body is one [`Node::rich`], which is markdown and renders
20 - //! through docengine -- the same path `/docs/*` takes, so the policy prose and
21 - //! the documents it links to are formatted by one renderer. **What stays
22 - //! described is the structure**: [`Node::section`] per heading, so the section
23 - //! hierarchy is a fact of the screen rather than an `<h2>` inside a blob.
19 + //! So each section's body is one [`super::own_prose`], which is markdown and
20 + //! renders through docengine -- the same path `/docs/*` takes, so the policy
21 + //! prose and the documents it links to are formatted by one renderer. **What
22 + //! stays described is the structure**: [`Node::section`] per heading, so the
23 + //! section hierarchy is a fact of the screen rather than an `<h2>` inside a
24 + //! blob.
25 + //!
26 + //! `own_prose` and not `Node::rich`, which is the difference between a page
27 + //! that points at its own documents and a page that tells crawlers not to
28 + //! follow them (quasi 0.94, quasicoherent `24a3b1df`).
24 29 //!
25 30 //! The escape hatch is not swallowing the page. It is carrying the one thing
26 31 //! this page is made of, which is sentences.
@@ -171,7 +176,7 @@
171 176 for section in SECTIONS {
172 177 page = page
173 178 .with(Node::section(section.heading))
174 - .with(Node::rich(section.body));
179 + .with(super::own_prose(section.body));
175 180 }
176 181
177 182 page = page
@@ -182,7 +187,7 @@
182 187 .act(Act::new("Read", Action::get(*route).navigating()))
183 188 })));
184 189
185 - page = page.with(Node::section("Questions")).with(Node::rich(
190 + page = page.with(Node::section("Questions")).with(super::own_prose(
186 191 "If something's unclear or you want to check before posting, reach out at \
187 192 **policy@makenot.work**.",
188 193 ));
@@ -240,6 +245,13 @@
240 245 html.contains(r#"href="/docs/guarantees""#),
241 246 "the creator-guarantees link did not survive: {html}"
242 247 );
248 + // This page exists to point at the other policy documents, so a
249 + // `nofollow` on the way there would be the page working against itself.
250 + // The seal on `super::own_prose`: an untrusted source is hardened.
251 + assert!(
252 + !html.contains("nofollow"),
253 + "the policy page nofollowed its own documents: {html}"
254 + );
243 255 assert!(
244 256 html.contains("<strong>reports@makenot.work</strong>")
245 257 || html.contains("<b>reports@makenot.work</b>"),
@@ -212,7 +212,7 @@
212 212 .into(),
213 213 act: None,
214 214 })
215 - .with(Node::rich(
215 + .with(super::own_prose(
216 216 "1. Create a project\n\
217 217 2. Add items: audio, video, text, or software\n\
218 218 3. Set prices (or keep them free) and publish\n\
@@ -143,7 +143,7 @@
143 143
144 144 /// How long an answer takes, and the way round the queue for a security issue.
145 145 fn response_times() -> Node {
146 - Node::rich(
146 + super::own_prose(
147 147 "Response times:\n\n\
148 148 - Security issues: same day\n\
149 149 - Billing and account access: 24 hours\n\
@@ -19,15 +19,16 @@
19 19 // CTA's own copy rather than on `href="/join"`, which the site header
20 20 // carries on every page and would pass whatever this block said.
21 21 //
22 - // Split across the anchor's attributes since the page became a described
23 - // document (`fa1a268d`): the sentence is `Node::rich`, which renders through
24 - // docengine's strict preset and adds `rel="noopener noreferrer nofollow"`.
25 - // That rel on our own pages is wrong and is quasicoherent `24a3b1df`; when
26 - // it is fixed this goes back to matching the whole anchor.
27 - assert!(resp.text.contains(r#"href="/join""#));
28 - assert!(resp.text.contains("Create an account</a> to join"));
29 - assert!(resp.text.contains(r#"href="/login""#));
30 - assert!(resp.text.contains("log in</a> if you already have one"));
22 + // Each link's own copy, and neither nofollowed. The `nofollow` half seals
23 + // that the sentence is described as this server's own prose: an untrusted
24 + // markdown source is hardened by the renderer and both would carry it.
25 + assert!(resp.text.contains(">Create an account</a> to join"));
26 + assert!(resp.text.contains(">log in</a> if you already have one"));
27 + assert!(
28 + !resp.text.contains("nofollow"),
29 + "the page nofollowed its own links: {}",
30 + resp.text
31 + );
31 32 assert!(!resp.text.contains("Join Fan+"));
32 33 }
33 34