OXYGENHUB

Images that point at one of this hub's file pages render as the raw file (closes #346); a proposal's diffs over the API, GET …/proposals/N/diff (refs oxygenhub-android#4) 70c3d02998a2

Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key f5b430a30333
When
· 2026-10-27 22:04 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
70c3d02998a265776f5925971814ecb9f8bee7a225432ed181cedbf004a1de0c
Depends on
3f02e5ea4f2d a9088323c390 aeed63c00ad7 b5668108487f b64e2537d702 c0fac31d36fd d23464c0ac60 e57765dee61a
Depended on by
58ac360b3345
In bookmarks
main
In tags
v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24
Edits
9 atoms, 0 tree operations, 5 files

Browse the tree at this change

modified CHANGELOG.md +2 −0
…
5 unchanged lines
6 6
7 7
## 0.9.10 (2026-10-27)
8 8
9 +
- **A proposal's diffs over the API (oxygenhub-android#4).** `GET /api/v1/repos/OWNER/NAME/proposals/N/diff` gives each file the proposal changes with its diff (three lines of context, in the shape a change's files have), as the proposal page shows them, for the Android app's proposal view.
10 +
- **Images that point at a file's page show (#346).** Markdown left an image's full or root-relative address alone, so `![…](https://oxygenhub.io/OWNER/NAME/file/main/x.png)` (the address a file's page shows, as the doomsdayone/tetriblox wiki's screenshots use) pointed an `<img>` at an HTML page and showed broken. An image of one of this hub's file pages now renders as that file's raw bytes (`…/raw/REF/PATH`), as GitHub does for `blob/` images; links keep the page, and other sites' addresses are untouched.
9 11
- **Phone alerts (oxygenhub-android#16, oxygenhub-gcp decision 0008).** A phone registers its Cloud Messaging token and an X25519 public key with `PUT /api/v1/user/push`, using the token it signed in with (`DELETE` unregisters; revoking the token ends it too, migration 0101). Each notification an account is told then also goes to its phones, its title and link sealed to each phone's key (`flow_core::alert`), as a data-only, high-priority message, so Google carries only ciphertext. The hub sends as the service account its VM runs as, with the metadata server's token: no key. Off until `[push] fcm_project` (`OXYHUB_PUSH_FCM_PROJECT`) is set; a registration Cloud Messaging no longer knows is forgotten. Pins OxygenFlow main (sealed alerts).
10 12
11 13
- **A fork starts green where upstream proved the same state (#296, first slice).** Pass reuse (#265) looked in the repository's own runs only. A push's job with no pass of its own on that state now reuses one from the repositories it was forked from, nearest first (up to five up): the same state is the same changes, so upstream's receipt stands for it. Only from an upstream that is public or has the fork's owner. The run page says whose pass it was and links to it, and its check reads "passed in OWNER/NAME run #N on this state, reused".
…
380 unchanged lines
modified crates/hub/src/api/mod.rs +16 −0
…
157 unchanged lines
158 158
        .route("/repos/{owner}/{name}/proposals/{number}/reviewers/{username}", axum::routing::delete(withdraw_reviewer))
159 159
        .route("/repos/{owner}/{name}/proposals/{number}/checks", get(proposal_checks))
160 160
        .route("/repos/{owner}/{name}/proposals/{number}/files", get(proposal_files))
161 +
        .route("/repos/{owner}/{name}/proposals/{number}/diff", get(proposal_diff))
161 162
        .route("/repos/{owner}/{name}/proposals/{number}/content/{side}/{*path}", get(proposal_content))
162 163
        .route("/repos/{owner}/{name}/issues", get(issues).post(create_issue))
163 164
        .route("/repos/{owner}/{name}/issues/{number}", get(issue).post(issue_state).patch(issue_edit))
…
1410 unchanged lines
1574 1575
    ))
1575 1576
}
1576 1577
1578 +
/// `GET …/proposals/{number}/diff`: each file the proposal changes with its
1579 +
/// diff (three lines of context, as a change's), as the proposal page shows
1580 +
/// them, for clients that draw diffs (oxygenhub-android#4).
1581 +
async fn proposal_diff(
1582 +
    State(app): State<Shared>,
1583 +
    headers: HeaderMap,
1584 +
    Path((owner, name, number)): Path<(String, String, i64)>,
1585 +
) -> Result<Json<Vec<FileChange>>, ApiError> {
1586 +
    let c = caller(&app, &headers).await?;
1587 +
    let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
1588 +
    let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?;
1589 +
    let loaded = crate::web::proposals::load(&app, &repo, p).await?;
1590 +
    Ok(Json(loaded.files.into_iter().map(file_change).collect()))
1591 +
}
1592 +
1577 1593
/// One file's bytes on a side of a proposal: `base` is the target's
1578 1594
/// state, `head` the projected state. 404 when the file does not exist
1579 1595
/// on that side.
…
1667 unchanged lines
modified crates/hub/src/lib.rs +2 −0
…
123 unchanged lines
124 124
/// have run once; `serve` and the tests call this.
125 125
pub async fn build(config: Config) -> anyhow::Result<Shared> {
126 126
    let roles = config.roles()?;
127 +
    // Images of this hub's file pages render as their raw files.
128 +
    render::markdown::set_origin(config.base_url());
127 129
    std::fs::create_dir_all(config.repos_dir())?;
128 130
    let db = db::open(&config).await?;
129 131
    let repos = Arc::new(Repos::new(config.repos_dir()));
…
300 unchanged lines
modified crates/hub/src/render/markdown.rs +54 −1
…
33 unchanged lines
34 34
            Event::Start(Tag::Link { link_type, dest_url: rebase(&dest_url, base_file).into(), title, id })
35 35
        }
36 36
        Event::Start(Tag::Image { link_type, dest_url, title, id }) => {
37 -
            Event::Start(Tag::Image { link_type, dest_url: rebase(&dest_url, base_raw).into(), title, id })
37 +
            let src = raw_image(&dest_url, &origin()).unwrap_or_else(|| rebase(&dest_url, base_raw));
38 +
            Event::Start(Tag::Image { link_type, dest_url: src.into(), title, id })
38 39
        }
39 40
        other => other,
40 41
    });
…
7 unchanged lines
48 49
    builder.clean(&out).to_string()
49 50
}
50 51
52 +
/// This hub's own origin (`https://oxygenhub.io`), so an image written as
53 +
/// a full address of one of its file pages is still recognised.
54 +
static ORIGIN: std::sync::RwLock<String> = std::sync::RwLock::new(String::new());
55 +
56 +
/// Set when the hub is built, from its base URL.
57 +
pub fn set_origin(base_url: &str) {
58 +
    *ORIGIN.write().unwrap() = base_url.trim_end_matches('/').to_string();
59 +
}
60 +
61 +
fn origin() -> String {
62 +
    ORIGIN.read().unwrap().clone()
63 +
}
64 +
65 +
/// An image that points at one of this hub's file pages
66 +
/// (`[ORIGIN]/OWNER/NAME/file/REF/PATH`, the address a file's page shows)
67 +
/// is that file's raw bytes (`…/raw/REF/PATH`): the page is HTML, which an
68 +
/// `<img>` cannot show, as GitHub turns `blob/` images into raw ones.
69 +
fn raw_image(url: &str, origin: &str) -> Option<String> {
70 +
    let (host, path) = if url.starts_with('/') && !url.starts_with("//") {
71 +
        ("", url)
72 +
    } else if !origin.is_empty() && url.strip_prefix(origin).is_some_and(|p| p.starts_with('/')) {
73 +
        (origin, &url[origin.len()..])
74 +
    } else {
75 +
        return None;
76 +
    };
77 +
    let mut parts = path[1..].splitn(4, '/');
78 +
    let (owner, name, kind, rest) = (parts.next()?, parts.next()?, parts.next()?, parts.next()?);
79 +
    (kind == "file" && !owner.is_empty() && !name.is_empty() && !rest.is_empty()).then(|| format!("{host}/{owner}/{name}/raw/{rest}"))
80 +
}
81 +
51 82
/// Leave absolute URLs, fragments and mail links alone; rebase the rest.
52 83
fn rebase(url: &str, base: &str) -> String {
53 84
    if url.starts_with('#') || url.contains("://") || url.starts_with("mailto:") || url.starts_with('/') || url.starts_with("data:") {
…
20 unchanged lines
74 105
        assert!(!html.contains("<script"));
75 106
        assert!(html.contains("type=\"checkbox\""));
76 107
        assert!(render("[x](https://e.com) [y](#frag)", "/p/", "/p/").contains("href=\"https://e.com\""));
108 +
    }
109 +
110 +
    /// An image of one of this hub's file pages is its raw file: by full
111 +
    /// address or by path; another site's, a link, or a raw address stay.
112 +
    #[test]
113 +
    fn an_image_of_a_file_page_is_its_raw_file() {
114 +
        let o = "https://hub.example";
115 +
        assert_eq!(
116 +
            raw_image("https://hub.example/doomsdayone/tetriblox/file/main/docs/images/screens/menu.png", o).as_deref(),
117 +
            Some("https://hub.example/doomsdayone/tetriblox/raw/main/docs/images/screens/menu.png")
118 +
        );
119 +
        assert_eq!(raw_image("/a/b/file/v1.0/x.png", o).as_deref(), Some("/a/b/raw/v1.0/x.png"));
120 +
        assert_eq!(raw_image("https://elsewhere.example/a/b/file/main/x.png", o), None);
121 +
        assert_eq!(raw_image("https://hub.example.evil/a/b/file/main/x.png", o), None);
122 +
        assert_eq!(raw_image("/a/b/raw/main/x.png", o), None);
123 +
        assert_eq!(raw_image("//cdn.example/a/b/file/main/x.png", o), None);
124 +
        assert_eq!(raw_image("img.png", o), None);
125 +
        // Through render, with the origin set: only the image changes.
126 +
        set_origin("https://hub.example/");
127 +
        let html = render("[page](https://hub.example/a/b/file/main/x.png) ![x](https://hub.example/a/b/file/main/x.png)", "/p/", "/p/");
128 +
        assert!(html.contains("href=\"https://hub.example/a/b/file/main/x.png\""), "{html}");
129 +
        assert!(html.contains("src=\"https://hub.example/a/b/raw/main/x.png\""), "{html}");
77 130
    }
78 131
}
modified crates/hub/tests/api.rs +13 −0
…
323 unchanged lines
324 324
    assert_eq!(call(app, "GET", &side("head", "b.txt"), None, None, None, None).await.body, "b\n");
325 325
    assert_eq!(call(app, "GET", &side("base", "b.txt"), None, None, None, None).await.status, StatusCode::NOT_FOUND, "b.txt is new");
326 326
    assert_eq!(call(app, "GET", &side("middle", "a.txt"), None, None, None, None).await.status, StatusCode::BAD_REQUEST);
327 +
    // The same files with their diffs, as a change's (oxygenhub-android#4).
328 +
    let (st, v) = api(app, "GET", "/api/v1/repos/andy/demo/proposals/2/diff", None, None).await;
329 +
    assert_eq!(st, StatusCode::OK, "{v}");
330 +
    assert_eq!((v[0]["path"].as_str(), v[0]["added"].as_u64(), v[0]["truncated"].as_bool()), (Some("a.txt"), Some(1), Some(false)), "{v}");
331 +
    let lines: Vec<(String, String)> = v[0]["lines"]
332 +
        .as_array()
333 +
        .unwrap()
334 +
        .iter()
335 +
        .map(|l| (l["kind"].as_str().unwrap().to_string(), l["text"].as_str().unwrap().to_string()))
336 +
        .collect();
337 +
    assert_eq!(lines, vec![("context".into(), "a".into()), ("add".into(), "A".into())], "{v}");
338 +
    assert_eq!(v[1]["kind"], "added");
339 +
    assert_eq!(api(app, "GET", "/api/v1/repos/andy/demo/proposals/99/diff", None, None).await.0, StatusCode::NOT_FOUND);
327 340
    // An anchored comment comes back with its line in the projected state.
328 341
    let (st, v) = api(
329 342
        app,
…
1479 unchanged lines