OXYGENHUB

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!("![{file}]({url})") } 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: `![name](url)` 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!("![shot.png]({})", 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![shot.png]("), "{}", 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("![shot.png]("), "{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