A mistyped address shows the not-found page (closes #294) 8ad8e656a3a6
An address the router matched but could not read (a project or issue number that is not a number, a malformed query) answered with axum's plain 'Invalid URL: Cannot parse…'. A layer turns axum's own rejections of GET and HEAD page requests into the site's not-found page; a handler's own plain answers (the live stream's 400) and the oxy protocol are left alone.
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 2b8d7574df44
- When
- · 2026-10-24 21:28 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
8ad8e656a3a654690a1c7577e34a8a744611e1a0119167bc3209cd5c87c18787- Depends on
- 19bb31ff1f5c 43e8ca33bc5d 8ac2aa4ca6d1 a7e6e8e6094c b5668108487f d23464c0ac60
- Depended on by
- a22e4aec2e28 a66c05a6a881
- In bookmarks
- api-unknown-fields diffusion-pin-bookmark draft-ready main proposal-list-fast
- In tags
- v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 4 atoms, 0 tree operations, 3 files
Browse the tree at this change
modified CHANGELOG.md +1 −0
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | ## Unreleased |
|
| 6 | 6 | ||
| 7 | + | - **A mistyped address shows the not-found page (#294):** an address the hub could not read, such as a project or issue number that is not a number, answered with a raw parse error ("Invalid URL: Cannot parse value …"); it now shows the site's not-found page, signed-in header and all. |
|
| 7 | 8 | - **Embed a drop (#280):** a drop's page offers an `<iframe>` snippet; `/{owner}/drops/{id}/embed` shows its files with a link back, framed by any site, with no scripts and no forms. |
|
| 8 | 9 | - **Comments on drops (#280):** a drop's page has comments: signed-in readers write them in Markdown, the drop's owner is told, and the author, the owner or a site administrator deletes one. |
|
| 9 | 10 | - **Star a drop (#280):** a signed-in visitor stars a drop, or takes the star back, from its page, which shows how many it has. |
|
| … | 284 unchanged lines |
modified crates/hub/src/web/mod.rs +41 −0
| … | 372 unchanged lines |
||
| 373 | 373 | .route("/{owner}/{name}/milestones", get(issues::milestones).post(issues::save_milestone)) |
|
| 374 | 374 | .route("/{owner}/{name}/oxy/v1/{endpoint}", any(crate::protocol::dispatch).layer(DefaultBodyLimit::max(push_limit))) |
|
| 375 | 375 | .fallback(not_found) |
|
| 376 | + | .layer(axum::middleware::from_fn_with_state(app.clone(), rejections_as_pages)) |
|
| 376 | 377 | .layer(axum::middleware::from_fn_with_state(app.clone(), same_origin)) |
|
| 377 | 378 | .layer(CompressionLayer::new()) |
|
| 378 | 379 | .layer(TraceLayer::new_for_http()) |
|
| … | 385 unchanged lines |
||
| 764 | 765 | } |
|
| 765 | 766 | } |
|
| 766 | 767 | ||
| 768 | + | /// A page address the router matched but could not read (a number that is |
|
| 769 | + | /// not one, a malformed query): axum answers that in plain text before any |
|
| 770 | + | /// handler runs. For pages, that is the site's not-found page instead |
|
| 771 | + | /// (#294); the oxy protocol and anything not asked for with GET or HEAD keep |
|
| 772 | + | /// the plain answer. |
|
| 773 | + | async fn rejections_as_pages( |
|
| 774 | + | axum::extract::State(app): axum::extract::State<Shared>, |
|
| 775 | + | req: axum::extract::Request, |
|
| 776 | + | next: axum::middleware::Next, |
|
| 777 | + | ) -> Response { |
|
| 778 | + | let page = matches!(*req.method(), axum::http::Method::GET | axum::http::Method::HEAD) && !req.uri().path().contains("/oxy/v1/"); |
|
| 779 | + | if !page { |
|
| 780 | + | return next.run(req).await; |
|
| 781 | + | } |
|
| 782 | + | let (uri, headers) = (req.uri().clone(), req.headers().clone()); |
|
| 783 | + | let res = next.run(req).await; |
|
| 784 | + | let plain = res.headers().get(header::CONTENT_TYPE).and_then(|v| v.to_str().ok()).is_some_and(|t| t.starts_with("text/plain")); |
|
| 785 | + | if !(plain && matches!(res.status(), StatusCode::BAD_REQUEST | StatusCode::UNPROCESSABLE_ENTITY)) { |
|
| 786 | + | return res; |
|
| 787 | + | } |
|
| 788 | + | // Only axum's own rejections: a handler's plain 400 (the live stream's |
|
| 789 | + | // "unknown topic") is the handler's answer and stays. |
|
| 790 | + | let (head, body) = res.into_parts(); |
|
| 791 | + | let Ok(bytes) = axum::body::to_bytes(body, 4096).await else { return StatusCode::BAD_REQUEST.into_response() }; |
|
| 792 | + | let rejected = [&b"Invalid URL"[..], b"Failed to deserialize query string", b"Failed to deserialize the query string"] |
|
| 793 | + | .iter() |
|
| 794 | + | .any(|p| bytes.starts_with(p)); |
|
| 795 | + | if !rejected { |
|
| 796 | + | return Response::from_parts(head, axum::body::Body::from(bytes)); |
|
| 797 | + | } |
|
| 798 | + | let mut probe = axum::http::Request::builder().uri(uri.clone()).body(()).expect("a request"); |
|
| 799 | + | *probe.headers_mut() = headers; |
|
| 800 | + | let (mut parts, ()) = probe.into_parts(); |
|
| 801 | + | let viewer = match <Viewer as axum::extract::FromRequestParts<Shared>>::from_request_parts(&mut parts, &app).await { |
|
| 802 | + | Ok(v) => v, |
|
| 803 | + | Err(never) => match never {}, |
|
| 804 | + | }; |
|
| 805 | + | Error::not_found(Shell::new(&app, uri.path(), &viewer)).into_response() |
|
| 806 | + | } |
|
| 807 | + | ||
| 767 | 808 | async fn not_found(axum::extract::State(app): axum::extract::State<Shared>, viewer: Viewer, uri: axum::http::Uri) -> Response { |
|
| 768 | 809 | Error::not_found(Shell::new(&app, uri.path(), &viewer)).into_response() |
|
| 769 | 810 | } |
|
| … | 138 unchanged lines |
modified crates/hub/tests/browse.rs +7 −0
| … | 107 unchanged lines |
||
| 108 | 108 | let (_client, c1, c2) = populate(&format!("{}/andy/demo", h.base), work.path()); |
|
| 109 | 109 | ||
| 110 | 110 | // Help, filled in for this hub (hub spec §6.3). |
|
| 111 | + | // #294: an address the router matches but cannot read (a project or |
|
| 112 | + | // issue number that is not one) is the not-found page, not a parse error. |
|
| 113 | + | for bad in ["/projects/andy/metalgear2", "/andy/demo/issue/abc", "/andy/demo/proposal/x1"] { |
|
| 114 | + | let (status, body) = get(&h.app, bad).await; |
|
| 115 | + | assert_eq!(status, StatusCode::NOT_FOUND, "{bad}"); |
|
| 116 | + | assert!(body.contains("There is nothing at this address.") && !body.contains("Cannot parse"), "{bad}: {body}"); |
|
| 117 | + | } |
|
| 111 | 118 | let help = page(&h.app, "/help").await; |
|
| 112 | 119 | assert!(help.contains(&format!("oxy remote add hub {}/OWNER/REPOSITORY", h.base)), "commands carry this hub's address"); |
|
| 113 | 120 | assert!(help.contains(&format!("oxy clone {}/OWNER/REPOSITORY", h.base))); |
|
| … | 218 unchanged lines |