Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

309 linesCodeBlameRaw
1//! Files put in a work item's, a pull request's or a comment's Markdown
2//! (`upload_attachment`): whoever may comment on a repository, or on a work
3//! item, may add one, as an agent shows a screenshot of its preview in a
4//! pull request instead of committing the image. The bytes go in the
5//! usercontent namespace (`AVATARS`) under `attachments/<id>`, which the
6//! usercontent origin serves at `/attachments/<id>`
7//! (apps/web/workers/usercontent.ts); a row here names the repository, or
8//! the work item and its workspace, so a purged repository's or a deleted
9//! workspace's go with it ([`purge`], [`purge_workspace`]). See
10//! `g1t_contracts::attachments`.
11
12use base64::Engine;
13use g1t_contracts::attachments::{
14 ATTACHMENT_KEY_PREFIX, ATTACHMENT_TYPES, Attachment, AttachmentMeta, MAX_ATTACHMENT_BYTES, UploadAttachmentArgs,
15 attachment_name, attachment_path, normalize_content_type, sniff_attachment,
16};
17use g1t_contracts::access::Capability;
18use g1t_contracts::time::rfc3339;
19use g1t_contracts::{FailureCode, Outcome};
20use g1t_kit::now_ms;
21use worker::kv::KvStore;
22use worker::{Env, Result};
23
24use crate::retired::writable;
25use crate::{UNVERIFIED, Work, items};
26
27/// Unwraps an `Outcome`, returning its failure from the enclosing method.
28macro_rules! check {
29 ($outcome:expr) => {
30 match $outcome {
31 Outcome::Ok(value) => value,
32 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
33 }
34 };
35}
36
37/// `repo.purged`: the repository's rows, `?1` its id (its bytes go first, [`purge`]).
38pub(crate) const PURGED: &str = "DELETE FROM attachments WHERE repo_id = ?1";
39
40/// `workspace.deleted`: its work items' rows, `?1` its slug (their bytes
41/// go first, [`purge_workspace`]).
42pub(crate) const DELETED: &[&str] = &["DELETE FROM attachments WHERE workspace = ?1"];
43
44/// `workspace.renamed`: its work items' rows follow (`?1` the slug now,
45/// `?2` before).
46pub(crate) const RENAMED: &[&str] = &["UPDATE attachments SET workspace = ?1 WHERE workspace = ?2"];
47
48const MB: usize = 1024 * 1024;
49
50/// What the file is, read from its bytes, or why it is refused.
51pub(crate) fn checked(data: &str, content_type: Option<&str>) -> Outcome<(Vec<u8>, &'static str)> {
52 // Base64 is four characters for three bytes: refuse a file far too
53 // large before decoding it.
54 if data.len() / 4 * 3 > MAX_ATTACHMENT_BYTES + 3 {
55 return too_large();
56 }
57 let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(data.trim()) else {
58 return Outcome::fail(FailureCode::Invalid, "data must be the file, base64-encoded.");
59 };
60 if bytes.is_empty() {
61 return Outcome::fail(FailureCode::Invalid, "The file is empty.");
62 }
63 if bytes.len() > MAX_ATTACHMENT_BYTES {
64 return too_large();
65 }
66 let Some(kind) = sniff_attachment(&bytes) else {
67 return unsupported();
68 };
69 if let Some(given) = content_type.map(normalize_content_type).filter(|given| !given.is_empty()) {
70 if given != kind {
71 return Outcome::fail(
72 FailureCode::UnsupportedType,
73 format!("The file is {kind}, not {given}: send its own content_type, or none."),
74 );
75 }
76 }
77 Outcome::Ok((bytes, kind))
78}
79
80fn too_large<T>() -> Outcome<T> {
81 Outcome::fail(FailureCode::TooLarge, format!("An attachment can be at most {} MB.", MAX_ATTACHMENT_BYTES / MB))
82}
83
84fn unsupported<T>() -> Outcome<T> {
85 let names: Vec<&str> = ATTACHMENT_TYPES.iter().map(|kind| kind.trim_start_matches("image/")).collect();
86 Outcome::fail(
87 FailureCode::UnsupportedType,
88 format!("An attachment must be an image: {} (by its bytes, not its name).", names.join(", ").to_uppercase()),
89 )
90}
91
92/// 256 random bits, in hex: an attachment's id, and its address's secret.
93fn new_attachment_id() -> String {
94 let mut bytes = [0u8; 32];
95 getrandom::getrandom(&mut bytes).expect("no source of randomness");
96 hex::encode(bytes)
97}
98
99fn store(env: &Env) -> Result<KvStore> {
100 env.kv("AVATARS")
101}
102
103impl Work {
104 /// `upload_attachment`: as `add_comment` checks who may comment (a
105 /// confirmed account that can read the repository, which is not
106 /// archived, or that may take part in the work item), then keeps the
107 /// file.
108 pub(crate) async fn upload_attachment(&self, env: &Env, a: UploadAttachmentArgs) -> Result<Outcome<Attachment>> {
109 if !a.actor.verified {
110 return Ok(Outcome::fail(FailureCode::Forbidden, UNVERIFIED));
111 }
112 let (bytes, content_type) = check!(checked(&a.data, a.content_type.as_deref()));
113 // What it is kept by: the repository, or the work item and its workspace.
114 let (repo_id, item) = match &a.repo {
115 Some(path) => {
116 let repo = check!(self.repo(path, &Some(a.actor.clone())).await?);
117 check!(writable(&repo));
118 (Some(repo.id), None)
119 }
120 None => {
121 let Some(number) = a.number.filter(|number| *number > 0) else {
122 return Ok(Outcome::fail(FailureCode::Invalid, "Say which repository, or which work item (workspace and number)."));
123 };
124 let held = check!(items::for_viewer(self, &Some(a.actor.clone()), &a.workspace, number, Capability::Participate).await?);
125 (None, Some((held.item.id, held.item.workspace)))
126 }
127 };
128 let name = attachment_name(&a.name);
129 let id = new_attachment_id();
130 let now = now_ms();
131 let size = bytes.len() as u64;
132 store(env)?
133 .put_bytes(&format!("{ATTACHMENT_KEY_PREFIX}{id}"), &bytes)?
134 .metadata(AttachmentMeta {
135 content_type: content_type.to_owned(),
136 name: name.clone(),
137 repo_id: repo_id.clone(),
138 item_id: item.as_ref().map(|(id, _)| id.clone()),
139 user_id: a.actor.id.clone(),
140 size,
141 at: now,
142 })?
143 .execute()
144 .await?;
145 self.db
146 .prepare(
147 "INSERT INTO attachments (id, repo_id, item_id, workspace, user_id, name, content_type, size, created_at)
148 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
149 )
150 .bind(&[
151 id.as_str().into(),
152 crate::optional(&repo_id),
153 crate::optional(&item.as_ref().map(|(id, _)| id.clone())),
154 crate::optional(&item.as_ref().map(|(_, workspace)| workspace.clone())),
155 a.actor.id.as_str().into(),
156 name.as_str().into(),
157 content_type.into(),
158 (size as f64).into(),
159 rfc3339(now).into(),
160 ])?
161 .run()
162 .await?;
163 Ok(Outcome::Ok(Attachment {
164 path: attachment_path(&id),
165 id,
166 name,
167 content_type: content_type.to_owned(),
168 size,
169 }))
170 }
171}
172
173/// `repo.purged`: deletes the repository's attachments' bytes; its rows go
174/// with [`PURGED`], after.
175pub(crate) async fn purge(env: &Env, db: &worker::D1Database, repo_id: &str) -> Result<()> {
176 purge_where(env, db, "repo_id = ?1", repo_id).await
177}
178
179/// `workspace.deleted`: deletes the bytes of its work items' attachments;
180/// their rows go with [`DELETED`], after.
181pub(crate) async fn purge_workspace(env: &Env, db: &worker::D1Database, workspace: &str) -> Result<()> {
182 purge_where(env, db, "workspace = ?1", &workspace.to_lowercase()).await
183}
184
185async fn purge_where(env: &Env, db: &worker::D1Database, condition: &str, value: &str) -> Result<()> {
186 #[derive(serde::Deserialize)]
187 struct Row {
188 id: String,
189 }
190 let rows: Vec<Row> = db
191 .prepare(format!("SELECT id FROM attachments WHERE {condition}"))
192 .bind(&[value.into()])?
193 .all()
194 .await?
195 .results()?;
196 if rows.is_empty() {
197 return Ok(());
198 }
199 let kv = store(env)?;
200 for row in rows {
201 kv.delete(&format!("{ATTACHMENT_KEY_PREFIX}{}", row.id)).await?;
202 }
203 Ok(())
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209
210 fn b64(bytes: &[u8]) -> String {
211 base64::engine::general_purpose::STANDARD.encode(bytes)
212 }
213
214 const PNG: &[u8] = &[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13];
215
216 fn refusal<T>(outcome: Outcome<T>) -> (FailureCode, String) {
217 match outcome {
218 Outcome::Fail(failure) => (failure.code, failure.message),
219 Outcome::Ok(_) => panic!("was not refused"),
220 }
221 }
222
223 #[test]
224 fn an_image_is_taken_by_its_bytes() {
225 match checked(&b64(PNG), None) {
226 Outcome::Ok((bytes, kind)) => {
227 assert_eq!(bytes, PNG);
228 assert_eq!(kind, "image/png");
229 }
230 Outcome::Fail(failure) => panic!("{}", failure.message),
231 }
232 assert!(matches!(checked(&b64(PNG), Some("image/PNG")), Outcome::Ok(_)));
233 assert!(matches!(checked(&b64(&[0xff, 0xd8, 0xff, 0xe0]), Some("image/jpg")), Outcome::Ok((_, "image/jpeg"))));
234 }
235
236 #[test]
237 fn other_types_are_unsupported() {
238 let (code, message) = refusal(checked(&b64(b"<svg onload=alert(1)>"), Some("image/svg+xml")));
239 assert_eq!(code, FailureCode::UnsupportedType);
240 assert!(message.contains("PNG, JPEG, GIF, WEBP"), "{message}");
241 // A PNG said to be a JPEG.
242 let (code, message) = refusal(checked(&b64(PNG), Some("image/jpeg")));
243 assert_eq!(code, FailureCode::UnsupportedType);
244 assert!(message.contains("image/png, not image/jpeg"), "{message}");
245 assert_eq!(code.http_status(), 415);
246 }
247
248 #[test]
249 fn too_large_is_refused_before_and_after_decoding() {
250 let mut big = PNG.to_vec();
251 big.resize(MAX_ATTACHMENT_BYTES + 1, 0);
252 let (code, message) = refusal(checked(&b64(&big), None));
253 assert_eq!(code, FailureCode::TooLarge);
254 assert_eq!(code.http_status(), 413);
255 assert!(message.contains("10 MB"));
256 let mut most = PNG.to_vec();
257 most.resize(MAX_ATTACHMENT_BYTES, 0);
258 assert!(matches!(checked(&b64(&most), None), Outcome::Ok(_)));
259 assert_eq!(refusal(checked(&"A".repeat(MAX_ATTACHMENT_BYTES * 2), None)).0, FailureCode::TooLarge);
260 }
261
262 #[test]
263 fn not_base64_or_empty_is_invalid() {
264 assert_eq!(refusal(checked("not base64!", None)).0, FailureCode::Invalid);
265 assert_eq!(refusal(checked("", None)).0, FailureCode::Invalid);
266 }
267
268 #[test]
269 fn ids_are_attachment_ids() {
270 let id = new_attachment_id();
271 assert!(g1t_contracts::attachments::is_attachment_id(&id), "{id}");
272 assert_ne!(id, new_attachment_id());
273 }
274
275 #[test]
276 fn purging_takes_the_repository_id() {
277 assert_eq!(g1t_kit::transfer::parameters(PURGED), 1);
278 assert_eq!(g1t_kit::transfer::parameters(DELETED[0]), 1);
279 assert_eq!(g1t_kit::rename::parameters(RENAMED[0]), 2);
280 }
281
282 #[test]
283 fn a_work_item_keeps_attachments_of_its_own() {
284 let db = crate::migrated::database();
285 db.execute_batch(
286 "INSERT INTO attachments (id, repo_id, user_id, name, content_type, size, created_at)
287 VALUES ('a1', 'rep_1', 'usr_1', 'one.png', 'image/png', 1, 'now');
288 INSERT INTO attachments (id, item_id, workspace, user_id, name, content_type, size, created_at)
289 VALUES ('a2', 'wki_1', 'acme', 'usr_1', 'two.png', 'image/png', 1, 'now');",
290 )
291 .unwrap();
292 // Each names a repository or a work item.
293 let neither = "INSERT INTO attachments (id, user_id, name, content_type, size, created_at) VALUES ('a3', 'usr_1', 'x', 'image/png', 1, 'now')";
294 assert!(db.execute(neither, []).is_err());
295 db.execute(RENAMED[0], ["globex", "acme"]).unwrap();
296 db.execute(PURGED, ["rep_1"]).unwrap();
297 let left: Vec<(String, String)> = db
298 .prepare("SELECT id, workspace FROM attachments")
299 .unwrap()
300 .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
301 .unwrap()
302 .map(Result::unwrap)
303 .collect();
304 assert_eq!(left, vec![("a2".to_owned(), "globex".to_owned())]);
305 db.execute(DELETED[0], ["globex"]).unwrap();
306 let none: u32 = db.query_row("SELECT count(*) FROM attachments", [], |row| row.get(0)).unwrap();
307 assert_eq!(none, 0);
308 }
309}