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.

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