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.

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