//! Attachments over the API (oxygenhub#350): `POST //! /api/v1/repos/{owner}/{name}/attachments?name=FILE` with the file's //! bytes as the body. Anyone signed in who may read the repository and //! write issues may attach; what comes back says how to show it. use super::{caller, reach, ApiError}; use crate::auth::roles::Access; use crate::db; use crate::Shared; use axum::body::Bytes; use axum::extract::{Path, Query, State}; use axum::http::{HeaderMap, StatusCode}; use axum::Json; /// The largest file one attachment may be. pub const ATTACHMENT_MAX: usize = 25 << 20; #[derive(serde::Deserialize)] pub struct Named { name: String, } /// An image type a page may show, from the bytes; never what the uploader /// said (an SVG or HTML file would run in the hub's origin). pub fn image_type(bytes: &[u8]) -> Option<&'static str> { if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { Some("image/png") } else if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) { Some("image/jpeg") } else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") { Some("image/gif") } else if bytes.len() >= 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP" { Some("image/webp") } else { None } } /// A file name to keep: its last part, plain, not hidden. pub fn file_name(name: &str) -> Option { let base = name.rsplit(['/', '\\']).next().unwrap_or(name).trim(); let clean: String = base.chars().filter(|c| !c.is_control() && !matches!(c, '"' | '<' | '>' | '`')).collect(); (!clean.is_empty() && !clean.starts_with('.') && clean.len() <= 200).then_some(clean) } /// Percent-encode a path segment. fn seg(s: &str) -> String { s.bytes() .map(|b| if b.is_ascii_alphanumeric() || b"-._~".contains(&b) { (b as char).to_string() } else { format!("%{b:02X}") }) .collect() } pub async fn upload( State(app): State, headers: HeaderMap, Path((owner, name)): Path<(String, String)>, Query(q): Query, body: Bytes, ) -> Result<(StatusCode, Json), ApiError> { let c = caller(&app, &headers).await?; let me = c.require("issues:write").or_else(|_| c.require("repo:write"))?.clone(); let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; let Some(file) = file_name(&q.name) else { return Err(ApiError( StatusCode::UNPROCESSABLE_ENTITY, "give the file a name (?name=FILE): plain, not hidden, at most 200 bytes".into(), )); }; if body.is_empty() { return Err(ApiError(StatusCode::UNPROCESSABLE_ENTITY, "the file is empty".into())); } if body.len() > ATTACHMENT_MAX { return Err(ApiError(StatusCode::PAYLOAD_TOO_LARGE, format!("an attachment is at most {} MiB", ATTACHMENT_MAX >> 20))); } let image = image_type(&body); let content_type = image.unwrap_or("application/octet-stream"); let hash = hex::encode(blake3::hash(&body).as_bytes()); let path = crate::web::releases::blob_path(&app, &hash); if !path.exists() { let parent = path.parent().expect("a blob is in a directory"); tokio::fs::create_dir_all(parent).await.map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let tmp = path.with_extension("partial"); tokio::fs::write(&tmp, &body).await.map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; tokio::fs::rename(&tmp, &path).await.map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; } let a = db::attachments::add(&app.db, row.id, &file, body.len() as i64, &hash, content_type, Some(me.id)).await?; let url = format!("{}/{}/{}/attachments/{}/{}", app.config.base_url().trim_end_matches('/'), row.owner, row.name, a.id, seg(&file)); let markdown = if image.is_some() { format!("![{file}]({url})") } else { format!("[{file}]({url})") }; Ok(( StatusCode::CREATED, Json(hub_api::Attachment { id: a.id, name: file, size: a.size, content_type: content_type.into(), inline: image.is_some(), url, markdown, }), )) } #[cfg(test)] mod tests { use super::*; #[test] fn only_real_images_show_in_place() { assert_eq!(image_type(b"\x89PNG\r\n\x1a\n...."), Some("image/png")); assert_eq!(image_type(&[0xFF, 0xD8, 0xFF, 0xE0]), Some("image/jpeg")); assert_eq!(image_type(b"GIF89a.."), Some("image/gif")); assert_eq!(image_type(b"RIFF\0\0\0\0WEBPVP8 "), Some("image/webp")); assert_eq!(image_type(b""), None, "an SVG is a download"); assert_eq!(image_type(b""), None); } #[test] fn file_names_are_kept_plain() { assert_eq!(file_name("C:\\Users\\a\\shot.png").as_deref(), Some("shot.png")); assert_eq!(file_name("../../etc/passwd").as_deref(), Some("passwd")); assert_eq!(file_name("a\"b.txt").as_deref(), Some("abc.txt")); assert_eq!(file_name(".env"), None); assert_eq!(file_name(" "), None); } }