Skip to main content

shared/models/user/
avatar.rs

1use 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    /// Stores `data` as this user's avatar under a freshly generated path, removing the
148    /// previously stored one, and returns the path it was stored at.
149    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    /// Downloads, transcodes and stores `source_url` as the avatar of `user_uuid`. The path is
162    /// prefixed by a digest of `source_url`, so re-importing an unchanged url is a no-op, and
163    /// suffixed by a digest of the stored bytes, so a path never changes content (avatars are
164    /// served as immutable).
165    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}