anvilsign in

collin/anvil

1//! Attachment endpoints: upload (write-gated) and serve (read-gated).
2//!
3//! Images pasted/dropped into the file editor are POSTed here as a raw body,
4//! stored content-addressed outside git (see [`anvil_core::attachments`]), and
5//! served back so the Markdown only carries a URL. The serve route is gated by
6//! the repo's read access, so private repos stay private.
7
8use anvil_core::{
9 App,
10 access,
11 attachments,
12 storage,
13};
14use axum::{
15 Router,
16 body::Bytes,
17 extract::{
18 DefaultBodyLimit,
19 Path,
20 State,
21 },
22 http::{
23 HeaderMap,
24 StatusCode,
25 header,
26 },
27 response::{
28 IntoResponse,
29 Response,
30 },
31 routing::{
32 get,
33 post,
34 },
35};
36
37use crate::{
38 auth::{
39 CSRF_FIELD,
40 Csrf,
41 CurrentUser,
42 verify_csrf,
43 },
44 ui::{
45 forbidden,
46 not_found,
47 resolve_repo,
48 server_error,
49 },
50};
51
52/// Register the attachment routes. `max_upload_bytes` bounds a single upload
53/// (from `http.attachment_max_mb`); the body-limit layer rejects anything
54/// larger before it is buffered.
55pub fn routes(router: Router<App>, max_upload_bytes: usize) -> Router<App> {
56 router
57 .route(
58 "/{owner}/{repo}/-/attachments/{hash}",
59 get(serve_attachment),
60 )
61 .route(
62 "/{owner}/{repo}/-/attachments",
63 post(upload_attachment).layer(DefaultBodyLimit::max(max_upload_bytes)),
64 )
65}
66
67/// A 64-char lowercase hex SHA-256 — the only shape a stored attachment name
68/// can take. Guards the path component before it touches the filesystem.
69fn is_valid_hash(hash: &str) -> bool {
70 hash.len() == 64
71 && hash
72 .bytes()
73 .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
74}
75
76/// Detect a supported raster image type from leading magic bytes, returning its
77/// MIME type. SVG is deliberately excluded — it can carry script, and we serve
78/// attachments from our own origin. The client's `Content-Type` is ignored.
79fn sniff_image(bytes: &[u8]) -> Option<&'static str> {
80 if bytes.starts_with(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]) {
81 Some("image/png")
82 } else if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
83 Some("image/jpeg")
84 } else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
85 Some("image/gif")
86 } else if bytes.len() >= 12 && &bytes[0..4] == b"RIFF" && &bytes[8..12] == b"WEBP" {
87 Some("image/webp")
88 } else {
89 None
90 }
91}
92
93/// `GET /{owner}/{repo}/-/attachments/{hash}` — serve an attachment's bytes,
94/// gated by the repo's read access. Content is immutable (addressed by hash),
95/// so it is cached aggressively and served with active content neutralized.
96async fn serve_attachment(
97 State(app): State<App>,
98 CurrentUser(user): CurrentUser,
99 Path((owner, repo, hash)): Path<(String, String, String)>,
100) -> Response {
101 let (_, meta) = match resolve_repo(&app, user.as_ref(), &owner, &repo).await {
102 Ok(v) => v,
103 Err(resp) => return resp,
104 };
105 if !is_valid_hash(&hash) {
106 return not_found("no such attachment");
107 }
108 let row = match attachments::find(&app.db, meta.id, &hash).await {
109 Ok(Some(r)) => r,
110 Ok(None) => return not_found("no such attachment"),
111 Err(e) => return server_error(e),
112 };
113 let path = storage::attachment_path(&app.config.attachments_dir(), meta.id, &hash);
114 let bytes = match std::fs::read(&path) {
115 Ok(b) => b,
116 Err(_) => return not_found("attachment data missing on disk"),
117 };
118 (
119 [
120 (header::CONTENT_TYPE, row.content_type),
121 (header::X_CONTENT_TYPE_OPTIONS, "nosniff".to_string()),
122 // Content-addressed ⇒ immutable: cache for a year.
123 (
124 header::CACHE_CONTROL,
125 "public, max-age=31536000, immutable".to_string(),
126 ),
127 (header::CONTENT_DISPOSITION, "inline".to_string()),
128 // Neutralize any active content even if a type slips through.
129 (
130 header::CONTENT_SECURITY_POLICY,
131 "default-src 'none'; sandbox".to_string(),
132 ),
133 ],
134 bytes,
135 )
136 .into_response()
137}
138
139/// `POST /{owner}/{repo}/-/attachments` — store a raw image body and return its
140/// serve URL. Requires write access and a valid CSRF token (sent as the
141/// `X-CSRF-Token` header, since the body is the raw file, not a form).
142async fn upload_attachment(
143 State(app): State<App>,
144 CurrentUser(user): CurrentUser,
145 csrf: Csrf,
146 Path((owner, repo)): Path<(String, String)>,
147 headers: HeaderMap,
148 body: Bytes,
149) -> Response {
150 let (_, meta) = match resolve_repo(&app, user.as_ref(), &owner, &repo).await {
151 Ok(v) => v,
152 Err(resp) => return resp,
153 };
154 let Some(user) = user else {
155 return (StatusCode::UNAUTHORIZED, "sign in to upload").into_response();
156 };
157 if !access::can_write(&meta, Some(&user)) {
158 return forbidden();
159 }
160 let submitted = headers
161 .get("x-csrf-token")
162 .and_then(|v| v.to_str().ok())
163 .or_else(|| headers.get(CSRF_FIELD).and_then(|v| v.to_str().ok()))
164 .unwrap_or_default();
165 if let Err(resp) = verify_csrf(&csrf, submitted) {
166 return resp;
167 }
168
169 let cap = app.config.http.attachment_max_mb.saturating_mul(1 << 20);
170 if cap != 0 && body.len() > cap {
171 return (StatusCode::PAYLOAD_TOO_LARGE, "attachment too large").into_response();
172 }
173 let Some(content_type) = sniff_image(&body) else {
174 return (
175 StatusCode::BAD_REQUEST,
176 "unsupported file type (png, jpeg, gif, webp only)",
177 )
178 .into_response();
179 };
180
181 let hash = attachments::content_hash(&body);
182 let dir = app.config.attachments_dir().join(meta.id.to_string());
183 if let Err(e) = std::fs::create_dir_all(&dir) {
184 return server_error(e);
185 }
186 // Content-addressed: the file is immutable, so only write if it's new.
187 let path = dir.join(&hash);
188 let is_new = !path.exists();
189 // Per-repo quota: only new content adds bytes, so deduped re-uploads are
190 // always allowed even at the cap.
191 let quota = app.config.http.attachment_quota_mb.saturating_mul(1 << 20);
192 if quota != 0 && is_new && storage::dir_size(&dir) + body.len() as u64 > quota as u64 {
193 return (
194 StatusCode::PAYLOAD_TOO_LARGE,
195 "repository attachment quota exceeded",
196 )
197 .into_response();
198 }
199 if is_new && let Err(e) = std::fs::write(&path, &body) {
200 return server_error(e);
201 }
202 if let Err(e) = attachments::add(
203 &app.db,
204 meta.id,
205 &hash,
206 content_type,
207 body.len() as i64,
208 user.id,
209 )
210 .await
211 {
212 return server_error(e);
213 }
214
215 let url = format!("/{owner}/{repo}/-/attachments/{hash}");
216 let markdown = format!("![image]({url})");
217 axum::Json(serde_json::json!({ "url": url, "markdown": markdown })).into_response()
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223
224 #[test]
225 fn hash_shape_is_strict() {
226 assert!(is_valid_hash(&"a".repeat(64)));
227 assert!(!is_valid_hash(&"a".repeat(63)));
228 assert!(!is_valid_hash(&"A".repeat(64))); // uppercase rejected
229 assert!(!is_valid_hash("../etc/passwd"));
230 assert!(!is_valid_hash(&"g".repeat(64))); // non-hex
231 }
232
233 #[test]
234 fn sniffs_supported_images_only() {
235 assert_eq!(
236 sniff_image(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0]),
237 Some("image/png")
238 );
239 assert_eq!(sniff_image(&[0xFF, 0xD8, 0xFF, 0xE0]), Some("image/jpeg"));
240 assert_eq!(sniff_image(b"GIF89a..."), Some("image/gif"));
241 assert_eq!(sniff_image(b"RIFF\0\0\0\0WEBPVP8 "), Some("image/webp"));
242 assert_eq!(sniff_image(b"<svg></svg>"), None);
243 assert_eq!(sniff_image(b"<!doctype html>"), None);
244 assert_eq!(sniff_image(b""), None);
245 }
246}