g1t/services/identity/src/avatars.rs
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 control | 1 | //! 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 | ||
| 12 | use base64::Engine; | |
| 13 | use base64::engine::general_purpose::STANDARD; | |
| 14 | use g1t_contracts::identity::*; | |
| 15 | use g1t_contracts::{FailureCode, Outcome, PrincipalKind, Role}; | |
| 16 | use sha2::{Digest, Sha256}; | |
| 17 | use worker::wasm_bindgen::JsValue; | |
| 18 | use worker::Result; | |
| 19 | ||
| 20 | use crate::Identity; | |
| 21 | ||
| 22 | /// The media type an image's bytes show it to be, or why it is refused. | |
| 23 | pub 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. | |
| 44 | pub 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. | |
| 51 | pub 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. | |
| 69 | pub 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")] | |
| 76 | struct Stored { | |
| 77 | content_type: &'static str, | |
| 78 | } | |
| 79 | ||
| 80 | impl 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)] | |
| 205 | mod 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 | } |