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.

168 linesCodeBlameRaw
1//! Files put in an issue's, a pull request's or a comment's Markdown: a
2//! screenshot of a preview, a recording's still. The work service keeps
3//! them (`upload_attachment`), with whoever may comment on the repository
4//! allowed to add one, and the usercontent origin serves each at
5//! `<usercontent>/attachments/<id>` (apps/web/workers/usercontent.ts),
6//! never the site. An id is 256 random bits, so its address is its
7//! permission, as a Docs file's is: a private repository's attachment is
8//! seen by whoever is given its address, as its Markdown gives it.
9
10use serde::{Deserialize, Serialize};
11
12use crate::User;
13use crate::repos::RepoPath;
14
15/// The largest attachment, in bytes: 10 MB.
16pub const MAX_ATTACHMENT_BYTES: usize = 10 * 1024 * 1024;
17
18/// What an attachment may be, as its bytes show it: PNG, JPEG, GIF or WebP.
19pub const ATTACHMENT_TYPES: &[&str] = &["image/png", "image/jpeg", "image/gif", "image/webp"];
20
21/// The longest name kept, in characters.
22pub const MAX_ATTACHMENT_NAME_CHARS: usize = 200;
23
24/// Where attachments are kept in the usercontent namespace (`AVATARS`):
25/// `attachments/<id>`, with [`AttachmentMeta`].
26pub const ATTACHMENT_KEY_PREFIX: &str = "attachments/";
27
28/// `upload_attachment`: keep a file to show in Markdown on `repo`, for
29/// someone who may comment there. `data` is the file, base64-encoded; its
30/// type is read from its bytes, and a `content_type` that says otherwise
31/// is refused. Returns `Outcome<Attachment>`: too large is
32/// `FailureCode::TooLarge`, another type `FailureCode::UnsupportedType`.
33#[derive(Debug, Serialize, Deserialize)]
34pub struct UploadAttachmentArgs {
35 pub actor: User,
36 pub repo: RepoPath,
37 /// The file's name, such as `preview.png`: the image's alt text.
38 pub name: String,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub content_type: Option<String>,
41 pub data: String,
42}
43
44/// An attachment kept: `path` is under the usercontent origin.
45#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
46pub struct Attachment {
47 pub id: String,
48 pub name: String,
49 pub content_type: String,
50 pub size: u64,
51 /// `/attachments/<id>`.
52 pub path: String,
53}
54
55/// What is kept with an attachment's bytes, as KV metadata (at most 1 KB).
56#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(rename_all = "camelCase")]
58pub struct AttachmentMeta {
59 /// As the usercontent origin serves it.
60 pub content_type: String,
61 pub name: String,
62 pub repo_id: String,
63 pub user_id: String,
64 pub size: u64,
65 /// Milliseconds since the epoch.
66 pub at: u64,
67}
68
69/// `/attachments/<id>`: where an attachment is under the usercontent origin.
70pub fn attachment_path(id: &str) -> String {
71 format!("/attachments/{id}")
72}
73
74/// Whether `id` is an attachment's: 64 lowercase hex digits.
75pub fn is_attachment_id(id: &str) -> bool {
76 id.len() == 64 && id.bytes().all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f'))
77}
78
79/// The type a file's first bytes show it to be, if it is one an attachment may be.
80pub fn sniff_attachment(bytes: &[u8]) -> Option<&'static str> {
81 if bytes.starts_with(&[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]) {
82 Some("image/png")
83 } else if bytes.starts_with(&[0xff, 0xd8, 0xff]) {
84 Some("image/jpeg")
85 } else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
86 Some("image/gif")
87 } else if bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WEBP" {
88 Some("image/webp")
89 } else {
90 None
91 }
92}
93
94/// A content type as given (`image/PNG; charset=…`), as one of
95/// [`ATTACHMENT_TYPES`] compares it; `image/jpg` is `image/jpeg`.
96pub fn normalize_content_type(given: &str) -> String {
97 let kind = given.split(';').next().unwrap_or_default().trim().to_ascii_lowercase();
98 if kind == "image/jpg" { "image/jpeg".to_owned() } else { kind }
99}
100
101/// A file's name as kept: its last path segment, without control
102/// characters, at most [`MAX_ATTACHMENT_NAME_CHARS`]; `image` when nothing is left.
103pub fn attachment_name(given: &str) -> String {
104 let last = given.rsplit(['/', '\\']).next().unwrap_or_default();
105 let name: String = last.chars().filter(|c| !c.is_control()).take(MAX_ATTACHMENT_NAME_CHARS).collect();
106 let name = name.trim();
107 if name.is_empty() { "image".to_owned() } else { name.to_owned() }
108}
109
110/// The Markdown that shows the attachment at `url`: `![name](url)`, with
111/// the brackets and backslashes of its name escaped.
112pub fn attachment_markdown(name: &str, url: &str) -> String {
113 let mut alt = String::with_capacity(name.len());
114 for c in name.chars() {
115 if matches!(c, '[' | ']' | '\\') {
116 alt.push('\\');
117 }
118 alt.push(c);
119 }
120 format!("![{alt}]({url})")
121}
122
123#[cfg(test)]
124mod tests {
125 use super::*;
126
127 #[test]
128 fn types_are_read_from_bytes() {
129 assert_eq!(sniff_attachment(&[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a, 0]), Some("image/png"));
130 assert_eq!(sniff_attachment(&[0xff, 0xd8, 0xff, 0xe0]), Some("image/jpeg"));
131 assert_eq!(sniff_attachment(b"GIF89a...."), Some("image/gif"));
132 assert_eq!(sniff_attachment(b"RIFF\0\0\0\0WEBPVP8 "), Some("image/webp"));
133 assert_eq!(sniff_attachment(b"<svg xmlns=\"http://www.w3.org/2000/svg\"/>"), None);
134 assert_eq!(sniff_attachment(b"%PDF-1.7"), None);
135 assert_eq!(sniff_attachment(&[]), None);
136 for kind in ATTACHMENT_TYPES {
137 assert!(kind.starts_with("image/"));
138 }
139 }
140
141 #[test]
142 fn names_and_markdown() {
143 assert_eq!(attachment_name("shots/preview.png"), "preview.png");
144 assert_eq!(attachment_name("C:\\tmp\\a b.png"), "a b.png");
145 assert_eq!(attachment_name(" \n"), "image");
146 assert_eq!(attachment_name(&"x".repeat(500)).chars().count(), MAX_ATTACHMENT_NAME_CHARS);
147 assert_eq!(attachment_markdown("preview.png", "https://g1tusercontent.com/attachments/ab"), "![preview.png](https://g1tusercontent.com/attachments/ab)");
148 assert_eq!(attachment_markdown("a]b[c\\", "u"), "![a\\]b\\[c\\\\](u)");
149 assert_eq!(normalize_content_type("Image/JPG; q=1"), "image/jpeg");
150 assert_eq!(attachment_path("ab"), "/attachments/ab");
151 assert!(is_attachment_id(&"0a".repeat(32)));
152 assert!(!is_attachment_id(&"0A".repeat(32)));
153 assert!(!is_attachment_id("abc"));
154 }
155
156 #[test]
157 fn meta_fits_in_kv_metadata() {
158 let meta = AttachmentMeta {
159 content_type: "image/webp".into(),
160 name: "é".repeat(MAX_ATTACHMENT_NAME_CHARS),
161 repo_id: "repo_01m4z09g22e7ysqpg2ha423cws".into(),
162 user_id: "usr_01m4z09g22e7ysqpg2ha423cws".into(),
163 size: MAX_ATTACHMENT_BYTES as u64,
164 at: 1_792_627_200_000,
165 };
166 assert!(serde_json::to_string(&meta).unwrap().len() <= 1024);
167 }
168}