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