OXYGENHUB
◆ v0.9.23 oxygenhub / crates / hub / src / api / attachments.rs
5.1 KiB · Rust · last changed 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) 4 days ago source blame history raw
1c52d940 Andy McCall ↶ 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
}