From e727de9c63e6513c628d4fe374285625d1b55fe1 Mon Sep 17 00:00:00 2001
From: trisua <me@trisua.com>
Date: Sun, 4 May 2025 16:19:34 -0400
Subject: [PATCH] add: allow supporters to upload gif avatars/banners

---
 crates/app/src/{avif.rs => image.rs}          |  22 +--
 crates/app/src/main.rs                        |   2 +-
 .../app/src/public/html/profile/settings.html |   8 +-
 crates/app/src/public/js/atto.js              |   3 +-
 crates/app/src/routes/api/v1/auth/images.rs   | 187 +++++++++++++++---
 .../src/routes/api/v1/communities/images.rs   |   6 +-
 crates/core/src/database/auth.rs              |  12 +-
 crates/core/src/model/auth.rs                 |  10 +
 crates/core/src/model/mod.rs                  |   4 +
 crates/core/src/model/oauth.rs                |  29 ++-
 10 files changed, 231 insertions(+), 52 deletions(-)
 rename crates/app/src/{avif.rs => image.rs} (79%)

diff --git a/crates/app/src/avif.rs b/crates/app/src/image.rs
similarity index 79%
rename from crates/app/src/avif.rs
rename to crates/app/src/image.rs
index 524c1d9..c415aa0 100644
--- a/crates/app/src/avif.rs
+++ b/crates/app/src/image.rs
@@ -12,7 +12,7 @@ use std::{fs::File, io::BufWriter};
 /// * `image/jpeg`
 /// * `image/avif`
 /// * `image/webp`
-pub struct Image(pub Bytes);
+pub struct Image(pub Bytes, pub String);
 
 impl<S> FromRequest<S> for Image
 where
@@ -26,11 +26,10 @@ where
             return Err(StatusCode::BAD_REQUEST);
         };
 
-        let body = if content_type
-            .to_str()
-            .unwrap()
-            .starts_with("multipart/form-data")
-        {
+        let content_type = content_type.to_str().unwrap();
+        let content_type_string = content_type.to_string();
+
+        let body = if content_type.starts_with("multipart/form-data") {
             let mut multipart = Multipart::from_request(req, state)
                 .await
                 .map_err(|_| StatusCode::BAD_REQUEST)?;
@@ -53,12 +52,12 @@ where
             return Err(StatusCode::BAD_REQUEST);
         };
 
-        Ok(Self(body))
+        Ok(Self(body, content_type_string))
     }
 }
 
-/// Create an AVIF buffer given an input of `bytes`
-pub fn save_avif_buffer(path: &str, bytes: Vec<u8>) -> std::io::Result<()> {
+/// Create an image buffer given an input of `bytes`
+pub fn save_buffer(path: &str, bytes: Vec<u8>, format: image::ImageFormat) -> std::io::Result<()> {
     let pre_img_buffer = match image::load_from_memory(&bytes) {
         Ok(i) => i,
         Err(_) => {
@@ -72,10 +71,7 @@ pub fn save_avif_buffer(path: &str, bytes: Vec<u8>) -> std::io::Result<()> {
     let file = File::create(path)?;
     let mut writer = BufWriter::new(file);
 
-    if pre_img_buffer
-        .write_to(&mut writer, image::ImageFormat::Avif)
-        .is_err()
-    {
+    if pre_img_buffer.write_to(&mut writer, format).is_err() {
         return Err(std::io::Error::new(
             std::io::ErrorKind::Other,
             "Image conversion failed",
diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs
index 1cc0101..1285a1a 100644
--- a/crates/app/src/main.rs
+++ b/crates/app/src/main.rs
@@ -1,5 +1,5 @@
 mod assets;
-mod avif;
+mod image;
 mod macros;
 mod routes;
 mod sanitize;
diff --git a/crates/app/src/public/html/profile/settings.html b/crates/app/src/public/html/profile/settings.html
index f0c5eec..01d0006 100644
--- a/crates/app/src/public/html/profile/settings.html
+++ b/crates/app/src/public/html/profile/settings.html
@@ -311,7 +311,7 @@
                             id="avatar_file"
                             name="file"
                             type="file"
-                            accept="image/png,image/jpeg,image/avif,image/webp"
+                            accept="image/png,image/jpeg,image/avif,image/webp,image/gif"
                             class="w-content"
                         />
 
@@ -319,9 +319,7 @@
                     </div>
 
                     <span class="fade"
-                        >Images must be less than 8 MB large. Animated images
-                        such as GIFs or APNGs will not work because of all
-                        images being formatted as AVIF.</span
+                        >Images must be less than 8 MB large. Animated GIFs are only supported for supporter users. GIFs can be at most 2 MB large.</span
                     >
                 </form>
             </div>
@@ -1071,7 +1069,7 @@
                     "color",
                     {
                         description:
-                            "Text on elements with the surface backgrounds.",
+                            "Text on elements with the surface background.",
                     },
                 ],
                 [
diff --git a/crates/app/src/public/js/atto.js b/crates/app/src/public/js/atto.js
index 68225ef..5f5ad0d 100644
--- a/crates/app/src/public/js/atto.js
+++ b/crates/app/src/public/js/atto.js
@@ -845,7 +845,7 @@ media_theme_pref();
                         onchange="window.update_field_with_color('${option.key}', event.target.value)"
                         value="${option.value}"
                         id="${option.key}/color"
-                        style="width: 4rem"
+                        style="width: 4rem; height: 32px"
                     />
 
                     <input
@@ -856,6 +856,7 @@ media_theme_pref();
                         id="${option.key}"
                         value="${option.value}"
                         class="w-full"
+                        style="height: 32px"
                     />
                 </div>
 
diff --git a/crates/app/src/routes/api/v1/auth/images.rs b/crates/app/src/routes/api/v1/auth/images.rs
index bea651d..7f1fb43 100644
--- a/crates/app/src/routes/api/v1/auth/images.rs
+++ b/crates/app/src/routes/api/v1/auth/images.rs
@@ -11,11 +11,11 @@ use std::{
     fs::{File, exists},
     io::Read,
 };
-use tetratto_core::model::{ApiReturn, Error};
+use tetratto_core::model::{permissions::FinePermission, ApiReturn, Error};
 
 use crate::{
     State,
-    avif::{Image, save_avif_buffer},
+    image::{Image, save_buffer},
     get_user_from_token,
 };
 
@@ -55,14 +55,14 @@ pub async fn avatar_request(
         data.get_user_by_id(match selector.parse::<usize>() {
             Ok(d) => d,
             Err(_) => {
-                return (
+                return Err((
                     [("Content-Type", "image/svg+xml")],
                     Body::from(read_image(PathBufD::current().extend(&[
                         data.0.dirs.media.as_str(),
                         "images",
                         "default-avatar.svg",
                     ]))),
-                );
+                ));
             }
         })
         .await
@@ -71,38 +71,45 @@ pub async fn avatar_request(
     } {
         Ok(ua) => ua,
         Err(_) => {
-            return (
+            return Err((
                 [("Content-Type", "image/svg+xml")],
                 Body::from(read_image(PathBufD::current().extend(&[
                     data.0.dirs.media.as_str(),
                     "images",
                     "default-avatar.svg",
                 ]))),
-            );
+            ));
         }
     };
 
     let path = PathBufD::current().extend(&[
         data.0.dirs.media.as_str(),
         "avatars",
-        &format!("{}.avif", &(user.id as i64)),
+        &format!(
+            "{}.{}",
+            &(user.id as i64),
+            user.settings.avatar_mime.replace("image/", "")
+        ),
     ]);
 
     if !exists(&path).unwrap() {
-        return (
+        return Err((
             [("Content-Type", "image/svg+xml")],
             Body::from(read_image(PathBufD::current().extend(&[
                 data.0.dirs.media.as_str(),
                 "images",
                 "default-avatar.svg",
             ]))),
-        );
+        ));
     }
 
-    (
-        [("Content-Type", "image/avif")],
+    Ok((
+        [(
+            "Content-Type".to_string(),
+            user.settings.avatar_mime.clone(),
+        )],
         Body::from(read_image(path)),
-    )
+    ))
 }
 
 /// Get a profile's banner image
@@ -116,41 +123,49 @@ pub async fn banner_request(
     let user = match data.get_user_by_username(&username).await {
         Ok(ua) => ua,
         Err(_) => {
-            return (
+            return Err((
                 [("Content-Type", "image/svg+xml")],
                 Body::from(read_image(PathBufD::current().extend(&[
                     data.0.dirs.media.as_str(),
                     "images",
                     "default-banner.svg",
                 ]))),
-            );
+            ));
         }
     };
 
     let path = PathBufD::current().extend(&[
         data.0.dirs.media.as_str(),
         "banners",
-        &format!("{}.avif", &(user.id as i64)),
+        &format!(
+            "{}.{}",
+            &(user.id as i64),
+            user.settings.banner_mime.replace("image/", "")
+        ),
     ]);
 
     if !exists(&path).unwrap() {
-        return (
+        return Err((
             [("Content-Type", "image/svg+xml")],
             Body::from(read_image(PathBufD::current().extend(&[
                 data.0.dirs.media.as_str(),
                 "images",
                 "default-banner.svg",
             ]))),
-        );
+        ));
     }
 
-    (
-        [("Content-Type", "image/avif")],
+    Ok((
+        [(
+            "Content-Type".to_string(),
+            user.settings.banner_mime.clone(),
+        )],
         Body::from(read_image(path)),
-    )
+    ))
 }
 
 pub static MAXIUMUM_FILE_SIZE: usize = 8388608;
+pub static MAXIUMUM_GIF_FILE_SIZE: usize = 2097152;
 
 /// Upload avatar
 pub async fn upload_avatar_request(
@@ -160,12 +175,65 @@ pub async fn upload_avatar_request(
 ) -> impl IntoResponse {
     // get user from token
     let data = &(data.read().await).0;
-    let auth_user = match get_user_from_token!(jar, data) {
+    let mut auth_user = match get_user_from_token!(jar, data) {
         Some(ua) => ua,
         None => return Json(Error::NotAllowed.into()),
     };
 
-    let path = pathd!("{}/avatars/{}.avif", data.0.dirs.media, &auth_user.id);
+    if img.1 == "image/gif" && !auth_user.permissions.check(FinePermission::SUPPORTER) {
+        return Json(Error::RequiresSupporter.into());
+    }
+
+    let mime = if img.1 == "image/gif" {
+        "image/gif"
+    } else {
+        "image/avif"
+    };
+
+    if auth_user.settings.avatar_mime != mime {
+        // mime changed; delete old image
+        let path = pathd!(
+            "{}/avatars/{}.{}",
+            data.0.dirs.media,
+            &auth_user.id,
+            auth_user.settings.avatar_mime.replace("image/", "")
+        );
+
+        if std::fs::exists(&path).unwrap() {
+            std::fs::remove_file(path).unwrap();
+        }
+    }
+
+    let path = pathd!(
+        "{}/avatars/{}.{}",
+        data.0.dirs.media,
+        &auth_user.id,
+        mime.replace("image/", "")
+    );
+
+    // update user settings
+    auth_user.settings.avatar_mime = mime.to_string();
+    if let Err(e) = data
+        .update_user_settings(auth_user.id, auth_user.settings)
+        .await
+    {
+        return Json(e.into());
+    }
+
+    // upload image (gif)
+    if mime == "image/gif" {
+        // gif image, don't encode
+        if img.0.len() > MAXIUMUM_GIF_FILE_SIZE {
+            return Json(Error::DataTooLong("gif".to_string()).into());
+        }
+
+        std::fs::write(&path, img.0).unwrap();
+        return Json(ApiReturn {
+            ok: true,
+            message: "Avatar uploaded. It might take a bit to update".to_string(),
+            payload: (),
+        });
+    }
 
     // check file size
     if img.0.len() > MAXIUMUM_FILE_SIZE {
@@ -179,7 +247,15 @@ pub async fn upload_avatar_request(
         bytes.push(byte);
     }
 
-    match save_avif_buffer(&path, bytes) {
+    match save_buffer(
+        &path,
+        bytes,
+        if mime == "image/gif" {
+            image::ImageFormat::Gif
+        } else {
+            image::ImageFormat::Avif
+        },
+    ) {
         Ok(_) => Json(ApiReturn {
             ok: true,
             message: "Avatar uploaded. It might take a bit to update".to_string(),
@@ -197,12 +273,63 @@ pub async fn upload_banner_request(
 ) -> impl IntoResponse {
     // get user from token
     let data = &(data.read().await).0;
-    let auth_user = match get_user_from_token!(jar, data) {
+    let mut auth_user = match get_user_from_token!(jar, data) {
         Some(ua) => ua,
         None => return Json(Error::NotAllowed.into()),
     };
 
-    let path = pathd!("{}/banners/{}.avif", data.0.dirs.media, &auth_user.id);
+    if img.1 == "image/gif" && !auth_user.permissions.check(FinePermission::SUPPORTER) {
+        return Json(Error::RequiresSupporter.into());
+    }
+
+    let mime = if img.1 == "image/gif" {
+        "image/gif"
+    } else {
+        "image/avif"
+    };
+
+    if auth_user.settings.banner_mime != mime {
+        // mime changed; delete old image
+        let path = pathd!(
+            "{}/banners/{}.{}",
+            data.0.dirs.media,
+            &auth_user.id,
+            auth_user.settings.banner_mime.replace("image/", "")
+        );
+
+        std::fs::remove_file(path).unwrap();
+    }
+
+    let path = pathd!(
+        "{}/banners/{}.{}",
+        data.0.dirs.media,
+        &auth_user.id,
+        mime.replace("image/", "")
+    );
+
+    // update user settings
+    auth_user.settings.banner_mime = mime.to_string();
+    if let Err(e) = data
+        .update_user_settings(auth_user.id, auth_user.settings)
+        .await
+    {
+        return Json(e.into());
+    }
+
+    // upload image (gif)
+    if mime == "image/gif" {
+        // gif image, don't encode
+        if img.0.len() > MAXIUMUM_GIF_FILE_SIZE {
+            return Json(Error::DataTooLong("gif".to_string()).into());
+        }
+
+        std::fs::write(&path, img.0).unwrap();
+        return Json(ApiReturn {
+            ok: true,
+            message: "Banner uploaded. It might take a bit to update".to_string(),
+            payload: (),
+        });
+    }
 
     // check file size
     if img.0.len() > MAXIUMUM_FILE_SIZE {
@@ -216,7 +343,15 @@ pub async fn upload_banner_request(
         bytes.push(byte);
     }
 
-    match save_avif_buffer(&path, bytes) {
+    match save_buffer(
+        &path,
+        bytes,
+        if mime == "image/gif" {
+            image::ImageFormat::Gif
+        } else {
+            image::ImageFormat::Avif
+        },
+    ) {
         Ok(_) => Json(ApiReturn {
             ok: true,
             message: "Banner uploaded. It might take a bit to update".to_string(),
diff --git a/crates/app/src/routes/api/v1/communities/images.rs b/crates/app/src/routes/api/v1/communities/images.rs
index c2a95bf..4a7a2c1 100644
--- a/crates/app/src/routes/api/v1/communities/images.rs
+++ b/crates/app/src/routes/api/v1/communities/images.rs
@@ -6,7 +6,7 @@ use tetratto_core::model::{ApiReturn, Error, permissions::FinePermission};
 
 use crate::{
     State,
-    avif::{Image, save_avif_buffer},
+    image::{Image, save_buffer},
     get_user_from_token,
     routes::api::v1::auth::images::{MAXIUMUM_FILE_SIZE, read_image},
 };
@@ -146,7 +146,7 @@ pub async fn upload_avatar_request(
         bytes.push(byte);
     }
 
-    match save_avif_buffer(&path, bytes) {
+    match save_buffer(&path, bytes, image::ImageFormat::Avif) {
         Ok(_) => Json(ApiReturn {
             ok: true,
             message: "Avatar uploaded. It might take a bit to update".to_string(),
@@ -201,7 +201,7 @@ pub async fn upload_banner_request(
         bytes.push(byte);
     }
 
-    match save_avif_buffer(&path, bytes) {
+    match save_buffer(&path, bytes, image::ImageFormat::Avif) {
         Ok(_) => Json(ApiReturn {
             ok: true,
             message: "Banner uploaded. It might take a bit to update".to_string(),
diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs
index b55b8bb..6de3e02 100644
--- a/crates/core/src/database/auth.rs
+++ b/crates/core/src/database/auth.rs
@@ -304,13 +304,21 @@ impl DataManager {
         let avatar = PathBufD::current().extend(&[
             self.0.dirs.media.as_str(),
             "avatars",
-            &format!("{}.avif", &(user.id as i64)),
+            &format!(
+                "{}.{}",
+                &(user.id as i64),
+                user.settings.avatar_mime.replace("image/", "")
+            ),
         ]);
 
         let banner = PathBufD::current().extend(&[
             self.0.dirs.media.as_str(),
             "banners",
-            &format!("{}.avif", &(user.id as i64)),
+            &format!(
+                "{}.{}",
+                &(user.id as i64),
+                user.settings.banner_mime.replace("image/", "")
+            ),
         ]);
 
         if exists(&avatar).unwrap() {
diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs
index 6f4103f..a7f147d 100644
--- a/crates/core/src/model/auth.rs
+++ b/crates/core/src/model/auth.rs
@@ -199,6 +199,16 @@ pub struct UserSettings {
     /// The user's status. Shows over connection info.
     #[serde(default)]
     pub status: String,
+    /// The mime type of the user's avatar.
+    #[serde(default = "mime_avif")]
+    pub avatar_mime: String,
+    /// The mime type of the user's banner.
+    #[serde(default = "mime_avif")]
+    pub banner_mime: String,
+}
+
+fn mime_avif() -> String {
+    "image/avif".to_string()
 }
 
 impl Default for User {
diff --git a/crates/core/src/model/mod.rs b/crates/core/src/model/mod.rs
index 1a6dc80..8c5e6f9 100644
--- a/crates/core/src/model/mod.rs
+++ b/crates/core/src/model/mod.rs
@@ -43,6 +43,7 @@ pub enum Error {
     UsernameInUse,
     TitleInUse,
     QuestionsDisabled,
+    RequiresSupporter,
     Unknown,
 }
 
@@ -63,6 +64,9 @@ impl Display for Error {
             Self::UsernameInUse => "Username in use".to_string(),
             Self::TitleInUse => "Title in use".to_string(),
             Self::QuestionsDisabled => "You are not allowed to ask questions there".to_string(),
+            Self::RequiresSupporter => {
+                "Only site supporters can upload GIF files as their avatar/banner".to_string()
+            }
             _ => format!("An unknown error as occurred: ({:?})", self),
         })
     }
diff --git a/crates/core/src/model/oauth.rs b/crates/core/src/model/oauth.rs
index f25ba8e..46bde81 100644
--- a/crates/core/src/model/oauth.rs
+++ b/crates/core/src/model/oauth.rs
@@ -10,8 +10,35 @@ pub enum PkceChallengeMethod {
 
 #[derive(Serialize, Deserialize, PartialEq, Eq)]
 pub enum AppScope {
-    #[serde(alias = "user-read-profile")]
     UserReadProfile,
+    UserReadSessions,
+    UserReadPosts,
+    UserReadMessages,
+    UserCreatePosts,
+    UserCreateMessages,
+    UserDeletePosts,
+    UserDeleteMessages,
+}
+
+impl AppScope {
+    /// Parse the given input string as a list of scopes.
+    pub fn parse(input: &str) -> Vec<AppScope> {
+        let mut out: Vec<AppScope> = Vec::new();
+        for scope in input.split(" ") {
+            out.push(match scope {
+                "user-read-profile" => Self::UserReadProfile,
+                "user-read-sessions" => Self::UserReadSessions,
+                "user-read-posts" => Self::UserReadPosts,
+                "user-read-messages" => Self::UserReadMessages,
+                "user-create-posts" => Self::UserCreatePosts,
+                "user-create-messages" => Self::UserCreateMessages,
+                "user-delete-posts" => Self::UserDeletePosts,
+                "user-delete-messages" => Self::UserDeleteMessages,
+                _ => continue,
+            })
+        }
+        out
+    }
 }
 
 /// Check a verifier against the stored challenge (using the given [`PkceChallengeMethod`]).