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 FromRequest 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) -> std::io::Result<()> { +/// Create an image buffer given an input of `bytes` +pub fn save_buffer(path: &str, bytes: Vec, 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) -> 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 @@ 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.Images must be less than 8 MB large. Animated GIFs are only supported for supporter users. GIFs can be at most 2 MB large. @@ -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" /> 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::() { 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 { + let mut out: Vec = 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`]).