OXYGENHUB

Coverage / crates/hub/src/api/attachments.rs 94.5%

86 of 91 lines run, 13 of 24 functions · run #540 on main · run missed

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).
2514×
pub fn image_type(bytes: &[u8]) -> Option<&'static str> {
2614×
    if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
276×
        Some("image/png")
288×
    } else if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
291×
        Some("image/jpeg")
307×
    } else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
311×
        Some("image/gif")
326×
    } else if bytes.len() >= 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP" {
331×
        Some("image/webp")
34
    } else {
355×
        None
36
    }
3714×
}
38
39
/// A file name to keep: its last part, plain, not hidden.
4012×
pub fn file_name(name: &str) -> Option<String> {
4112×
    let base = name.rsplit(['/', '\\']).next().unwrap_or(name).trim();
4276×
    let clean: String = base.chars().filter(|c| !c.is_control() && !matches!(c, '"' | '<' | '>' | '`')).collect();
4312×
    (!clean.is_empty() && !clean.starts_with('.') && clean.len() <= 200).then_some(clean)
4412×
}
45
46
/// Percent-encode a path segment.
476×
fn seg(s: &str) -> String {
486×
    s.bytes()
4944×
        .map(|b| if b.is_ascii_alphanumeric() || b"-._~".contains(&b) { (b as char).to_string() } else { format!("%{b:02X}") })
506×
        .collect()
516×
}
52
538×
pub async fn upload(
548×
    State(app): State<Shared>,
558×
    headers: HeaderMap,
568×
    Path((owner, name)): Path<(String, String)>,
578×
    Query(q): Query<Named>,
588×
    body: Bytes,
598×
) -> Result<(StatusCode, Json<hub_api::Attachment>), ApiError> {
608×
    let c = caller(&app, &headers).await?;
618×
    let me = c.require("issues:write").or_else(|_| c.require("repo:write"))?.clone();
627×
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
637×
    let Some(file) = file_name(&q.name) else {
641×
        return Err(ApiError(
651×
            StatusCode::UNPROCESSABLE_ENTITY,
661×
            "give the file a name (?name=FILE): plain, not hidden, at most 200 bytes".into(),
671×
        ));
68
    };
696×
    if body.is_empty() {
700×
        return Err(ApiError(StatusCode::UNPROCESSABLE_ENTITY, "the file is empty".into()));
716×
    }
726×
    if body.len() > ATTACHMENT_MAX {
730×
        return Err(ApiError(StatusCode::PAYLOAD_TOO_LARGE, format!("an attachment is at most {} MiB", ATTACHMENT_MAX >> 20)));
746×
    }
756×
    let image = image_type(&body);
766×
    let content_type = image.unwrap_or("application/octet-stream");
776×
    let hash = hex::encode(blake3::hash(&body).as_bytes());
786×
    let path = crate::web::releases::blob_path(&app, &hash);
796×
    if !path.exists() {
804×
        let parent = path.parent().expect("a blob is in a directory");
814×
        tokio::fs::create_dir_all(parent).await.map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
824×
        let tmp = path.with_extension("partial");
834×
        tokio::fs::write(&tmp, &body).await.map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
844×
        tokio::fs::rename(&tmp, &path).await.map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
852×
    }
866×
    let a = db::attachments::add(&app.db, row.id, &file, body.len() as i64, &hash, content_type, Some(me.id)).await?;
876×
    let url = format!("{}/{}/{}/attachments/{}/{}", app.config.base_url().trim_end_matches('/'), row.owner, row.name, a.id, seg(&file));
886×
    let markdown = if image.is_some() { format!("![{file}]({url})") } else { format!("[{file}]({url})") };
896×
    Ok((
906×
        StatusCode::CREATED,
916×
        Json(hub_api::Attachment {
926×
            id: a.id,
936×
            name: file,
946×
            size: a.size,
956×
            content_type: content_type.into(),
966×
            inline: image.is_some(),
976×
            url,
986×
            markdown,
996×
        }),
1006×
    ))
1018×
}
102
103
#[cfg(test)]
104
mod tests {
105
    use super::*;
106
107
    #[test]
1081×
    fn only_real_images_show_in_place() {
1091×
        assert_eq!(image_type(b"\x89PNG\r\n\x1a\n...."), Some("image/png"));
1101×
        assert_eq!(image_type(&[0xFF, 0xD8, 0xFF, 0xE0]), Some("image/jpeg"));
1111×
        assert_eq!(image_type(b"GIF89a.."), Some("image/gif"));
1121×
        assert_eq!(image_type(b"RIFF\0\0\0\0WEBPVP8 "), Some("image/webp"));
1131×
        assert_eq!(image_type(b"<svg xmlns=\"http://www.w3.org/2000/svg\"><script/></svg>"), None, "an SVG is a download");
1141×
        assert_eq!(image_type(b"<html><script>alert(1)</script>"), None);
1151×
    }
116
117
    #[test]
1181×
    fn file_names_are_kept_plain() {
1191×
        assert_eq!(file_name("C:\\Users\\a\\shot.png").as_deref(), Some("shot.png"));
1201×
        assert_eq!(file_name("../../etc/passwd").as_deref(), Some("passwd"));
1211×
        assert_eq!(file_name("a\"b<c>.txt").as_deref(), Some("abc.txt"));
1221×
        assert_eq!(file_name(".env"), None);
1231×
        assert_eq!(file_name("  "), None);
1241×
    }
125
}