OXYGENHUB

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