Skip to main content

max / makenotwork

Close seven loose-wire audit findings /docs bucketed the Guide section through an allowlist that was also the only way onto the index, so 17 of 82 published pages rendered on a direct URL and could not be navigated to. Bucketing is exhaustive now: curated order, then a "More" tail for anything unnamed. The 17 orphans are slotted into the existing categories and two entries that never resolved are gone. Query strings are the input a visitor edits by hand, and a malformed one met axum's bare text/plain rejection rather than the error page. ValidatedQuery and ValidatedExtraQuery join the ValidatedForm family; 25 page, git, admin and embed routes take them. Protocol routes keep their own rejections. The discover price filter took cents with nothing on screen saying so, so filtering under $20 returned items under twenty cents. PriceDollars parses dollars at the edge through the canonical parser, and the URL, the bucket links and the inputs all speak the same unit. Also: the doc pages reach sitemap.xml; /fan-plus offers signup beside login instead of dead-ending an anonymous visitor; the rustdoc links come out, since /rustdoc has never served anything (follow-up filed); and the remaining Makenot.work prose is Makenotwork per brand.md.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-04 18:24 UTC
Signed with PGP, not checked
Commit: 9a701bb0cd8ccae14704ba54b632d8b662580a2d
Parent: ba5b472
42 files changed, +561 insertions, -191 deletions
@@ -884,7 +884,7 @@
884 884 let url = format!("https://api.pwnedpasswords.com/range/{prefix}");
885 885 let response = match crate::helpers::HTTP_CLIENT
886 886 .get(&url)
887 - .header("User-Agent", "MakeNotWork-Security-Check")
887 + .header("User-Agent", "Makenotwork-Security-Check")
888 888 .header("Add-Padding", "true")
889 889 .timeout(std::time::Duration::from_secs(3))
890 890 .send()
@@ -10,7 +10,8 @@
10 10 //! friendly template for page routes, `{"error": "..."}` for API routes (via
11 11 //! `json_error_layer`), and a 422.
12 12
13 - use axum::extract::{FromRequest, Request};
13 + use axum::extract::{FromRequest, FromRequestParts, Request};
14 + use axum::http::request::Parts;
14 15 use serde::de::DeserializeOwned;
15 16
16 17 use crate::error::AppError;
@@ -73,6 +74,73 @@
73 74 }
74 75 }
75 76
77 + /// `axum::extract::Query<T>` whose extraction failures become
78 + /// [`AppError::BadRequest`] instead of axum's bare `text/plain` rejection.
79 + ///
80 + /// Query strings are the one input a visitor edits by hand or inherits from a
81 + /// stale link, so a malformed one is the most likely way to meet an error page
82 + /// at all. Left as a raw `Query<T>` it answers with unlayouted framework text
83 + /// (loose-wire g2-06); routed through here it gets the same branded template,
84 + /// `HX-Error` header and JSON-on-API-routes treatment as every other failure.
85 + ///
86 + /// 400 rather than the 422 the body extractors use: nothing was submitted for
87 + /// the server to process, the address itself is wrong.
88 + pub struct ValidatedQuery<T>(pub T);
89 +
90 + impl<T, S> FromRequestParts<S> for ValidatedQuery<T>
91 + where
92 + T: DeserializeOwned,
93 + S: Send + Sync,
94 + {
95 + type Rejection = AppError;
96 +
97 + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
98 + match axum::extract::Query::<T>::from_request_parts(parts, state).await {
99 + Ok(axum::extract::Query(value)) => Ok(Self(value)),
100 + Err(rejection) => Err(rejection_to_bad_request(&rejection.body_text())),
101 + }
102 + }
103 + }
104 +
105 + /// `axum_extra::extract::Query<T>` (repeated-param query) with the same
106 + /// treatment as [`ValidatedQuery`].
107 + ///
108 + /// Separate from `ValidatedQuery` for the same reason `ValidatedHtmlForm` is
109 + /// separate from `ValidatedForm`: only the `axum_extra` extractor collects
110 + /// `?tag=a&tag=b` into a `Vec`, and swapping one for the other silently turns
111 + /// every multi-select filter on the discover page into a 400.
112 + pub struct ValidatedExtraQuery<T>(pub T);
113 +
114 + impl<T, S> FromRequestParts<S> for ValidatedExtraQuery<T>
115 + where
116 + T: DeserializeOwned,
117 + S: Send + Sync,
118 + {
119 + type Rejection = AppError;
120 +
121 + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
122 + match axum_extra::extract::Query::<T>::from_request_parts(parts, state).await {
123 + Ok(axum_extra::extract::Query(value)) => Ok(Self(value)),
124 + Err(rejection) => Err(rejection_to_bad_request(&rejection.to_string())),
125 + }
126 + }
127 + }
128 +
129 + /// Same prefix-stripping as [`rejection_to_validation`], for the query-string
130 + /// wording axum uses, then wrapped as a 400.
131 + fn rejection_to_bad_request(detail: &str) -> AppError {
132 + let msg = detail
133 + .strip_prefix("Failed to deserialize query string: ")
134 + .unwrap_or(detail)
135 + .trim();
136 + let msg = if msg.is_empty() {
137 + "That link has a bad address. Please check it and try again.".to_string()
138 + } else {
139 + format!("That link has a bad address: {msg}")
140 + };
141 + AppError::BadRequest(msg)
142 + }
143 +
76 144 /// Strip axum's "Failed to deserialize ...: " machinery so the user-facing message
77 145 /// is the underlying cause (which, for a validating newtype, is its own error
78 146 /// string). Falls back to a generic message when nothing useful remains.
@@ -10926,12 +10926,6 @@
10926 10926 opacity: 0.6;
10927 10927 margin-bottom: var(--gap-pane);
10928 10928 }
10929 - .section-mono-hint {
10930 - font-family: var(--font-mono);
10931 - font-size: var(--text-note);
10932 - margin-top: var(--gap-peer);
10933 - }
10934 -
10935 10929 /* Email-result single-card page. */
10936 10930 .email-result-wrap {
10937 10931 max-width: 600px;