Skip to main content

max / quasi

34.9 KB · 780 lines History Blame Raw
1 //! The http-shaped seam every webview host adapter shares.
2 //!
3 //! <!-- wiki: quasi-overview -->
4 //!
5 //! A hosted axum route and a Tauri custom-protocol handler are the same
6 //! function wearing two coats. Both are handed a verb, a path, a query string
7 //! and maybe a form body; both owe back a status, some headers and a rendered
8 //! body; and both are talking to htmx on the other side. The coats are the
9 //! parts that differ, and they are thin: how the bytes arrive, and which thread
10 //! is allowed to block.
11 //!
12 //! So the middle lives here, in `http` types that neither host owns, and a host
13 //! adapter is left with only its own coat. [`quasi_axum`] was the first caller
14 //! and carried this code inline; the Tauri adapter is the second, and the
15 //! [`htmx`] module said in its own doc comment that it would move when that
16 //! happened.
17 //!
18 //! # What is here
19 //!
20 //! - [`decode`], which turns a request into the [`Method`], path and [`Params`]
21 //! the router takes. [`quasi_router`] deliberately parses neither a query
22 //! string nor a form body, because every host already has that code.
23 //! - [`respond`] and [`refuse`], which turn the router's answer, or the
24 //! adapter's own refusal, into a response.
25 //! - [`Serves`], the seam to markup, which is a parameter rather than an
26 //! implementation for the reason its own docs give.
27 //! - [`htmx`], the two response headers and one client configuration an http
28 //! host has to know to speak the webview transport correctly.
29 //!
30 //! # What is not here
31 //!
32 //! Anything that knows which host it is in. There is no runtime, no thread and
33 //! no socket in this crate, and nothing in it is `async`: the blocking hop is a
34 //! host's own problem and the two hosts solve it differently. That is what
35 //! keeps this callable from a Tauri protocol handler, which has no executor of
36 //! its own to hand work to.
37 //!
38 //! [`quasi_axum`]: https://makenot.work/git/max/quasi
39
40 use quasi_router::{
41 Accepted, Action, Address, Destination, Method, Node, Outcome, Params, Request, Response,
42 RouteError, Sought, safe_file_name,
43 };
44
45 pub mod htmx;
46 pub mod serves;
47
48 pub use crate::serves::Serves;
49
50 /// How much of a form body is read before the request is refused.
51 ///
52 /// A description-layer form is fields and choices, so a request an order of
53 /// magnitude past this is a mistake or an attack rather than a long answer.
54 /// File uploads do not come through here: a byte stream is not something a
55 /// description describes, and each host keeps its own path for them.
56 pub const DEFAULT_BODY_LIMIT: usize = 256 * 1024;
57
58 /// The verbs the description layer has, as an `Allow` header value.
59 ///
60 /// Must list exactly what `translate` accepts, or the header promises a verb
61 /// the decoder refuses. `PATCH` is the one HTTP has that this does not: nothing
62 /// in either app writes one, and a verb with no consumer is a verb whose
63 /// meaning nobody has had to decide.
64 pub const ALLOWED_METHODS: &str = "GET, POST, DELETE, PUT";
65
66 /// A request, in the terms the router takes.
67 #[derive(Debug, Clone, PartialEq, Eq)]
68 pub struct Incoming {
69 /// Asking or telling.
70 pub method: Method,
71 /// The path, with no scheme, host or query on it.
72 pub path: String,
73 /// The form body: what the control sent. Empty on a read.
74 pub payload: Params,
75 /// The query string: the view the control was offered under.
76 pub carried: Params,
77 /// Whether htmx made this request rather than the browser navigating.
78 ///
79 /// Read off [`htmx::REQUEST`], which htmx sets on every call it makes. The
80 /// one fact about the envelope that changes what an answer may be: an
81 /// ordinary navigation can be handed any bytes at all, and an XHR cannot
82 /// (see [`respond`]'s file arm).
83 ///
84 /// Never given to a handler. A route describes what it answers, not who
85 /// asked for it; this is the adapter's own and stops here.
86 pub htmx: bool,
87 }
88
89 /// What was asked, kept back so the answer can be placed in history.
90 ///
91 /// [`Incoming`] is consumed by the router, and the two facts history needs — was
92 /// this a read, and of what address — outlive it. Taken here rather than
93 /// re-derived from the http request, so the URL a push carries is exactly the
94 /// one the route was reached at, params and all.
95 #[derive(Debug, Clone, PartialEq, Eq)]
96 pub struct Asked {
97 /// Asking or telling. Only a read can be a place.
98 pub method: Method,
99 /// The address this request was made at, carried params included.
100 pub url: String,
101 /// [`Incoming::htmx`], kept for the same reason the other two are: the
102 /// answer is placed after the router has consumed the request.
103 pub htmx: bool,
104 }
105
106 impl Asked {
107 /// What a request was, before the router takes it.
108 #[must_use]
109 pub fn new(incoming: &Incoming) -> Self {
110 Self {
111 method: incoming.method,
112 url: route_url(&incoming.path, &incoming.carried),
113 htmx: incoming.htmx,
114 }
115 }
116 }
117
118 impl From<Incoming> for Request {
119 fn from(incoming: Incoming) -> Self {
120 Self {
121 method: incoming.method,
122 path: incoming.path,
123 captures: Params::new(),
124 payload: incoming.payload,
125 carried: incoming.carried,
126 }
127 }
128 }
129
130 /// A request the adapter turns away without troubling the router.
131 ///
132 /// Three, and all three are about the envelope rather than the address. A
133 /// missing route is not here: that is a [`RouteError`] from the router, it is
134 /// classified, and it renders a notice like any other failure.
135 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
136 pub enum Refusal {
137 /// A verb the description layer does not have.
138 Method,
139 /// A form body past the limit.
140 TooLarge,
141 /// A form body that is not UTF-8.
142 Malformed,
143 }
144
145 impl Refusal {
146 /// The status this refusal answers with.
147 #[must_use]
148 pub const fn status(self) -> u16 {
149 match self {
150 Self::Method => 405,
151 Self::TooLarge => 413,
152 Self::Malformed => 400,
153 }
154 }
155 }
156
157 /// Read a request into the router's terms.
158 ///
159 /// `body` is the bytes the host already has. Reading them is the host's job
160 /// because the two hosts read them differently, and by the time either calls
161 /// this the read has happened.
162 ///
163 /// # The two halves stay apart
164 ///
165 /// That merge was the bug: a screen carrying its view in the address sends its
166 /// filters on every control, so a write about the same noun the filter filters
167 /// on ended up with two meanings for one name and the handler read whichever
168 /// landed first. goingson's mail screen met it twice in one afternoon.
169 ///
170 /// They are two things and they arrive separately, which is what HTTP already
171 /// says: the query is where you are, the body is what you are telling it. The
172 /// router adds the path captures as a third bag, since a capture is the route's
173 /// own and was sent by nobody.
174 pub fn decode(
175 method: &http::Method,
176 uri: &http::Uri,
177 headers: &http::HeaderMap,
178 body: &[u8],
179 body_limit: usize,
180 ) -> Result<Incoming, Refusal> {
181 let method = translate(method).ok_or(Refusal::Method)?;
182
183 let mut payload = Params::new();
184 if method.mutates() && is_form(headers) {
185 if body.len() > body_limit {
186 return Err(Refusal::TooLarge);
187 }
188 let text = std::str::from_utf8(body).map_err(|_| Refusal::Malformed)?;
189 payload.absorb(decode_pairs(text));
190 }
191
192 Ok(Incoming {
193 method,
194 path: uri.path().to_owned(),
195 payload,
196 carried: decode_pairs(uri.query().unwrap_or_default()),
197 htmx: headers.contains_key(htmx::REQUEST),
198 })
199 }
200
201 /// Turn the router's answer into a response.
202 ///
203 /// Takes the `Result` whole rather than the two halves separately, because a
204 /// failure is not a special case here: it becomes a [`Node::Notice`] and is
205 /// rendered as a fragment down the same path as everything else. That is
206 /// decision 9 holding at the boundary and not only in the router.
207 ///
208 /// A host with a way of failing the router does not have, such as a panicking
209 /// handler or a worker that died, reports it as
210 /// [`RouteError::internal`] and gets the same treatment.
211 pub fn respond<R: Serves + ?Sized>(
212 render: &R,
213 outcome: Result<Response, RouteError>,
214 asked: &Asked,
215 ) -> http::Response<Vec<u8>> {
216 match outcome {
217 Ok(answer) => {
218 // Whether this answer is a place, decided here because this is the
219 // one point that holds both halves: what was asked, and what the
220 // router did about it. A control cannot know the second, which is
221 // why no `hx-push-url` is ever emitted into markup.
222 let address = placement(&answer, asked);
223 // The notice is orthogonal to the outcome and is applied to all
224 // three, including a redirect, which has no body to carry one.
225 let trigger = answer.notice.as_ref().map(|notice| {
226 htmx::notice_trigger(notice.kind, notice.tone, &notice.text, notice.undo.as_ref())
227 });
228 let mut response = match answer.outcome {
229 // An invalidation is not applied to a whole screen, and that is
230 // not a case being dropped. Every slot is being replaced
231 // already, so an out-of-band copy would be a second element
232 // carrying an id the document now has twice. A handler that
233 // says `.also()` on a screen is stating something the answer
234 // already made true.
235 Outcome::Screen(screen) => body(render, 200, render.screen(&screen), None),
236 Outcome::Fragment { region, node } => {
237 // The router said what it changed, so the client is told
238 // rather than left to infer it from which element was
239 // clicked. The webview renderer owes every slot an `id`
240 // matching its `Slot::id` for this to land.
241 //
242 // The other slots the answer changed ride along behind the
243 // targeted one, each named rather than aimed. Appended in
244 // the order the router gave them, because a renderer
245 // reordering them would be inventing a fact the response
246 // did not state.
247 let mut markup = render.fragment(&node);
248 for stale in &answer.invalidates {
249 markup.push_str(&render.invalidated(&stale.region, &stale.node));
250 }
251 body(render, 200, markup, Some(format!("#{region}")))
252 }
253 // The work is running somewhere else and the region says so.
254 // Aimed at that region, the same as a fragment, because it is
255 // the same swap: what differs is that the contents are a wait.
256 //
257 // `Readiness` is the axis everywhere else, and on this host it
258 // cannot be the whole answer. `aria-busy` sits on the region
259 // element, and an innerHTML swap replaces what is inside that
260 // element rather than the element itself — so the saying-so has
261 // to be a node. `Node::pending` is that node, and
262 // makeover-webview draws it `role="status" aria-live="polite"`,
263 // which is the announcement a swapped-in wait owes a reader who
264 // cannot see it.
265 //
266 // Deliberately not an `HX-Reswap` to `outerHTML` to get the
267 // attribute back. `htmx::RESWAP` says why: how a response is put
268 // in place travels with the element.
269 //
270 // Nothing here re-asks. The region already carries its own
271 // `hx-trigger` from the render that put it up — that is
272 // `Slot::live` — and a header telling it to poll would be this
273 // crate deciding a cadence the description declined to name.
274 Outcome::Started { region, message } => body(
275 render,
276 200,
277 render.fragment(&Node::pending(message)),
278 Some(format!("#{region}")),
279 ),
280 // Nor to a redirect, which has no body to carry one. The
281 // destination answers next and answers with everything.
282 Outcome::Goto(action) => redirect(&action),
283 // Over what is already there. The retarget is the whole
284 // difference from a screen: the answer lands in the overlay
285 // container and the document under it is left alone. A
286 // renderer with no such container says so by answering `None`,
287 // and its `overlay` draws the screen instead.
288 // Aimed at the list the field owns, which the renderer names
289 // from the field's own name. A renderer with no such container
290 // answers `None` and the fragment lands where it was aimed,
291 // which is the same bargain an overlay strikes.
292 Outcome::Suggestions { field, options } => {
293 let target = render.suggestions_target(&field).map(|id| format!("#{id}"));
294 body(render, 200, render.suggestions(&field, &options), target)
295 }
296 Outcome::Over(screen) => {
297 let target = render.overlay_target().map(|id| format!("#{id}"));
298 body(render, 200, render.overlay(&screen), target)
299 }
300 // Over what is already there, at a point in it. The container
301 // belongs to the anchored thing rather than to the app, which
302 // is the whole difference from the arm above: the retarget is
303 // derived from the anchor and the one overlay container is left
304 // alone.
305 //
306 // The swap style is not touched. `htmx::RESWAP` says why: how a
307 // response is put in place travels with the element, so the
308 // container this aims at is one whose ordinary innerHTML swap is
309 // already right, rather than a header overriding a decision the
310 // renderer made.
311 Outcome::Anchored { screen, anchor } => {
312 let target = render.anchored_target(&anchor).map(|id| format!("#{id}"));
313 body(render, 200, render.anchored(&screen), target)
314 }
315 // A file, at whatever destination the browser is configured to
316 // put downloads. `67881a88`: the route answers with the file
317 // and the host puts it somewhere, and on this host the header
318 // is the whole of the saying-so.
319 //
320 // Nothing is rendered, so `Serves` is not consulted: bytes are
321 // not markup and there is no renderer question to ask. That is
322 // why this member cost the `Serves` trait nothing when it
323 // arrived, unlike every other outcome.
324 //
325 // Unless htmx asked, and the bytes are not text. `3bdf1a75`:
326 // htmx leaves `responseType` unset, so the browser decodes the
327 // body as UTF-8 before `DOWNLOAD_JS` can see it and every byte
328 // sequence that is not valid UTF-8 has already become U+FFFD.
329 // A `.zip` answered this way downloads and is corrupt, and it
330 // looks exactly like one that worked.
331 Outcome::File {
332 name,
333 kind,
334 bytes: _,
335 } if asked.htmx && !is_text(&kind) => {
336 refused(render, &binary_download(&name, &kind))
337 }
338 Outcome::File { name, kind, bytes } => attach(&name, &kind, bytes),
339 // A place, on a host with no picker to open. `ec92f9cb` says a
340 // host that cannot perform this refuses explicitly, and this is
341 // the explicit refusal: 501, and a notice down the same path
342 // every other failure takes.
343 //
344 // Deliberately not degraded into an upload field. A browser can
345 // offer a file input and cannot hand back a folder the server
346 // may write into afterwards, so anything drawn here would be a
347 // control that looks like it worked. The `Locate` sites are
348 // audiofiles' and audiofiles is not on this host; a described
349 // screen that has to work in a browser asks for a file with
350 // `FieldKind::File`, which is the described upload and is a
351 // different sentence.
352 //
353 // Which dialog was wanted is named rather than the whole class
354 // refused, because "cannot choose a place" tells a reader
355 // nothing about what they pressed. The match is exhaustive so
356 // that a shape added to `Sought` stops compiling here rather
357 // than arriving at a wildcard that calls it something it is
358 // not.
359 Outcome::Locate(asking) => {
360 let wanted = match &asking.sought {
361 Sought::Folder => "choose a folder",
362 Sought::File { .. } => "choose a file",
363 Sought::Files { .. } => "choose files",
364 // `7fda7ae3`. The one shape this host has something
365 // adjacent to, and adjacent is not the same. A browser
366 // saves bytes that exist now, through the download it
367 // is already given `Outcome::File` for; this asks for
368 // somewhere to write into later, and there is no way to
369 // hand a page one. A description that has to work here
370 // answers with the file instead.
371 Sought::Save { .. } => "choose where to save",
372 };
373 refused(
374 render,
375 &format!("this host cannot {wanted}: {}", asking.prompt),
376 )
377 }
378 };
379 if let Some(trigger) = trigger
380 && let Ok(value) = http::HeaderValue::from_str(&trigger)
381 {
382 response.headers_mut().insert(htmx::TRIGGER, value);
383 }
384 if let Some((header, url)) = address
385 && let Ok(value) = http::HeaderValue::from_str(&url)
386 {
387 response.headers_mut().insert(header, value);
388 }
389 response
390 }
391 Err(error) => {
392 let node = Node::Notice {
393 kind: error.notice,
394 tone: error.tone(),
395 text: error.message.clone(),
396 // A `RouteError` carries no way back, so neither does this.
397 act: None,
398 };
399 let mut response = body(
400 render,
401 error.class.http_status(),
402 render.fragment(&node),
403 None,
404 );
405 // A 405 without `Allow` is a refusal with no way to learn what
406 // would have worked, which RFC 9110 makes a MUST for exactly that
407 // reason. Empty for every other class, so nothing is added to the
408 // answers that were already right.
409 if let Some(allow) = error.allow_header()
410 && let Ok(value) = http::HeaderValue::from_str(&allow)
411 {
412 response.headers_mut().insert(http::header::ALLOW, value);
413 }
414 response
415 }
416 }
417 }
418
419 /// Which history header this answer earns, and what address it carries.
420 ///
421 /// The override first, then the derivation, because the whole point of
422 /// [`Address`] is to be able to say something the derivation cannot reach.
423 ///
424 /// The derivation:
425 ///
426 /// - a read answering with a whole screen is a place, at the address it was
427 /// read from
428 /// - a write answering with a screen is not: the address is where the form was,
429 /// and going back to it should not re-offer the write's result as a page
430 /// - a fragment is not a place, unless it says otherwise. This is where the
431 /// addressable tab panel says otherwise
432 /// - a [`Goto`](Outcome::Goto) sets nothing, because
433 /// [`HX-Location`](htmx::LOCATION) issues the request client-side and htmx
434 /// pushes for it, and [`HX-Redirect`](htmx::REDIRECT) is a real navigation
435 fn placement(answer: &Response, asked: &Asked) -> Option<(&'static str, String)> {
436 match &answer.address {
437 Some(Address::Enters(url)) => return Some((htmx::PUSH_URL, url.clone())),
438 Some(Address::Replaces(url)) => return Some((htmx::REPLACE_URL, url.clone())),
439 Some(Address::Unchanged) => return None,
440 None => {}
441 }
442
443 match answer.outcome {
444 Outcome::Screen(_) if asked.method == Method::Get => {
445 Some((htmx::PUSH_URL, asked.url.clone()))
446 }
447 _ => None,
448 }
449 }
450
451 /// Send the user somewhere instead of answering with content.
452 ///
453 /// 200 and an empty body, with the whole answer in the header. See
454 /// [`htmx::LOCATION`] for why this is not a 303.
455 ///
456 /// A route keeps its params, because a redirect back to a filtered list that
457 /// drops the filter is a different place. An external address is taken as
458 /// written: a `mailto:` or a `file://` has no query string this router built.
459 ///
460 /// A [`Destination::Local`] is nowhere to go, and a `Goto` carrying one is a
461 /// description bug: locality marks an interaction that asks nothing, and
462 /// "navigate, asking nothing" names no place. It answers 200 with no header, so
463 /// the page stays where it is. Refusing to answer at all was the other reading
464 /// and is [`Screen::replace`]'s question rather than this one — a bad
465 /// description is worth being able to see, and is not worth a 500.
466 ///
467 /// [`Destination::Back`] is the same answer for a different reason, and the
468 /// reason is worth writing down because "go back after this write" is a
469 /// coherent thing to want. A redirect is a header, and no header says "back":
470 /// the browser's history is walked by script, and a response header cannot run
471 /// one. The retained-screen hosts drop it here too — their `Goto` arms read
472 /// `Destination::route` and get `None` — so the three agree, which is the
473 /// property that matters more than any one of them doing something clever.
474 /// **What a description says instead is an action on a control**
475 /// ([`Action::back`](quasi_router::Action::back)), which every host answers.
476 ///
477 /// [`Screen::replace`]: quasi_router::Screen::replace
478 fn redirect(action: &Action) -> http::Response<Vec<u8>> {
479 let Some((header, address)) = (match &action.destination {
480 // The view, not the payload. Going somewhere is an address, and an
481 // address is what `carried` holds; a redirect that dropped the filter
482 // would land on an unfiltered list.
483 Destination::Route(path) => Some((htmx::LOCATION, route_url(path, &action.carried))),
484 // Both ways out of the app are one redirect here. A response header
485 // sends the browser somewhere and has no say in what window it lands
486 // in, so the distinction the two variants draw is a link's and not a
487 // redirect's: a page that has already navigated has nothing to keep
488 // aside.
489 Destination::External(address) | Destination::Leaving(address) => {
490 Some((htmx::REDIRECT, address.clone()))
491 }
492 Destination::Local | Destination::Back => None,
493 }) else {
494 return http::Response::builder()
495 .status(200)
496 .body(Vec::new())
497 .expect("a response with no body and no headers is always valid");
498 };
499 let mut builder = http::Response::builder().status(200);
500 if let Ok(value) = http::HeaderValue::from_str(&address) {
501 builder = builder.header(header, value);
502 }
503 builder
504 .body(Vec::new())
505 .expect("a response with no body and one checked header is always valid")
506 }
507
508 /// A route with its parameters folded into a query string.
509 ///
510 /// Encoded here rather than in the router, for the reason the router does not
511 /// decode: the host has this code already and a second implementation is a
512 /// second place for an escaping bug. Public because the renderers need the same
513 /// answer the redirect does. A read that a renderer writes into an `href` and a
514 /// redirect back to a filtered list are the same address, and two functions
515 /// building it is how they stop being.
516 ///
517 /// A route with no parameters is returned as written, so nothing gains a
518 /// trailing `?` it did not have.
519 #[must_use]
520 pub fn route_url(path: &str, params: &Params) -> String {
521 if params.is_empty() {
522 return path.to_owned();
523 }
524 let query = form_urlencoded::Serializer::new(String::new())
525 .extend_pairs(params.iter())
526 .finish();
527 let joiner = if path.contains('?') { '&' } else { '?' };
528 format!("{path}{joiner}{query}")
529 }
530
531 /// Turn the adapter's own refusal into a response.
532 ///
533 /// No body, because there is nothing to say that the status does not already
534 /// say and no description was ever reached. `Allow` on a 405 is what a client
535 /// needs to correct itself rather than retry the same thing.
536 #[must_use]
537 pub fn refuse(refusal: Refusal) -> http::Response<Vec<u8>> {
538 let mut builder = http::Response::builder().status(refusal.status());
539 if refusal == Refusal::Method {
540 builder = builder.header(http::header::ALLOW, ALLOWED_METHODS);
541 }
542 builder
543 .body(Vec::new())
544 .expect("a response with no body and a static header is always valid")
545 }
546
547 /// A rendered body, with the renderer's own content type.
548 /// Hand the bytes over as a download.
549 ///
550 /// 200 and the file, with the name in `Content-Disposition`. Not a 303 to a
551 /// second route that serves it: the payload is already in hand, and inventing a
552 /// second address would mean holding it somewhere between the two requests.
553 ///
554 /// # The name is rewritten twice, on purpose
555 ///
556 /// [`safe_file_name`] first, because the name reaches here from a description
557 /// and is regularly built from something a user typed. Then RFC 6266's two
558 /// forms: a quoted ASCII `filename` every agent has understood for twenty
559 /// years, and a `filename*` carrying the real characters for the ones that
560 /// read it. A name that is already ASCII gets both and they agree, which is
561 /// cheaper to read than a branch.
562 ///
563 /// # What htmx does with this, and what it does not
564 ///
565 /// A control that reaches this route through `hx-post` gets the bytes into an
566 /// XHR and the browser downloads nothing, because a download is a navigation.
567 /// `quasi-webview` closes that with [`DOWNLOAD_JS`], which cancels the swap and
568 /// hands the body to the browser as a blob. A host serving plain links —
569 /// anything not driving this from htmx — needs none of it and works off this
570 /// header alone.
571 ///
572 /// [`DOWNLOAD_JS`]: https://makenot.work/git/max/quasi
573 fn attach(name: &str, kind: &Accepted, bytes: Vec<u8>) -> http::Response<Vec<u8>> {
574 let name = safe_file_name(name);
575 http::Response::builder()
576 .status(200)
577 .header(http::header::CONTENT_TYPE, media_type(kind))
578 .header(http::header::CONTENT_DISPOSITION, disposition(&name))
579 .body(bytes)
580 .unwrap_or_else(|_| {
581 // Unreachable: both header values are built from a name this
582 // function just sanitised and from a media type below, and neither
583 // can carry a control character.
584 http::Response::builder()
585 .status(500)
586 .body(Vec::new())
587 .expect("a response with no body and no headers is always valid")
588 })
589 }
590
591 /// Whether an answer's bytes can survive being read back off an XHR as text.
592 ///
593 /// The question is not "is this a sensible download" but "has the browser
594 /// already destroyed it": htmx reads a response as a UTF-8 string, so anything
595 /// that is not valid UTF-8 arrives at [`DOWNLOAD_JS`] with U+FFFD where its
596 /// bytes were. Text survives that round trip exactly.
597 ///
598 /// Only [`Accepted::Type`] can answer, and it is the same reason
599 /// [`media_type`] gives: a [`Family`](Accepted::Family) spells `image/*`, which
600 /// is a filter, and a [`Suffix`](Accepted::Suffix) is a name. Guessing text-ness
601 /// from `.csv` would be this crate keeping the suffix table the description
602 /// layer deliberately does not have, and a wrong guess here is a corrupt file
603 /// rather than a wrong header. Both answer "cannot say", which this reads as
604 /// "not safe" -- a loud refusal is recoverable and a silently mangled download
605 /// is not.
606 ///
607 /// The types that count are `text/*` and the structured formats that are text
608 /// wearing an `application/` prefix for historical reasons. `+json` and `+xml`
609 /// are RFC 6839 structured suffixes and are text by construction, so they are
610 /// matched by shape rather than listed.
611 ///
612 /// [`DOWNLOAD_JS`]: https://makenot.work/git/max/quasi
613 fn is_text(kind: &Accepted) -> bool {
614 let Accepted::Type(media_type) = kind else {
615 return false;
616 };
617 let media_type = media_type
618 .split(';')
619 .next()
620 .unwrap_or_default()
621 .trim()
622 .to_ascii_lowercase();
623 media_type.starts_with("text/")
624 || media_type.ends_with("+json")
625 || media_type.ends_with("+xml")
626 || matches!(
627 media_type.as_str(),
628 "application/json" | "application/xml" | "application/javascript"
629 )
630 }
631
632 /// What a refused answer says, as a fragment down the path every failure takes.
633 ///
634 /// Decision 9 at the boundary: a host that cannot perform something says so as
635 /// a notice rather than by answering something that looks like it worked. 501,
636 /// because the description is fine and this host is what cannot do it.
637 fn refused<R: Serves + ?Sized>(render: &R, text: &str) -> http::Response<Vec<u8>> {
638 let node = Node::Notice {
639 kind: quasi_router::layout::Notice::Banner,
640 tone: quasi_router::layout::Tone::Danger,
641 text: text.to_owned(),
642 // A refusal, with no route that would undo it.
643 act: None,
644 };
645 body(render, 501, render.fragment(&node), None)
646 }
647
648 /// What the refusal above says about a file htmx cannot be handed.
649 ///
650 /// Names the two ways out, because "cannot" with no next step is a dead end and
651 /// both of these are one line at the call site: say a text media type if the
652 /// bytes are text, or reach the route with a plain link, which never becomes an
653 /// XHR and is always correct.
654 fn binary_download(name: &str, kind: &Accepted) -> String {
655 // `Accepted` is `#[non_exhaustive]`, so a member added upstream lands with
656 // the two that already say "cannot say". That is the right default here:
657 // this refuses what it cannot show to be text.
658 let said = match kind {
659 Accepted::Type(media_type) => media_type.clone(),
660 _ => "no media type".to_owned(),
661 };
662 format!(
663 "this host cannot download `{name}` over htmx: {said} is not known to be \
664 text, and htmx reads a response as text. Answer with a text media type, \
665 or offer the route as a plain link."
666 )
667 }
668
669 /// The media type to send a described file kind as.
670 ///
671 /// Only [`Accepted::Type`] is one. A [`Family`](Accepted::Family) spells
672 /// `image/*`, which is a filter and not a type, and a
673 /// [`Suffix`](Accepted::Suffix) is a name rather than a type at all — guessing
674 /// one from `.json` would be this crate keeping a suffix table that the
675 /// description layer deliberately does not have. Both fall back to the type
676 /// that means "bytes", which is what a download is.
677 fn media_type(kind: &Accepted) -> &str {
678 match kind {
679 Accepted::Type(media_type) if !media_type.is_empty() => media_type,
680 _ => "application/octet-stream",
681 }
682 }
683
684 /// `attachment`, with the name said both ways RFC 6266 allows.
685 ///
686 /// The unquoted `filename*` is percent-encoded over everything outside the
687 /// attr-char set, which is the RFC's own rule and is why this does not reach
688 /// for a general URL encoder: the sets are not the same.
689 fn disposition(name: &str) -> String {
690 const HEX: [char; 16] = [
691 '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F',
692 ];
693
694 let mut ascii = String::with_capacity(name.len());
695 let mut encoded = String::with_capacity(name.len());
696 for ch in name.chars() {
697 if ch.is_ascii() && !ch.is_ascii_control() && ch != '"' && ch != '\\' {
698 ascii.push(ch);
699 } else {
700 ascii.push('_');
701 }
702 if ch.is_ascii_alphanumeric() || "!#$&+-.^_`|~".contains(ch) {
703 encoded.push(ch);
704 } else {
705 let mut buffer = [0u8; 4];
706 for byte in ch.encode_utf8(&mut buffer).as_bytes() {
707 encoded.push('%');
708 encoded.push(HEX[usize::from(byte >> 4)]);
709 encoded.push(HEX[usize::from(byte & 0x0f)]);
710 }
711 }
712 }
713 format!("attachment; filename=\"{ascii}\"; filename*=UTF-8''{encoded}")
714 }
715
716 fn body<R: Serves + ?Sized>(
717 render: &R,
718 status: u16,
719 rendered: String,
720 retarget: Option<String>,
721 ) -> http::Response<Vec<u8>> {
722 let mut builder = http::Response::builder()
723 .status(status)
724 .header(http::header::CONTENT_TYPE, render.content_type());
725 if let Some(target) = retarget {
726 builder = builder.header(htmx::RETARGET, target);
727 }
728 builder.body(rendered.into_bytes()).unwrap_or_else(|_| {
729 // Only reachable if a renderer answered with a content type that is not
730 // a legal header value, which is our bug and not the request's.
731 http::Response::builder()
732 .status(500)
733 .body(Vec::new())
734 .expect("a response with no body and no headers is always valid")
735 })
736 }
737
738 /// The two verbs the description layer has, and nothing else.
739 fn translate(method: &http::Method) -> Option<Method> {
740 match *method {
741 http::Method::GET => Some(Method::Get),
742 http::Method::POST => Some(Method::Post),
743 http::Method::DELETE => Some(Method::Delete),
744 http::Method::PUT => Some(Method::Put),
745 _ => None,
746 }
747 }
748
749 /// Whether the body is a form these adapters read.
750 ///
751 /// `multipart/form-data` is deliberately not read. A file is a byte stream, a
752 /// description has no word for one, and buffering an upload into [`Params`]
753 /// would be the wrong shape at any size. Such a request still routes, with no
754 /// parameters from its body.
755 fn is_form(headers: &http::HeaderMap) -> bool {
756 headers
757 .get(http::header::CONTENT_TYPE)
758 .and_then(|value| value.to_str().ok())
759 .is_some_and(|value| {
760 value.split(';').next().is_some_and(|kind| {
761 kind.trim()
762 .eq_ignore_ascii_case("application/x-www-form-urlencoded")
763 })
764 })
765 }
766
767 /// Percent-decoded name and value pairs, repeats kept.
768 ///
769 /// Repeats are the point: a checkbox group submits one name several times, and
770 /// a decoder that keeps the last is a bug that only shows on the screen with
771 /// the multi-select on it.
772 fn decode_pairs(encoded: &str) -> Params {
773 form_urlencoded::parse(encoded.as_bytes())
774 .map(|(name, value)| (name.into_owned(), value.into_owned()))
775 .collect()
776 }
777
778 #[cfg(test)]
779 mod tests;
780