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 `` (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) ", "/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 |