fix(web): signed-in pages say Cache-Control: private, no-store (closes #158) 33bd0bd45f8a
A page for a signed-in viewer may list private repositories, and the dashboard's sky carries their activity. The hub relied on Cloudflare not caching HTML; now an HTML response to a request with a session cookie that sets no Cache-Control of its own says private, no-store. Tests prove the sky holds only the viewer's own repositories, and that there is none signed out.
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-13 18:27 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
33bd0bd45f8a872f62627eadce4c910490dc48206381958a7bf44f0e622e0112- Depends on
- 17ca6cddc534 18e381ea88c2 25ab8c73514a 39442b000e6b b5668108487f d23464c0ac60 eb2a3502601d f8cf54e99879
- Depended on by
- bff9106edcba 6246d1e51f4f 0a3b244b748f 61ecbd299f22
- In bookmarks
- api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
- In tags
- v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 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
- 6 atoms, 0 tree operations, 3 files
Browse the tree at this change
modified CHANGELOG.md +2 −0
| … | 5 unchanged lines |
||
| 6 | 6 | ||
| 7 | 7 | - **Diffusion (#148, docs/diffusion.md).** When a repository that others pin as a subrepo moves on, each consumer that follows it gets a proposal moving the pin, signed by the hub, tested by the consumer's own pipeline, with the upstream's changes listed. One proposal per consumer and subrepo, kept up to date; never landed by itself. Turned on per repository (Settings › Diffusion, `PUT /repos/{owner}/{name}/diffusion`, or `follow` in `.oxymodules`), off by default; `green` waits for the upstream's run to pass. A repository's page shows who uses it and how far behind each is (Diffusion › Diffuses to, `GET /repos/{owner}/{name}/consumers`). Consumers are bumped on the bookmark their work lands on, learned from where their proposals land. |
|
| 8 | 8 | - **Diffusion in the API (#153):** `GET /repos/{owner}/{name}/diffusion` gives what a repository takes from and what it diffuses to, each with its pill, for the VS Code extension. |
|
| 9 | + | - **Your sky (#157, docs/backgrounds.md):** a quiet, moving backdrop behind the dashboard, drawn in Rust compiled to WebAssembly: your repositories as clusters and their last 30 days (every change pushed, proposal and issue events, releases, Flow runs) as marks around them; the cursor lights and links them, and names the cluster it rests by. Settings › Appearance: Moving, Still or Off; still under reduced motion. |
|
| 10 | + | - **Signed-in pages are private (#158):** HTML for a signed-in viewer says `Cache-Control: private, no-store`, so no proxy or CDN serves one viewer's page to another. |
|
| 9 | 11 | - **The dashboard in cards (#155):** each section a rounded card with its heading and count in a shaded bar, your repositories a sidebar card with New in its heading. |
|
| 10 | 12 | - **Tidier pages (#152).** The repository sidebar is a column of cards with shaded headings: Diffusion in one card (Takes from and Diffuses to, each with its pill), the ten most recently moved bookmarks with a link to the rest, and the hub's delegate key shown with the OxygenHub mark. The code tree has a shaded head with the latest change and the history, and folder and file icons; the site bar has icons; the Diffusion tables share one column grid; Help › Coming from GitHub covers submodules and Renovate. |
|
| 11 | 13 | - **Admin › Runners › Images (#146):** every runner image, where it is and what uses it. |
|
| … | 164 unchanged lines |
modified crates/hub/src/lib.rs +22 −1
| … | 144 unchanged lines |
||
| 145 | 145 | ||
| 146 | 146 | /// The full router: pages, API, protocol and static assets. |
|
| 147 | 147 | pub fn router(app: Shared) -> axum::Router { |
|
| 148 | - | web::router(app).layer(axum::middleware::from_fn(slow_requests)) |
|
| 148 | + | web::router(app).layer(axum::middleware::from_fn(private_pages)).layer(axum::middleware::from_fn(slow_requests)) |
|
| 149 | 149 | } |
|
| 150 | 150 | ||
| 151 | 151 | /// Requests slower than this are logged, with their path (#145). |
|
| … | 12 unchanged lines |
||
| 164 | 164 | res |
|
| 165 | 165 | } |
|
| 166 | 166 | ||
| 167 | + | /// A page for a signed-in viewer is theirs alone: it may list private |
|
| 168 | + | /// repositories, and the dashboard's sky carries their activity (#157). An |
|
| 169 | + | /// HTML response to a request with a session cookie, which has not said |
|
| 170 | + | /// how to cache itself, says `private, no-store`, so no shared cache (a |
|
| 171 | + | /// proxy or a CDN in front of the hub) ever serves it to anyone else. |
|
| 172 | + | async fn private_pages(req: axum::extract::Request, next: axum::middleware::Next) -> axum::response::Response { |
|
| 173 | + | use axum::http::header; |
|
| 174 | + | let signed_in = req |
|
| 175 | + | .headers() |
|
| 176 | + | .get_all(header::COOKIE) |
|
| 177 | + | .iter() |
|
| 178 | + | .filter_map(|v| v.to_str().ok()) |
|
| 179 | + | .any(|c| c.split(';').any(|kv| kv.trim_start().starts_with("oxyhub="))); |
|
| 180 | + | let mut res = next.run(req).await; |
|
| 181 | + | let html = res.headers().get(header::CONTENT_TYPE).and_then(|v| v.to_str().ok()).is_some_and(|t| t.starts_with("text/html")); |
|
| 182 | + | if signed_in && html && !res.headers().contains_key(header::CACHE_CONTROL) { |
|
| 183 | + | res.headers_mut().insert(header::CACHE_CONTROL, header::HeaderValue::from_static("private, no-store")); |
|
| 184 | + | } |
|
| 185 | + | res |
|
| 186 | + | } |
|
| 187 | + | ||
| 167 | 188 | /// Serve on the configured addresses until the process is told to stop. |
|
| 168 | 189 | pub async fn serve(app: Shared) -> anyhow::Result<()> { |
|
| 169 | 190 | let router = router(app.clone()); |
|
| … | 33 unchanged lines |
modified crates/hub/tests/issues.rs +13 −0
| … | 382 unchanged lines |
||
| 383 | 383 | let events = data["events"].as_array().unwrap(); |
|
| 384 | 384 | assert!(events.iter().any(|e| e[1] == 1), "the proposals opened are marks: {data}"); |
|
| 385 | 385 | assert!(events.iter().all(|e| e[0].as_u64().unwrap() < repos.len() as u64)); |
|
| 386 | + | // His page is his alone: no shared cache may keep it (#157). |
|
| 387 | + | let res = oxygenhub::router(app.clone()) |
|
| 388 | + | .oneshot(Request::builder().uri("/").header(header::COOKIE, format!("oxyhub={andy}")).body(Body::empty()).unwrap()) |
|
| 389 | + | .await |
|
| 390 | + | .unwrap(); |
|
| 391 | + | assert_eq!(res.headers()[header::CACHE_CONTROL], "private, no-store"); |
|
| 392 | + | // Signed out there is no sky at all. |
|
| 393 | + | assert!(!anon.body.contains("backdrop-data")); |
|
| 386 | 394 | // The engine is served as WebAssembly. |
|
| 387 | 395 | let res = oxygenhub::router(app.clone()) |
|
| 388 | 396 | .oneshot(Request::builder().uri("/static/backdrop/backdrop_bg.wasm").body(Body::empty()).unwrap()) |
|
| … | 10 unchanged lines |
||
| 399 | 407 | ||
| 400 | 408 | // Bob sees his own proposal on the public repository, never the private one's. |
|
| 401 | 409 | let page = get(app, "/", Some(&bob)).await; |
|
| 410 | + | // His sky holds only his own repositories: never Andy's, public or |
|
| 411 | + | // private, and nothing of the private one (#157). |
|
| 412 | + | let start = page.body.find(r#"id="backdrop-data">"#).unwrap() + r#"id="backdrop-data">"#.len(); |
|
| 413 | + | let sky = &page.body[start..start + page.body[start..].find("</script>").unwrap()]; |
|
| 414 | + | assert!(!sky.contains("andy/secret") && !sky.contains("andy/demo"), "{sky}"); |
|
| 402 | 415 | assert!(page.body.contains("Your proposals</h2><span class=\"count-chip\">1</span>"), "{}", page.body); |
|
| 403 | 416 | assert!(!page.body.contains("Hidden work")); |
|
| 404 | 417 | assert!(page.body.contains("Waiting for your review</h2><span class=\"count-chip\">0</span>")); |
|
| … | 548 unchanged lines |