OxygenHub 0.9.19: attachments on issues and comments, over the API and oh --attach; images by their bytes shown in place, everything else a download (closes #350) 1c52d940b4a0
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key f44bbe3350b5
- When
- · 2026-11-01 03:21 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
1c52d940b4a0435d9a46400293c69d8b6588231b3f3b1fafd7ead56feb7c1d3a- Depends on
- 04f9c984439e 166f6e9bf4f1 17a3a84b00d1 25ab8c73514a 2e9188ca4f98 3b1bc27fe805 3f4b0ea6eabf 42c77f6515c2 42fa95540468 456f4197ade3 4e2cad00a484 5c6e92fd8d71 75491d35c258 93be9ce4653e b5668108487f b7e1be264c0a bd91edeb44f8 d23464c0ac60 d7e2c2754807 df71c7892d27
- Depended on by
- 306aec1839aa a22e4aec2e28 47932fa8d6f3 8f1d76f4169a
- In bookmarks
- main
- In tags
- v0.9.19 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24
- Edits
- 36 atoms, 4 tree operations, 17 files
Browse the tree at this change
added crates/hub/migrations-pg/0103_attachments.sql +15 −0
| 1 | + | -- Files attached to issues, comments and proposals (oxygenhub#350): kept by |
|
| 2 | + | -- content in the hub's assets store, as release assets are, and served |
|
| 3 | + | -- under the repository's read rules. |
|
| 4 | + | CREATE TABLE attachments ( |
|
| 5 | + | id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, |
|
| 6 | + | repo_id BIGINT NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, |
|
| 7 | + | name TEXT NOT NULL, |
|
| 8 | + | size BIGINT NOT NULL, |
|
| 9 | + | hash TEXT NOT NULL, |
|
| 10 | + | content_type TEXT NOT NULL, |
|
| 11 | + | uploader_id BIGINT, |
|
| 12 | + | created_ms BIGINT NOT NULL |
|
| 13 | + | ); |
|
| 14 | + | CREATE INDEX attachments_repo ON attachments (repo_id); |
|
| 15 | + | CREATE INDEX attachments_hash ON attachments (hash); |
added crates/hub/migrations/0103_attachments.sql +15 −0
| 1 | + | -- Files attached to issues, comments and proposals (oxygenhub#350): kept by |
|
| 2 | + | -- content in the hub's assets store, as release assets are, and served |
|
| 3 | + | -- under the repository's read rules. |
|
| 4 | + | CREATE TABLE attachments ( |
|
| 5 | + | id INTEGER PRIMARY KEY, |
|
| 6 | + | repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, |
|
| 7 | + | name TEXT NOT NULL, |
|
| 8 | + | size INTEGER NOT NULL, |
|
| 9 | + | hash TEXT NOT NULL, |
|
| 10 | + | content_type TEXT NOT NULL, |
|
| 11 | + | uploader_id INTEGER, |
|
| 12 | + | created_ms INTEGER NOT NULL |
|
| 13 | + | ); |
|
| 14 | + | CREATE INDEX attachments_repo ON attachments (repo_id); |
|
| 15 | + | CREATE INDEX attachments_hash ON attachments (hash); |
added crates/hub/src/api/attachments.rs +125 −0
| 1 | + | //! Attachments over the API (oxygenhub#350): `POST |
|
| 2 | + | //! /api/v1/repos/{owner}/{name}/attachments?name=FILE` with the file's |
|
| 3 | + | //! bytes as the body. Anyone signed in who may read the repository and |
|
| 4 | + | //! write issues may attach; what comes back says how to show it. |
|
| 5 | + | ||
| 6 | + | use super::{caller, reach, ApiError}; |
|
| 7 | + | use crate::auth::roles::Access; |
|
| 8 | + | use crate::db; |
|
| 9 | + | use crate::Shared; |
|
| 10 | + | use axum::body::Bytes; |
|
| 11 | + | use axum::extract::{Path, Query, State}; |
|
| 12 | + | use axum::http::{HeaderMap, StatusCode}; |
|
| 13 | + | use axum::Json; |
|
| 14 | + | ||
| 15 | + | /// The largest file one attachment may be. |
|
| 16 | + | pub const ATTACHMENT_MAX: usize = 25 << 20; |
|
| 17 | + | ||
| 18 | + | #[derive(serde::Deserialize)] |
|
| 19 | + | pub struct Named { |
|
| 20 | + | name: String, |
|
| 21 | + | } |
|
| 22 | + | ||
| 23 | + | /// An image type a page may show, from the bytes; never what the uploader |
|
| 24 | + | /// said (an SVG or HTML file would run in the hub's origin). |
|
| 25 | + | pub fn image_type(bytes: &[u8]) -> Option<&'static str> { |
|
| 26 | + | if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { |
|
| 27 | + | Some("image/png") |
|
| 28 | + | } else if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) { |
|
| 29 | + | Some("image/jpeg") |
|
| 30 | + | } else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") { |
|
| 31 | + | Some("image/gif") |
|
| 32 | + | } else if bytes.len() >= 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP" { |
|
| 33 | + | Some("image/webp") |
|
| 34 | + | } else { |
|
| 35 | + | None |
|
| 36 | + | } |
|
| 37 | + | } |
|
| 38 | + | ||
| 39 | + | /// A file name to keep: its last part, plain, not hidden. |
|
| 40 | + | pub fn file_name(name: &str) -> Option<String> { |
|
| 41 | + | let base = name.rsplit(['/', '\\']).next().unwrap_or(name).trim(); |
|
| 42 | + | let clean: String = base.chars().filter(|c| !c.is_control() && !matches!(c, '"' | '<' | '>' | '`')).collect(); |
|
| 43 | + | (!clean.is_empty() && !clean.starts_with('.') && clean.len() <= 200).then_some(clean) |
|
| 44 | + | } |
|
| 45 | + | ||
| 46 | + | /// Percent-encode a path segment. |
|
| 47 | + | fn seg(s: &str) -> String { |
|
| 48 | + | s.bytes() |
|
| 49 | + | .map(|b| if b.is_ascii_alphanumeric() || b"-._~".contains(&b) { (b as char).to_string() } else { format!("%{b:02X}") }) |
|
| 50 | + | .collect() |
|
| 51 | + | } |
|
| 52 | + | ||
| 53 | + | pub async fn upload( |
|
| 54 | + | State(app): State<Shared>, |
|
| 55 | + | headers: HeaderMap, |
|
| 56 | + | Path((owner, name)): Path<(String, String)>, |
|
| 57 | + | Query(q): Query<Named>, |
|
| 58 | + | body: Bytes, |
|
| 59 | + | ) -> Result<(StatusCode, Json<hub_api::Attachment>), ApiError> { |
|
| 60 | + | let c = caller(&app, &headers).await?; |
|
| 61 | + | let me = c.require("issues:write").or_else(|_| c.require("repo:write"))?.clone(); |
|
| 62 | + | let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
|
| 63 | + | let Some(file) = file_name(&q.name) else { |
|
| 64 | + | return Err(ApiError( |
|
| 65 | + | StatusCode::UNPROCESSABLE_ENTITY, |
|
| 66 | + | "give the file a name (?name=FILE): plain, not hidden, at most 200 bytes".into(), |
|
| 67 | + | )); |
|
| 68 | + | }; |
|
| 69 | + | if body.is_empty() { |
|
| 70 | + | return Err(ApiError(StatusCode::UNPROCESSABLE_ENTITY, "the file is empty".into())); |
|
| 71 | + | } |
|
| 72 | + | if body.len() > ATTACHMENT_MAX { |
|
| 73 | + | return Err(ApiError(StatusCode::PAYLOAD_TOO_LARGE, format!("an attachment is at most {} MiB", ATTACHMENT_MAX >> 20))); |
|
| 74 | + | } |
|
| 75 | + | let image = image_type(&body); |
|
| 76 | + | let content_type = image.unwrap_or("application/octet-stream"); |
|
| 77 | + | let hash = hex::encode(blake3::hash(&body).as_bytes()); |
|
| 78 | + | let path = crate::web::releases::blob_path(&app, &hash); |
|
| 79 | + | if !path.exists() { |
|
| 80 | + | let parent = path.parent().expect("a blob is in a directory"); |
|
| 81 | + | tokio::fs::create_dir_all(parent).await.map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; |
|
| 82 | + | let tmp = path.with_extension("partial"); |
|
| 83 | + | tokio::fs::write(&tmp, &body).await.map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; |
|
| 84 | + | tokio::fs::rename(&tmp, &path).await.map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; |
|
| 85 | + | } |
|
| 86 | + | let a = db::attachments::add(&app.db, row.id, &file, body.len() as i64, &hash, content_type, Some(me.id)).await?; |
|
| 87 | + | let url = format!("{}/{}/{}/attachments/{}/{}", app.config.base_url().trim_end_matches('/'), row.owner, row.name, a.id, seg(&file)); |
|
| 88 | + | let markdown = if image.is_some() { format!("") } else { format!("[{file}]({url})") }; |
|
| 89 | + | Ok(( |
|
| 90 | + | StatusCode::CREATED, |
|
| 91 | + | Json(hub_api::Attachment { |
|
| 92 | + | id: a.id, |
|
| 93 | + | name: file, |
|
| 94 | + | size: a.size, |
|
| 95 | + | content_type: content_type.into(), |
|
| 96 | + | inline: image.is_some(), |
|
| 97 | + | url, |
|
| 98 | + | markdown, |
|
| 99 | + | }), |
|
| 100 | + | )) |
|
| 101 | + | } |
|
| 102 | + | ||
| 103 | + | #[cfg(test)] |
|
| 104 | + | mod tests { |
|
| 105 | + | use super::*; |
|
| 106 | + | ||
| 107 | + | #[test] |
|
| 108 | + | fn only_real_images_show_in_place() { |
|
| 109 | + | assert_eq!(image_type(b"\x89PNG\r\n\x1a\n...."), Some("image/png")); |
|
| 110 | + | assert_eq!(image_type(&[0xFF, 0xD8, 0xFF, 0xE0]), Some("image/jpeg")); |
|
| 111 | + | assert_eq!(image_type(b"GIF89a.."), Some("image/gif")); |
|
| 112 | + | assert_eq!(image_type(b"RIFF\0\0\0\0WEBPVP8 "), Some("image/webp")); |
|
| 113 | + | assert_eq!(image_type(b"<svg xmlns=\"http://www.w3.org/2000/svg\"><script/></svg>"), None, "an SVG is a download"); |
|
| 114 | + | assert_eq!(image_type(b"<html><script>alert(1)</script>"), None); |
|
| 115 | + | } |
|
| 116 | + | ||
| 117 | + | #[test] |
|
| 118 | + | fn file_names_are_kept_plain() { |
|
| 119 | + | assert_eq!(file_name("C:\\Users\\a\\shot.png").as_deref(), Some("shot.png")); |
|
| 120 | + | assert_eq!(file_name("../../etc/passwd").as_deref(), Some("passwd")); |
|
| 121 | + | assert_eq!(file_name("a\"b<c>.txt").as_deref(), Some("abc.txt")); |
|
| 122 | + | assert_eq!(file_name(".env"), None); |
|
| 123 | + | assert_eq!(file_name(" "), None); |
|
| 124 | + | } |
|
| 125 | + | } |
added crates/hub/src/db/attachments.rs +50 −0
| 1 | + | //! Attachments (oxygenhub#350): files people attach to issues, comments and |
|
| 2 | + | //! proposals, by content in the assets store. |
|
| 3 | + | ||
| 4 | + | use super::{now_ms, Pool}; |
|
| 5 | + | ||
| 6 | + | #[derive(Clone, Debug, sqlx::FromRow)] |
|
| 7 | + | pub struct Attachment { |
|
| 8 | + | pub id: i64, |
|
| 9 | + | pub repo_id: i64, |
|
| 10 | + | pub name: String, |
|
| 11 | + | pub size: i64, |
|
| 12 | + | pub hash: String, |
|
| 13 | + | pub content_type: String, |
|
| 14 | + | pub uploader_id: Option<i64>, |
|
| 15 | + | pub created_ms: i64, |
|
| 16 | + | } |
|
| 17 | + | ||
| 18 | + | pub async fn add( |
|
| 19 | + | pool: &Pool, |
|
| 20 | + | repo_id: i64, |
|
| 21 | + | name: &str, |
|
| 22 | + | size: i64, |
|
| 23 | + | hash: &str, |
|
| 24 | + | content_type: &str, |
|
| 25 | + | uploader_id: Option<i64>, |
|
| 26 | + | ) -> sqlx::Result<Attachment> { |
|
| 27 | + | sqlx::query_as( |
|
| 28 | + | "INSERT INTO attachments (repo_id, name, size, hash, content_type, uploader_id, created_ms) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *", |
|
| 29 | + | ) |
|
| 30 | + | .bind(repo_id) |
|
| 31 | + | .bind(name) |
|
| 32 | + | .bind(size) |
|
| 33 | + | .bind(hash) |
|
| 34 | + | .bind(content_type) |
|
| 35 | + | .bind(uploader_id) |
|
| 36 | + | .bind(now_ms()) |
|
| 37 | + | .fetch_one(pool) |
|
| 38 | + | .await |
|
| 39 | + | } |
|
| 40 | + | ||
| 41 | + | /// One of a repository's attachments. |
|
| 42 | + | pub async fn get(pool: &Pool, repo_id: i64, id: i64) -> sqlx::Result<Option<Attachment>> { |
|
| 43 | + | sqlx::query_as("SELECT * FROM attachments WHERE repo_id = $1 AND id = $2").bind(repo_id).bind(id).fetch_optional(pool).await |
|
| 44 | + | } |
|
| 45 | + | ||
| 46 | + | /// Whether any attachment keeps the blob `hash`. |
|
| 47 | + | pub async fn hash_in_use(pool: &Pool, hash: &str) -> sqlx::Result<bool> { |
|
| 48 | + | let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM attachments WHERE hash = $1").bind(hash).fetch_one(pool).await?; |
|
| 49 | + | Ok(n > 0) |
|
| 50 | + | } |
modified CHANGELOG.md +4 −0
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | OxygenHub runs at https://oxygenhub.io; each deploy's build is on the About page and at `/api/v1/version`. |
|
| 4 | 4 | ||
| 5 | + | ## 0.9.19 (2026-11-01) |
|
| 6 | + | ||
| 7 | + | - **Added: attachments on issues and comments (#350).** Screenshots and any other file: `POST /api/v1/repos/OWNER/NAME/attachments?name=FILE` with the file's bytes answers with its address and the Markdown to use, and `oh issue create` and `oh issue comment` take `--attach FILE` (repeatable), so a screenshot pasted into Claude Code can go onto the issue it files. An image (by its bytes: PNG, JPEG, GIF, WebP) shows in place; anything else, SVG included, is a download. Attachments are served under the repository's read rules and can run nothing (no sniffing, a policy that allows nothing, never in a shared cache). Pasting and dragging into the web editors come later. |
|
| 8 | + | ||
| 5 | 9 | ## 0.9.18 (2026-10-31) |
|
| 6 | 10 | ||
| 7 | 11 | - **Added: `oh mcp`, OxygenHub for AI agents (#353).** An MCP server on stdio for the repository `oh` resolves: tools to read issues, proposals with their files and checks, OxygenFlow runs, job logs, reports, test results, releases, drops and the inbox; with `--allow-write`, to open, comment on and close issues, comment on proposals and re-run Flow runs. What people wrote on the hub comes back under `untrusted`, so an agent reads it as data, never as instructions. Built on oxy-core's MCP module (oxygen#159). See docs/oh.md. |
|
| … | 431 unchanged lines |
modified Cargo.lock +3 −3
| … | 1459 unchanged lines |
||
| 1460 | 1460 | ||
| 1461 | 1461 | [[package]] |
|
| 1462 | 1462 | name = "hub-api" |
|
| 1463 | - | version = "0.9.18" |
|
| 1463 | + | version = "0.9.19" |
|
| 1464 | 1464 | dependencies = [ |
|
| 1465 | 1465 | "schemars", |
|
| 1466 | 1466 | "serde", |
|
| … | 797 unchanged lines |
||
| 2264 | 2264 | ||
| 2265 | 2265 | [[package]] |
|
| 2266 | 2266 | name = "oh" |
|
| 2267 | - | version = "0.9.18" |
|
| 2267 | + | version = "0.9.19" |
|
| 2268 | 2268 | dependencies = [ |
|
| 2269 | 2269 | "anyhow", |
|
| 2270 | 2270 | "axum", |
|
| … | 86 unchanged lines |
||
| 2357 | 2357 | ||
| 2358 | 2358 | [[package]] |
|
| 2359 | 2359 | name = "oxygenhub" |
|
| 2360 | - | version = "0.9.18" |
|
| 2360 | + | version = "0.9.19" |
|
| 2361 | 2361 | dependencies = [ |
|
| 2362 | 2362 | "ammonia", |
|
| 2363 | 2363 | "anyhow", |
|
| … | 2513 unchanged lines |
modified Cargo.toml +1 −1
| … | 6 unchanged lines |
||
| 7 | 7 | resolver = "2" |
|
| 8 | 8 | ||
| 9 | 9 | [workspace.package] |
|
| 10 | - | version = "0.9.18" |
|
| 10 | + | version = "0.9.19" |
|
| 11 | 11 | edition = "2021" |
|
| 12 | 12 | license = "MIT OR Apache-2.0" |
|
| 13 | 13 | authors = ["Andy McCall <[email protected]>"] |
|
| … | 60 unchanged lines |
modified crates/hub-api/src/lib.rs +20 −0
| … | 1337 unchanged lines |
||
| 1338 | 1338 | pub body: String, |
|
| 1339 | 1339 | } |
|
| 1340 | 1340 | ||
| 1341 | + | /// `POST /repos/{owner}/{name}/attachments?name=FILE` (oxygenhub#350): a |
|
| 1342 | + | /// file attached for an issue, comment or proposal to show; the body is the |
|
| 1343 | + | /// file's bytes. |
|
| 1344 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| 1345 | + | pub struct Attachment { |
|
| 1346 | + | pub id: i64, |
|
| 1347 | + | pub name: String, |
|
| 1348 | + | pub size: i64, |
|
| 1349 | + | /// Checked from the bytes: an image the page may show, or |
|
| 1350 | + | /// `application/octet-stream`. |
|
| 1351 | + | pub content_type: String, |
|
| 1352 | + | /// An image shown in place; anything else is a download. |
|
| 1353 | + | pub inline: bool, |
|
| 1354 | + | pub url: String, |
|
| 1355 | + | /// What to put in an issue or comment: `` for an image, |
|
| 1356 | + | /// `[name](url)` otherwise. |
|
| 1357 | + | pub markdown: String, |
|
| 1358 | + | } |
|
| 1359 | + | ||
| 1341 | 1360 | /// A drop's stars after `PUT` or `DELETE /drops/{id}/star`. |
|
| 1342 | 1361 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| 1343 | 1362 | pub struct DropStars { |
|
| … | 208 unchanged lines |
||
| 1552 | 1571 | ("DropComment", schemars::schema_for!(DropComment)), |
|
| 1553 | 1572 | ("NewDropComment", schemars::schema_for!(NewDropComment)), |
|
| 1554 | 1573 | ("DropStars", schemars::schema_for!(DropStars)), |
|
| 1574 | + | ("Attachment", schemars::schema_for!(Attachment)), |
|
| 1555 | 1575 | ("NewDrop", schemars::schema_for!(NewDrop)), |
|
| 1556 | 1576 | ("DropEdit", schemars::schema_for!(DropEdit)), |
|
| 1557 | 1577 | ("UserProposals", schemars::schema_for!(UserProposals)), |
|
| … | 304 unchanged lines |
modified crates/hub/src/api/mod.rs +6 −0
| … | 14 unchanged lines |
||
| 15 | 15 | use hub_api::*; |
|
| 16 | 16 | use serde::Deserialize; |
|
| 17 | 17 | ||
| 18 | + | mod attachments; |
|
| 18 | 19 | mod backups; |
|
| 19 | 20 | pub mod device; |
|
| 21 | + | pub use attachments::image_type as attachment_image_type; |
|
| 20 | 22 | mod drops; |
|
| 21 | 23 | mod images; |
|
| 22 | 24 | mod projects; |
|
| … | 100 unchanged lines |
||
| 123 | 125 | .route("/drops", get(drops::mine).post(drops::create)) |
|
| 124 | 126 | .route("/drops/{id}", get(drops::view).patch(drops::edit).delete(drops::delete)) |
|
| 125 | 127 | .route("/drops/{id}/fork", post(drops::fork)) |
|
| 128 | + | .route( |
|
| 129 | + | "/repos/{owner}/{name}/attachments", |
|
| 130 | + | post(attachments::upload).layer(axum::extract::DefaultBodyLimit::max(attachments::ATTACHMENT_MAX + 4096)), |
|
| 131 | + | ) |
|
| 126 | 132 | .route("/drops/{id}/star", axum::routing::put(drops::star).delete(drops::unstar)) |
|
| 127 | 133 | .route("/drops/{id}/comments", get(drops::comments).post(drops::add_comment)) |
|
| 128 | 134 | .route("/drops/{id}/comments/{n}", axum::routing::delete(drops::delete_comment)) |
|
| … | 3347 unchanged lines |
modified crates/hub/src/db/mod.rs +1 −0
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | pub mod accounts; |
|
| 6 | 6 | pub mod activity; |
|
| 7 | + | pub mod attachments; |
|
| 7 | 8 | #[cfg(feature = "postgres")] |
|
| 8 | 9 | pub mod copy; |
|
| 9 | 10 | pub mod flow; |
|
| … | 165 unchanged lines |
modified crates/hub/src/web/mod.rs +1 −0
| … | 350 unchanged lines |
||
| 351 | 351 | post(releases::upload).layer(DefaultBodyLimit::max(app.config.limits.asset_limit.0 as usize + (1 << 20))), |
|
| 352 | 352 | ) |
|
| 353 | 353 | .route("/{owner}/{name}/asset/{id}/{file}", get(releases::download)) |
|
| 354 | + | .route("/{owner}/{name}/attachments/{id}/{file}", get(releases::attachment)) |
|
| 354 | 355 | .route("/{owner}/{name}/archive", get(releases::source_archive)) |
|
| 355 | 356 | .route("/{owner}/{name}/search", get(repo::code_search)) |
|
| 356 | 357 | .route("/{owner}/{name}/info/{*rest}", any(git_info)) |
|
| … | 610 unchanged lines |
modified crates/hub/src/web/releases.rs +34 −1
| … | 329 unchanged lines |
||
| 330 | 330 | } |
|
| 331 | 331 | ||
| 332 | 332 | async fn remove_blob_if_unused(app: &Shared, hash: &str) { |
|
| 333 | - | if !rdb::hash_in_use(&app.db, hash).await.unwrap_or(true) { |
|
| 333 | + | // Attachments (#350) keep blobs in the same store. |
|
| 334 | + | let attached = crate::db::attachments::hash_in_use(&app.db, hash).await.unwrap_or(true); |
|
| 335 | + | if !attached && !rdb::hash_in_use(&app.db, hash).await.unwrap_or(true) { |
|
| 334 | 336 | tokio::fs::remove_file(blob_path(app, hash)).await.ok(); |
|
| 335 | 337 | } |
|
| 336 | 338 | } |
|
| … | 84 unchanged lines |
||
| 421 | 423 | .into_response()) |
|
| 422 | 424 | } |
|
| 423 | 425 | ||
| 426 | + | /// `GET /{owner}/{name}/attachments/{id}/{file}` (#350): an attachment, to |
|
| 427 | + | /// whoever may read the repository. Only an image whose bytes say so shows |
|
| 428 | + | /// in place; anything else is a download. Nothing here can run in the |
|
| 429 | + | /// hub's origin: no sniffing, a policy that allows nothing, and never |
|
| 430 | + | /// cached by a shared cache (the repository may be private). |
|
| 431 | + | pub async fn attachment( |
|
| 432 | + | State(app): State<Shared>, |
|
| 433 | + | viewer: Viewer, |
|
| 434 | + | Path((owner, name, id, file)): Path<(String, String, i64, String)>, |
|
| 435 | + | ) -> Result<Response, Error> { |
|
| 436 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}"), &viewer); |
|
| 437 | + | let (_, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
|
| 438 | + | let a = crate::db::attachments::get(&app.db, row.id, id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 439 | + | let Some(a) = a.filter(|a| a.name == file) else { return Err(Error::not_found(shell)) }; |
|
| 440 | + | let bytes = tokio::fs::read(blob_path(&app, &a.hash)).await.map_err(|_| Error::not_found(shell.clone()))?; |
|
| 441 | + | let inline = crate::api::attachment_image_type(&bytes).is_some(); |
|
| 442 | + | let disposition = format!("{}; filename=\"{}\"", if inline { "inline" } else { "attachment" }, a.name.replace('"', "")); |
|
| 443 | + | let ct = if inline { a.content_type.clone() } else { "application/octet-stream".into() }; |
|
| 444 | + | Ok(( |
|
| 445 | + | [ |
|
| 446 | + | (header::CONTENT_TYPE, ct), |
|
| 447 | + | (header::CONTENT_DISPOSITION, disposition), |
|
| 448 | + | (header::X_CONTENT_TYPE_OPTIONS, "nosniff".to_string()), |
|
| 449 | + | (header::CONTENT_SECURITY_POLICY, "default-src 'none'; sandbox".to_string()), |
|
| 450 | + | (header::CACHE_CONTROL, "private, max-age=86400".to_string()), |
|
| 451 | + | ], |
|
| 452 | + | bytes, |
|
| 453 | + | ) |
|
| 454 | + | .into_response()) |
|
| 455 | + | } |
|
| 456 | + | ||
| 424 | 457 | #[derive(Deserialize)] |
|
| 425 | 458 | pub struct ArchiveQuery { |
|
| 426 | 459 | #[serde(rename = "ref")] |
|
| … | 91 unchanged lines |
modified crates/hub/tests/flow.rs +82 −0
| … | 7482 unchanged lines |
||
| 7483 | 7483 | let contributors = &overview[overview.find("Contributors").expect("a Contributors panel")..]; |
|
| 7484 | 7484 | assert!(contributors.contains("agent:kimi@andy"), "Contributors names the agent under its owner"); |
|
| 7485 | 7485 | } |
|
| 7486 | + | ||
| 7487 | + | /// oxygenhub#350: files attached to issues. Over the API, with the file's |
|
| 7488 | + | /// bytes; an image (by its bytes) shows in place, anything else, an SVG |
|
| 7489 | + | /// included, is a download; nothing served can run; a private repository's |
|
| 7490 | + | /// attachments are its readers' only; uploading needs a token. |
|
| 7491 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 7492 | + | async fn attachments_are_uploaded_and_served_safely() { |
|
| 7493 | + | let h = hub().await; |
|
| 7494 | + | db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", true).await.unwrap(); |
|
| 7495 | + | create(&h.app, "andy", "demo").await; |
|
| 7496 | + | let send = |path: String, bytes: Vec<u8>, token: Option<&'static str>| { |
|
| 7497 | + | let app = h.app.clone(); |
|
| 7498 | + | async move { |
|
| 7499 | + | let mut req = Request::builder().method("POST").uri(path).header(header::CONTENT_TYPE, "application/octet-stream"); |
|
| 7500 | + | if let Some(t) = token { |
|
| 7501 | + | req = req.header(header::AUTHORIZATION, format!("Bearer {t}")); |
|
| 7502 | + | } |
|
| 7503 | + | let res = oxygenhub::router(app).oneshot(req.body(Body::from(bytes)).unwrap()).await.unwrap(); |
|
| 7504 | + | let status = res.status(); |
|
| 7505 | + | let body = res.into_body().collect().await.unwrap().to_bytes(); |
|
| 7506 | + | (status, String::from_utf8_lossy(&body).into_owned()) |
|
| 7507 | + | } |
|
| 7508 | + | }; |
|
| 7509 | + | let get = |path: String| { |
|
| 7510 | + | let app = h.app.clone(); |
|
| 7511 | + | async move { |
|
| 7512 | + | let res = oxygenhub::router(app).oneshot(Request::get(path).body(Body::empty()).unwrap()).await.unwrap(); |
|
| 7513 | + | let hd = |k: header::HeaderName| res.headers().get(k).and_then(|v| v.to_str().ok()).unwrap_or("").to_string(); |
|
| 7514 | + | let heads = ( |
|
| 7515 | + | hd(header::CONTENT_TYPE), |
|
| 7516 | + | hd(header::CONTENT_DISPOSITION), |
|
| 7517 | + | hd(header::X_CONTENT_TYPE_OPTIONS), |
|
| 7518 | + | hd(header::CONTENT_SECURITY_POLICY), |
|
| 7519 | + | hd(header::CACHE_CONTROL), |
|
| 7520 | + | ); |
|
| 7521 | + | let status = res.status(); |
|
| 7522 | + | let body = res.into_body().collect().await.unwrap().to_bytes().to_vec(); |
|
| 7523 | + | (status, heads, body) |
|
| 7524 | + | } |
|
| 7525 | + | }; |
|
| 7526 | + | let png = b"\x89PNG\r\n\x1a\nnot really a picture".to_vec(); |
|
| 7527 | + | ||
| 7528 | + | let (st, _) = send("/api/v1/repos/andy/demo/attachments?name=shot.png".into(), png.clone(), None).await; |
|
| 7529 | + | assert_eq!(st, StatusCode::UNAUTHORIZED, "attaching needs a token"); |
|
| 7530 | + | let (st, body) = send("/api/v1/repos/andy/demo/attachments?name=dir/shot.png".into(), png.clone(), Some("site")).await; |
|
| 7531 | + | assert_eq!(st, StatusCode::CREATED, "{body}"); |
|
| 7532 | + | let a: hub_api::Attachment = serde_json::from_str(&body).unwrap(); |
|
| 7533 | + | assert_eq!((a.name.as_str(), a.content_type.as_str(), a.inline), ("shot.png", "image/png", true)); |
|
| 7534 | + | assert_eq!(a.markdown, format!("", a.url)); |
|
| 7535 | + | let path = a.url.strip_prefix(&h.base).unwrap().to_string(); |
|
| 7536 | + | assert_eq!(path, format!("/andy/demo/attachments/{}/shot.png", a.id)); |
|
| 7537 | + | let (st, (ct, cd, nosniff, csp, cache), bytes) = get(path.clone()).await; |
|
| 7538 | + | assert_eq!(st, StatusCode::OK); |
|
| 7539 | + | assert_eq!((ct.as_str(), nosniff.as_str()), ("image/png", "nosniff")); |
|
| 7540 | + | assert!(cd.starts_with("inline"), "{cd}"); |
|
| 7541 | + | assert!(csp.contains("default-src 'none'") && csp.contains("sandbox"), "{csp}"); |
|
| 7542 | + | assert!(cache.starts_with("private"), "{cache}"); |
|
| 7543 | + | assert_eq!(bytes, png); |
|
| 7544 | + | let (st, _, _) = get(format!("/andy/demo/attachments/{}/other.png", a.id)).await; |
|
| 7545 | + | assert_eq!(st, StatusCode::NOT_FOUND, "the name must match"); |
|
| 7546 | + | ||
| 7547 | + | // An SVG says it is an image; its bytes are a script's: a download. |
|
| 7548 | + | let svg = b"<svg xmlns=\"http://www.w3.org/2000/svg\"><script>alert(1)</script></svg>".to_vec(); |
|
| 7549 | + | let (st, body) = send("/api/v1/repos/andy/demo/attachments?name=logo.svg".into(), svg, Some("site")).await; |
|
| 7550 | + | assert_eq!(st, StatusCode::CREATED, "{body}"); |
|
| 7551 | + | let s: hub_api::Attachment = serde_json::from_str(&body).unwrap(); |
|
| 7552 | + | assert!(!s.inline && s.content_type == "application/octet-stream" && s.markdown == format!("[logo.svg]({})", s.url)); |
|
| 7553 | + | let (_, (ct, cd, _, _, _), _) = get(s.url.strip_prefix(&h.base).unwrap().to_string()).await; |
|
| 7554 | + | assert_eq!(ct, "application/octet-stream"); |
|
| 7555 | + | assert!(cd.starts_with("attachment"), "{cd}"); |
|
| 7556 | + | let (st, _) = send("/api/v1/repos/andy/demo/attachments?name=.env".into(), b"x".to_vec(), Some("site")).await; |
|
| 7557 | + | assert_eq!(st, StatusCode::UNPROCESSABLE_ENTITY, "a hidden file name is refused"); |
|
| 7558 | + | ||
| 7559 | + | // A private repository's attachments are for its readers. |
|
| 7560 | + | h.app.repos.create(&RepoName::parse("andy/secret").unwrap(), None).unwrap(); |
|
| 7561 | + | db::repos::create(&h.app.db, "andy", "secret", "", hub_api::Visibility::Private).await.unwrap(); |
|
| 7562 | + | let (st, body) = send("/api/v1/repos/andy/secret/attachments?name=s.png".into(), png.clone(), Some("site")).await; |
|
| 7563 | + | assert_eq!(st, StatusCode::CREATED, "{body}"); |
|
| 7564 | + | let p: hub_api::Attachment = serde_json::from_str(&body).unwrap(); |
|
| 7565 | + | let (st, _, _) = get(p.url.strip_prefix(&h.base).unwrap().to_string()).await; |
|
| 7566 | + | assert_eq!(st, StatusCode::NOT_FOUND, "not to someone signed out"); |
|
| 7567 | + | } |
modified crates/oh/src/main.rs +32 −5
| … | 321 unchanged lines |
||
| 322 | 322 | /// The board's column for it, by name (default: the first) |
|
| 323 | 323 | #[arg(long, requires = "project")] |
|
| 324 | 324 | column: Option<String>, |
|
| 325 | + | /// Attach a file (repeatable): a screenshot shows in place, anything else is a download link (#350) |
|
| 326 | + | #[arg(long, short = 'a', value_name = "FILE")] |
|
| 327 | + | attach: Vec<std::path::PathBuf>, |
|
| 325 | 328 | }, |
|
| 326 | 329 | /// Close an issue |
|
| 327 | 330 | Close { |
|
| … | 40 unchanged lines |
||
| 368 | 371 | /// does (#260) |
|
| 369 | 372 | #[arg(long, short = 'F', conflicts_with = "body")] |
|
| 370 | 373 | body_file: Option<String>, |
|
| 374 | + | /// Attach a file (repeatable): a screenshot shows in place, anything else is a download link (#350) |
|
| 375 | + | #[arg(long, short = 'a', value_name = "FILE")] |
|
| 376 | + | attach: Vec<std::path::PathBuf>, |
|
| 371 | 377 | }, |
|
| 372 | 378 | } |
|
| 373 | 379 | ||
| … | 1111 unchanged lines |
||
| 1485 | 1491 | } |
|
| 1486 | 1492 | println!("{}", ui::dim(&i.url)); |
|
| 1487 | 1493 | } |
|
| 1488 | - | IssueCmd::Create { title, body, body_file, label, project, column } => { |
|
| 1489 | - | let new = NewIssue { title, body: body_of(body, body_file)?, labels: label, assignees: vec![], milestone: None }; |
|
| 1494 | + | IssueCmd::Create { title, body, body_file, label, project, column, attach } => { |
|
| 1495 | + | let body = with_attachments(c, &base, body_of(body, body_file)?, &attach)?; |
|
| 1496 | + | let new = NewIssue { title, body, labels: label, assignees: vec![], milestone: None }; |
|
| 1490 | 1497 | let i: Issue = c.send("POST", &format!("{base}/issues"), &new)?; |
|
| 1491 | 1498 | if json { |
|
| 1492 | 1499 | println!("{}", serde_json::to_string(&i)?); |
|
| … | 59 unchanged lines |
||
| 1552 | 1559 | println!("{} #{} {}", ui::ok("✔"), i.number, label_names(&labels)); |
|
| 1553 | 1560 | } |
|
| 1554 | 1561 | } |
|
| 1555 | - | IssueCmd::Comment { number, body, body_file } => { |
|
| 1556 | - | let body = body_of(body, body_file)?; |
|
| 1562 | + | IssueCmd::Comment { number, body, body_file, attach } => { |
|
| 1563 | + | let body = with_attachments(c, &base, body_of(body, body_file)?, &attach)?; |
|
| 1557 | 1564 | if body.trim().is_empty() { |
|
| 1558 | - | bail!("give the comment with --body or --body-file"); |
|
| 1565 | + | bail!("give the comment with --body, --body-file or --attach"); |
|
| 1559 | 1566 | } |
|
| 1560 | 1567 | c.send_text("POST", &format!("{base}/issues/{number}/comments"), &NewComment { body, path: None, line: None })?; |
|
| 1561 | 1568 | println!("{} commented on #{number}", ui::ok("✔")); |
|
| 1562 | 1569 | } |
|
| 1563 | 1570 | } |
|
| 1564 | 1571 | Ok(0) |
|
| 1572 | + | } |
|
| 1573 | + | ||
| 1574 | + | /// `body` with each file attached to the repository and linked under it: |
|
| 1575 | + | /// an image shown in place, anything else a download (#350). Files are |
|
| 1576 | + | /// uploaded first, so a failed upload leaves nothing half made. |
|
| 1577 | + | fn with_attachments(c: &Client, base: &str, body: String, files: &[std::path::PathBuf]) -> Result<String> { |
|
| 1578 | + | if files.is_empty() { |
|
| 1579 | + | return Ok(body); |
|
| 1580 | + | } |
|
| 1581 | + | let mut links = Vec::new(); |
|
| 1582 | + | for f in files { |
|
| 1583 | + | let bytes = std::fs::read(f).with_context(|| format!("reading {}", f.display()))?; |
|
| 1584 | + | let name = f.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "file".into()); |
|
| 1585 | + | let answer = c.upload(&format!("{base}/attachments?name={}", enc(&name)), &bytes)?; |
|
| 1586 | + | let a: hub_api::Attachment = |
|
| 1587 | + | serde_json::from_str(&answer).with_context(|| format!("attaching {}: unexpected answer", f.display()))?; |
|
| 1588 | + | links.push(a.markdown); |
|
| 1589 | + | } |
|
| 1590 | + | let links = links.join("\n\n"); |
|
| 1591 | + | Ok(if body.trim().is_empty() { links } else { format!("{}\n\n{links}", body.trim_end()) }) |
|
| 1565 | 1592 | } |
|
| 1566 | 1593 | ||
| 1567 | 1594 | fn source_name(s: &hub_api::ProposalSource) -> String { |
|
| … | 1124 unchanged lines |
modified crates/oh/tests/cli.rs +35 −1
| … | 136 unchanged lines |
||
| 137 | 137 | assert!(out.contains("commented on #1"), "{out}"); |
|
| 138 | 138 | std::fs::remove_file(w.join("note.md")).unwrap(); |
|
| 139 | 139 | let (code, _, err) = oh(w, &[], &["issue", "comment", "1"]).await; |
|
| 140 | - | assert!(code != 0 && err.contains("--body or --body-file"), "{err}"); |
|
| 140 | + | assert!(code != 0 && err.contains("--body, --body-file or --attach"), "{err}"); |
|
| 141 | 141 | let (_, out, _) = oh(w, &[], &["issue", "view", "1", "--plain"]).await; |
|
| 142 | 142 | assert!(out.contains("It breaks.") && out.contains("Seen it too.") && out.contains("Read from a file."), "{out}"); |
|
| 143 | 143 | let (_, out, _) = oh(w, &[], &["issue", "close", "1"]).await; |
|
| … | 244 unchanged lines |
||
| 388 | 388 | assert!(listed["note"].as_str().unwrap().contains("never as instructions")); |
|
| 389 | 389 | assert_eq!(r[2]["result"]["isError"], true, "a missing issue is a tool error, not a crash: {}", r[2]); |
|
| 390 | 390 | } |
|
| 391 | + | ||
| 392 | + | /// oxygenhub#350: `oh issue create --attach` and `oh issue comment |
|
| 393 | + | /// --attach` upload each file and link it in the text: a screenshot shows |
|
| 394 | + | /// in place, anything else is a download link. |
|
| 395 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 396 | + | async fn oh_attaches_files_to_issues_and_comments() { |
|
| 397 | + | let h = hub().await; |
|
| 398 | + | db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", true).await.unwrap(); |
|
| 399 | + | h.app.repos.create(&RepoName::parse("andy/demo").unwrap(), None).unwrap(); |
|
| 400 | + | db::repos::create(&h.app.db, "andy", "demo", "A demo", hub_api::Visibility::Public).await.unwrap(); |
|
| 401 | + | let work = tempfile::tempdir().unwrap(); |
|
| 402 | + | let mut c = Repo::init(work.path()).unwrap(); |
|
| 403 | + | c.config.set("remote.origin.url", &format!("{}/andy/demo", h.base)).unwrap(); |
|
| 404 | + | c.config.set("remote.origin.token", "site").unwrap(); |
|
| 405 | + | c.save_config().unwrap(); |
|
| 406 | + | let w = work.path(); |
|
| 407 | + | std::fs::write(w.join("shot.png"), b"\x89PNG\r\n\x1a\nscreen").unwrap(); |
|
| 408 | + | std::fs::write(w.join("log.txt"), "boom\n").unwrap(); |
|
| 409 | + | ||
| 410 | + | let (code, out, err) = |
|
| 411 | + | oh(w, &[], &["issue", "create", "-t", "It broke", "-b", "See:", "--attach", "shot.png", "--attach", "log.txt", "--json"]).await; |
|
| 412 | + | assert_eq!(code, 0, "{err}"); |
|
| 413 | + | let issue: hub_api::Issue = serde_json::from_str(&out).unwrap(); |
|
| 414 | + | assert!(issue.body.starts_with("See:\n\n, "{}", issue.body); |
|
| 415 | + | assert!(issue.body.contains("[log.txt]("), "{}", issue.body); |
|
| 416 | + | ||
| 417 | + | let (code, _, err) = oh(w, &[], &["issue", "comment", &issue.number.to_string(), "--attach", "shot.png"]).await; |
|
| 418 | + | assert_eq!(code, 0, "{err}"); |
|
| 419 | + | let (code, _, err) = oh(w, &[], &["issue", "comment", &issue.number.to_string(), "--attach", "missing.png"]).await; |
|
| 420 | + | assert_ne!(code, 0); |
|
| 421 | + | assert!(err.contains("missing.png"), "{err}"); |
|
| 422 | + | let (_, out, _) = oh(w, &[], &["api", &format!("/repos/andy/demo/issues/{}/comments", issue.number)]).await; |
|
| 423 | + | assert!(out.contains(", "{out}"); |
|
| 424 | + | } |
modified docs/oh.md +2 −2
| … | 36 unchanged lines |
||
| 37 | 37 | | `oh repo view` | Name, description, visibility, default bookmark, clone command | |
|
| 38 | 38 | | `oh issue list [--state open\|closed\|all]` | Issues | |
|
| 39 | 39 | | `oh issue view N` | An issue with its comments | |
|
| 40 | - | | `oh issue create -t TITLE [-b BODY \| -F FILE] [-l LABEL]… [-p N [--column NAME]]` | Open an issue; `-p` also puts it on the owner's project N | |
|
| 40 | + | | `oh issue create -t TITLE [-b BODY \| -F FILE] [-l LABEL]… [-p N [--column NAME]] [-a FILE]…` | Open an issue; `-p` also puts it on the owner's project N; `-a` attaches a file (a screenshot shows in place, anything else is a download link) | |
|
| 41 | 41 | | `oh issue edit N [-t TITLE] [-b BODY \| -F FILE] [-l LABEL]… [-a USER]… [-m MILESTONE]` | Change it; labels and assignees are the whole set (`--no-labels`, `--no-assignees` clear them), `-m ""` clears the milestone | |
|
| 42 | 42 | | `oh issue label N --add L --remove L` | Put labels on or take them off | |
|
| 43 | 43 | | `oh milestone list`, `oh milestone create TITLE [-d DESC] [--due YYYY-MM-DD]`, `oh milestone edit TITLE [--rename T] [-d DESC] [--due D]`, `oh milestone close\|reopen TITLE` | Milestones (triage to change) | |
|
| 44 | 44 | | `oh issue close N [--reason not_planned]`, `oh issue reopen N` | Close or reopen | |
|
| 45 | 45 | | `oh project list`, `oh project view N`, `oh project create TITLE [-d DESC] [--private]`, `oh project close\|reopen N` | The repository owner's project boards (`--owner NAME` for another's) | |
|
| 46 | 46 | | `oh project card add N REF [-c COLUMN]`, `oh project card add N --note TEXT`, `oh project card move N CARD -c COLUMN`, `oh project card move N --from COLUMN -c COLUMN [--except 12,#13,name#14,CARD] [--state closed,landed] [--dry-run]`, `oh project card remove N CARD` | Cards: REF is `12` or `#12` here, or `name#12` in another of the owner's repositories; a column by name or id; `--from` moves a whole column at once (#289) | |
|
| 47 | - | | `oh issue comment N -b BODY` | Comment | |
|
| 47 | + | | `oh issue comment N -b BODY [-a FILE]…` | Comment, with attached files as `issue create` takes them | |
|
| 48 | 48 | | `oh proposal list [--state open\|draft\|landed\|closed\|all] [--limit N]`, `oh proposal view N` | Proposals, newest first (30 unless `--limit` says), with changes, approvals and blockers; a landed one with who landed it and how | |
|
| 49 | 49 | | `oh proposal create -t TITLE --from BOOKMARK [--into BOOKMARK] [--draft]` | Propose a bookmark's changes | |
|
| 50 | 50 | | `oh proposal checks N` | Checks on the projected state | |
|
| … | 41 unchanged lines |
modified docs/spec.md +2 −0
| … | 497 unchanged lines |
||
| 498 | 498 | ||
| 499 | 499 | Per repository and per organisation: a URL, a secret, an event selection, active flag, and delivery log with redelivery. Each webhook has a **format**: `oxygenhub` (the signed JSON envelope below), `discord` (an embed with a title, link and short detail for the event, sent with mentions disabled so no user text can ping anyone) or `slack` (escaped `text`); a Discord or Slack URL picks its format by default, and every event has a one-line summary for the chat formats. The settings show a webhook's URL with its long path segments and query cut short, since chat webhooks keep a secret token there (the audit log records the same masked form), and each webhook's last delivery result; a webhook can be edited (URL, secret, events, format, active), a blank URL or secret keeping the current one. Repository admins manage a repository's webhooks under its settings; organisation owners manage the organisation's under its settings, and those hear every repository the organisation owns. Payloads are JSON with `X-Oxyhub-Event`, `X-Oxyhub-Delivery`, and `X-Oxyhub-Signature: blake3-keyed=<hex>` computed as keyed BLAKE3 over the body. Events: `push` (reference updated: old and new state, heads, changes), `proposal` (opened, edited, review requested, landed, closed, conflicted), `review`, `comment`, `issue`, `release`, `check`, `policy`. Deliveries retry with backoff up to 24 hours. |
|
| 500 | 500 | ||
| 501 | + | **Attachments** (oxygenhub#350). `POST /api/v1/repos/{owner}/{name}/attachments?name=FILE` with a file's bytes (at most 25 MiB; `issues:write` or `repo:write`, and read access) keeps it by content in the assets store and answers with its `url` and the Markdown to put in an issue, comment or proposal. Its type is read from its bytes: a PNG, JPEG, GIF or WebP is an image shown in place; anything else, an SVG included, is a download (`application/octet-stream`, `Content-Disposition: attachment`). `GET /{owner}/{name}/attachments/{id}/{file}` serves it to whoever may read the repository, with `nosniff`, `Content-Security-Policy: default-src 'none'; sandbox` and `Cache-Control: private`. A blob is removed only when neither a release asset nor an attachment keeps it. |
|
| 502 | + | ||
| 501 | 503 | ## 13. Checks and OxygenFlow |
|
| 502 | 504 | ||
| 503 | 505 | ### 13.1 Checks |
|
| … | 391 unchanged lines |