shared/models/user/
avatar.rs1use crate::{models::ByUuid, response::DisplayError};
2use futures_util::TryStreamExt;
3use image::{ImageReader, codecs::webp::WebPEncoder, imageops::FilterType};
4use rand::distr::SampleString;
5use sha2::Digest;
6
7const STORED_SIZE: u32 = 512;
8const MAX_IMPORT_BYTES: usize = 8 * 1024 * 1024;
9const MIN_UPLOAD_DIMENSION: u32 = 64;
10const MAX_UPLOAD_DIMENSION: u32 = 4096;
11
12pub fn upload_limits() -> image::Limits {
13 let mut limits = image::Limits::default();
14 limits.max_alloc = Some(64 * 1024 * 1024);
15 limits.max_image_width = Some(4096);
16 limits.max_image_height = Some(4096);
17
18 limits
19}
20
21fn import_limits() -> image::Limits {
22 let mut limits = image::Limits::default();
23 limits.max_alloc = Some(16 * 1024 * 1024);
24 limits.max_image_width = Some(2048);
25 limits.max_image_height = Some(2048);
26
27 limits
28}
29
30pub async fn decode(
31 data: impl AsRef<[u8]> + Send + 'static,
32 limits: image::Limits,
33) -> Result<image::DynamicImage, anyhow::Error> {
34 if data.as_ref().is_empty() {
35 return Err(DisplayError::new("image: payload cannot be empty").into());
36 }
37
38 let mut reader = ImageReader::new(std::io::Cursor::new(data))
39 .with_guessed_format()
40 .map_err(|_| DisplayError::new("image: unable to decode"))?;
41
42 let Some(format) = reader.format() else {
43 return Err(DisplayError::new("image: unrecognized image format").into());
44 };
45
46 if !matches!(
47 format,
48 image::ImageFormat::Png
49 | image::ImageFormat::Jpeg
50 | image::ImageFormat::WebP
51 | image::ImageFormat::Gif
52 ) {
53 return Err(
54 DisplayError::new("image: only PNG, JPEG, WebP, and GIF formats are allowed").into(),
55 );
56 }
57
58 reader.limits(limits);
59
60 Ok(tokio::task::spawn_blocking(move || reader.decode())
61 .await?
62 .map_err(|_| DisplayError::new("image: unable to decode"))?)
63}
64
65pub async fn transcode(
66 data: impl AsRef<[u8]> + Send + 'static,
67 limits: image::Limits,
68 enforce_minimum: bool,
69) -> Result<Vec<u8>, anyhow::Error> {
70 let image = decode(data, limits).await?;
71
72 if enforce_minimum
73 && (!(MIN_UPLOAD_DIMENSION..=MAX_UPLOAD_DIMENSION).contains(&image.width())
74 || !(MIN_UPLOAD_DIMENSION..=MAX_UPLOAD_DIMENSION).contains(&image.height()))
75 {
76 return Err(DisplayError::new(
77 "image: invalid resolution, dimensions must not be smaller than 64px and must not exceed 4096px",
78 )
79 .into());
80 }
81
82 Ok(
83 tokio::task::spawn_blocking(move || -> Result<Vec<u8>, image::ImageError> {
84 let image = image.resize_exact(STORED_SIZE, STORED_SIZE, FilterType::Triangle);
85 let mut data: Vec<u8> = Vec::new();
86 let encoder = WebPEncoder::new_lossless(&mut data);
87 let color = image.color();
88 encoder.encode(image.as_bytes(), STORED_SIZE, STORED_SIZE, color.into())?;
89
90 Ok(data)
91 })
92 .await??,
93 )
94}
95
96fn random_path(user_uuid: uuid::Uuid) -> String {
97 format!(
98 "avatars/{}/{}.webp",
99 user_uuid,
100 rand::distr::Alphanumeric.sample_string(&mut rand::rng(), 8)
101 )
102}
103
104fn imported_path_prefix(user_uuid: uuid::Uuid, source_url: &str) -> String {
105 let digest = sha2::Sha256::digest(source_url.as_bytes());
106
107 format!("avatars/{}/{}-", user_uuid, hex::encode(&digest[..16]))
108}
109
110async fn store(
111 state: &crate::State,
112 user_uuid: uuid::Uuid,
113 previous: Option<&str>,
114 path: &str,
115 data: Vec<u8>,
116) -> Result<(), anyhow::Error> {
117 state
118 .storage
119 .store_with_cache_control(
120 path,
121 data.as_slice(),
122 "image/webp",
123 Some("public, max-age=31536000, immutable"),
124 )
125 .await?;
126
127 if previous != Some(path) {
128 state.storage.remove(previous).await?;
129 }
130
131 sqlx::query!(
132 "UPDATE users
133 SET avatar = $2
134 WHERE users.uuid = $1",
135 user_uuid,
136 path
137 )
138 .execute(state.database.write())
139 .await?;
140
141 super::User::invalidate_cached(&state.database, user_uuid).await;
142
143 Ok(())
144}
145
146impl super::User {
147 pub async fn store_avatar(
150 &mut self,
151 state: &crate::State,
152 data: Vec<u8>,
153 ) -> Result<String, anyhow::Error> {
154 let path = random_path(self.uuid);
155 store(state, self.uuid, self.avatar.as_deref(), &path, data).await?;
156 self.avatar = Some(path.clone());
157
158 Ok(path)
159 }
160
161 pub async fn import_avatar_by_uuid(
166 state: &crate::State,
167 user_uuid: uuid::Uuid,
168 previous: Option<&str>,
169 source_url: &str,
170 ) -> Result<(), anyhow::Error> {
171 let path_prefix = imported_path_prefix(user_uuid, source_url);
172 if previous.is_some_and(|previous| previous.starts_with(&path_prefix)) {
173 return Ok(());
174 }
175
176 let response = crate::net::outbound_request(
177 &state.env,
178 reqwest::Method::GET,
179 reqwest::Url::parse(source_url)?,
180 )?
181 .send()
182 .await?;
183
184 if !response.status().is_success() {
185 return Err(DisplayError::new(format!(
186 "avatar source responded with status {}",
187 response.status()
188 ))
189 .into());
190 }
191
192 let mut data = Vec::new();
193 let mut stream = response.bytes_stream();
194
195 while let Some(chunk) = stream.try_next().await? {
196 if data.len() + chunk.len() > MAX_IMPORT_BYTES {
197 return Err(DisplayError::new("avatar source is too large").into());
198 }
199
200 data.extend_from_slice(&chunk);
201 }
202
203 let data = transcode(data, import_limits(), false).await?;
204 let path = format!(
205 "{path_prefix}{}.webp",
206 hex::encode(&sha2::Sha256::digest(&data)[..8])
207 );
208
209 store(state, user_uuid, previous, &path, data).await
210 }
211}