flagon-io/g1t

public

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/services/identity/src/avatars.rs

256 lines9,134 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Workspace names and icons, and a component kit for every control1//! Uploaded avatars: a workspace's icon and a person's picture.
2//!
3//! An image is checked by its bytes, not by what it claims to be: only
4//! PNG, JPEG, WebP and GIF, which a browser shows as an image and nothing
5//! else. SVG is refused, since it can carry script. Each is stored in the
6//! `AVATARS` KV namespace under the SHA-256 of its bytes, with its media
7//! type as metadata. The hash is also what the workspace or user row holds
8//! and what the address `/avatars/<hash>` names: an address always means
9//! the same image, so it can be cached for good. The site serves the
10//! namespace; only this service writes to it.
11
12use base64::Engine;
13use base64::engine::general_purpose::STANDARD;
14use g1t_contracts::identity::*;
15use g1t_contracts::{FailureCode, Outcome, PrincipalKind, Role};
16use sha2::{Digest, Sha256};
17use worker::wasm_bindgen::JsValue;
18use worker::Result;
19
20use crate::Identity;
21
22/// The media type an image's bytes show it to be, or why it is refused.
23pub fn sniff(bytes: &[u8]) -> std::result::Result<&'static str, &'static str> {
24 if bytes.is_empty() {
25 return Err("That file is empty.");
26 }
27 if bytes.len() > MAX_AVATAR_BYTES {
28 return Err("Use an image of at most 1 MB.");
29 }
30 if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
31 Ok("image/png")
32 } else if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
33 Ok("image/jpeg")
34 } else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
35 Ok("image/gif")
36 } else if bytes.len() >= 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP" {
37 Ok("image/webp")
38 } else {
39 Err("Use a PNG, JPEG, WebP or GIF image.")
40 }
41}
42
43/// An upload decoded and checked: its key and media type.
44pub struct Checked {
45 pub key: String,
46 pub content_type: &'static str,
47 pub bytes: Vec<u8>,
48}
49
50/// Decodes an uploaded image and checks it.
51pub fn check(image: &str) -> std::result::Result<Checked, &'static str> {
52 // Base64 is a third longer; anything far past the limit is refused
53 // before it is decoded.
54 if image.len() > MAX_AVATAR_BYTES / 3 * 4 + 8 {
55 return Err("Use an image of at most 1 MB.");
56 }
57 let bytes = STANDARD
58 .decode(image.trim())
59 .map_err(|_| "That upload could not be read.")?;
60 let content_type = sniff(&bytes)?;
61 Ok(Checked {
62 key: hex::encode(Sha256::digest(&bytes)),
63 content_type,
64 bytes,
65 })
66}
67
68/// Whether a stored avatar key is well formed: 64 lowercase hex digits.
69pub fn is_key(key: &str) -> bool {
70 key.len() == 64 && key.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
71}
72
73/// What is kept beside an avatar's bytes, as the site reads it.
74#[derive(serde::Serialize)]
75#[serde(rename_all = "camelCase")]
76struct Stored {
77 content_type: &'static str,
78}
79
80impl Identity {
81 /// Stores a checked image. The same bytes always land on the same
82 /// key, so storing them again changes nothing.
83 async fn store_avatar(&self, checked: Checked) -> Result<String> {
84 self.env
85 .kv("AVATARS")?
86 .put_bytes(&checked.key, &checked.bytes)?
87 .metadata(Stored {
88 content_type: checked.content_type,
89 })?
90 .execute()
91 .await?;
92 Ok(checked.key)
93 }
94
95 /// Deletes an avatar no workspace or person uses any more.
96 async fn forget_avatar(&self, key: Option<String>) -> Result<()> {
97 let Some(key) = key.filter(|key| is_key(key)) else {
98 return Ok(());
99 };
100 let used = self
101 .db
102 .prepare(
103 "SELECT 1 FROM workspaces WHERE avatar = ?1
104 UNION ALL SELECT 1 FROM users WHERE avatar = ?1 LIMIT 1",
105 )
106 .bind(&[key.as_str().into()])?
107 .first::<serde_json::Value>(None)
108 .await?
109 .is_some();
110 if !used {
111 self.env.kv("AVATARS")?.delete(&key).await?;
112 }
113 Ok(())
114 }
115
116 /// The avatar a row has now, before it is changed.
117 async fn current_avatar(&self, sql: &str, param: &str) -> Result<Option<String>> {
118 #[derive(serde::Deserialize)]
119 struct Row {
120 avatar: Option<String>,
121 }
122 Ok(self
123 .db
124 .prepare(sql)
125 .bind(&[param.into()])?
126 .first::<Row>(None)
127 .await?
128 .and_then(|row| row.avatar))
129 }
130
131 /// Checks and stores `image`, or does nothing for none; the new key.
132 async fn upload(&self, image: Option<&str>) -> Result<std::result::Result<Option<String>, &'static str>> {
133 let Some(image) = image else {
134 return Ok(Ok(None));
135 };
136 match check(image) {
137 Ok(checked) => Ok(Ok(Some(self.store_avatar(checked).await?))),
138 Err(message) => Ok(Err(message)),
139 }
140 }
141
142 pub async fn set_workspace_avatar(&self, a: SetWorkspaceAvatarArgs) -> Result<Outcome<Workspace>> {
143 let slug = a.slug.to_lowercase();
144 // Checked here, not only by the page: only an owner, as a person.
145 if a.actor.kind != PrincipalKind::User || a.actor.role_in(&slug) != Some(Role::Owner) {
146 return Ok(Outcome::fail(
147 FailureCode::Forbidden,
148 "Only an owner can change a workspace's icon.",
149 ));
150 }
151 let previous = self
152 .current_avatar("SELECT avatar FROM workspaces WHERE slug = ?", &slug)
153 .await?;
154 let key = match self.upload(a.image.as_deref()).await? {
155 Ok(key) => key,
156 Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
157 };
158 self.db
159 .prepare("UPDATE workspaces SET avatar = ? WHERE slug = ?")
160 .bind(&[
161 key.as_deref().map_or(JsValue::NULL, JsValue::from),
162 slug.as_str().into(),
163 ])?
164 .run()
165 .await?;
166 if previous != key {
167 self.forget_avatar(previous).await?;
168 }
169 Ok(match self.get_workspace(SlugArgs { slug }).await? {
170 Some(workspace) => Outcome::Ok(workspace),
171 None => Outcome::fail(FailureCode::NotFound, "Workspace not found."),
172 })
173 }
174
175 pub async fn set_user_avatar(&self, a: SetUserAvatarArgs) -> Result<Outcome<Option<String>>> {
176 if a.user.kind != PrincipalKind::User || a.user.id.is_empty() {
177 return Ok(Outcome::fail(
178 FailureCode::Forbidden,
179 "Only a person can change their own picture.",
180 ));
181 }
182 let previous = self
183 .current_avatar("SELECT avatar FROM users WHERE id = ?", &a.user.id)
184 .await?;
185 let key = match self.upload(a.image.as_deref()).await? {
186 Ok(key) => key,
187 Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
188 };
189 self.db
190 .prepare("UPDATE users SET avatar = ? WHERE id = ?")
191 .bind(&[
192 key.as_deref().map_or(JsValue::NULL, JsValue::from),
193 a.user.id.as_str().into(),
194 ])?
195 .run()
196 .await?;
197 if previous != key {
198 self.forget_avatar(previous).await?;
199 }
200 Ok(Outcome::Ok(key))
201 }
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207
208 const PNG: &[u8] = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR";
209 const JPEG: &[u8] = &[0xFF, 0xD8, 0xFF, 0xE0, 0, 0x10, b'J', b'F', b'I', b'F'];
210 const WEBP: &[u8] = b"RIFF\x24\0\0\0WEBPVP8 ";
211 const GIF: &[u8] = b"GIF89a\x01\0\x01\0";
212
213 #[test]
214 fn knows_each_image_by_its_bytes() {
215 assert_eq!(sniff(PNG), Ok("image/png"));
216 assert_eq!(sniff(JPEG), Ok("image/jpeg"));
217 assert_eq!(sniff(WEBP), Ok("image/webp"));
218 assert_eq!(sniff(GIF), Ok("image/gif"));
219 assert_eq!(sniff(b"GIF87a\x01\0"), Ok("image/gif"));
220 }
221
222 #[test]
223 fn refuses_svg_and_anything_else() {
224 assert!(sniff(b"<svg xmlns=\"http://www.w3.org/2000/svg\"><script>alert(1)</script></svg>").is_err());
225 assert!(sniff(b"<?xml version=\"1.0\"?><svg/>").is_err());
226 assert!(sniff(b"<html><body>hi</body></html>").is_err());
227 assert!(sniff(b"RIFF\0\0\0\0WAVEfmt ").is_err());
228 assert!(sniff(b"\x89PNX").is_err());
229 assert!(sniff(b"").is_err());
230 }
231
232 #[test]
233 fn refuses_more_than_a_megabyte() {
234 let mut big = PNG.to_vec();
235 big.resize(MAX_AVATAR_BYTES, 0);
236 assert_eq!(sniff(&big), Ok("image/png"));
237 big.push(0);
238 assert!(sniff(&big).is_err());
239 assert!(check(&STANDARD.encode(&big)).is_err());
240 }
241
242 #[test]
243 fn keys_an_image_by_its_hash() {
244 let checked = check(&STANDARD.encode(PNG)).ok().unwrap();
245 assert_eq!(checked.content_type, "image/png");
246 assert_eq!(checked.key, hex::encode(Sha256::digest(PNG)));
247 assert!(is_key(&checked.key));
248 assert!(!is_key("../etc/passwd"));
249 assert!(!is_key(&checked.key.to_uppercase()));
250 }
251
252 #[test]
253 fn refuses_what_is_not_base64() {
254 assert!(check("not base64!").is_err());
255 }
256}