2025-03-29 00:26:56 -04:00
|
|
|
use axum::{
|
|
|
|
Extension, Json,
|
|
|
|
body::Body,
|
|
|
|
extract::{Path, Query},
|
|
|
|
response::IntoResponse,
|
|
|
|
};
|
2025-03-23 18:03:11 -04:00
|
|
|
use axum_extra::extract::CookieJar;
|
|
|
|
use pathbufd::{PathBufD, pathd};
|
2025-03-29 00:26:56 -04:00
|
|
|
use serde::Deserialize;
|
2025-03-22 22:17:47 -04:00
|
|
|
use std::{
|
|
|
|
fs::{File, exists},
|
|
|
|
io::Read,
|
|
|
|
};
|
2025-05-04 16:19:34 -04:00
|
|
|
use tetratto_core::model::{permissions::FinePermission, ApiReturn, Error};
|
2025-03-22 22:17:47 -04:00
|
|
|
|
2025-03-23 18:03:11 -04:00
|
|
|
use crate::{
|
|
|
|
State,
|
2025-05-04 16:19:34 -04:00
|
|
|
image::{Image, save_buffer},
|
2025-03-23 18:03:11 -04:00
|
|
|
get_user_from_token,
|
|
|
|
};
|
2025-03-22 22:17:47 -04:00
|
|
|
|
|
|
|
pub fn read_image(path: PathBufD) -> Vec<u8> {
|
|
|
|
let mut bytes = Vec::new();
|
|
|
|
|
|
|
|
for byte in File::open(path).unwrap().bytes() {
|
|
|
|
bytes.push(byte.unwrap())
|
|
|
|
}
|
|
|
|
|
|
|
|
bytes
|
|
|
|
}
|
|
|
|
|
2025-03-29 00:26:56 -04:00
|
|
|
#[derive(Deserialize, PartialEq, Eq)]
|
|
|
|
pub enum AvatarSelectorType {
|
|
|
|
#[serde(alias = "username")]
|
|
|
|
Username,
|
|
|
|
#[serde(alias = "id")]
|
|
|
|
Id,
|
|
|
|
}
|
|
|
|
|
|
|
|
#[derive(Deserialize)]
|
|
|
|
pub struct AvatarSelectorQuery {
|
|
|
|
pub selector_type: AvatarSelectorType,
|
|
|
|
}
|
|
|
|
|
2025-03-22 22:17:47 -04:00
|
|
|
/// Get a profile's avatar image
|
2025-04-04 21:42:08 -04:00
|
|
|
/// `/api/v1/auth/user/{id}/avatar`
|
2025-03-22 22:17:47 -04:00
|
|
|
pub async fn avatar_request(
|
2025-03-29 00:26:56 -04:00
|
|
|
Path(selector): Path<String>,
|
2025-03-22 22:17:47 -04:00
|
|
|
Extension(data): Extension<State>,
|
2025-03-29 00:26:56 -04:00
|
|
|
Query(req): Query<AvatarSelectorQuery>,
|
2025-03-22 22:17:47 -04:00
|
|
|
) -> impl IntoResponse {
|
|
|
|
let data = &(data.read().await).0;
|
|
|
|
|
2025-03-31 15:39:49 -04:00
|
|
|
let user = match if req.selector_type == AvatarSelectorType::Id {
|
2025-04-09 00:10:58 -04:00
|
|
|
data.get_user_by_id(match selector.parse::<usize>() {
|
|
|
|
Ok(d) => d,
|
|
|
|
Err(_) => {
|
2025-05-04 16:19:34 -04:00
|
|
|
return Err((
|
2025-04-09 00:10:58 -04:00
|
|
|
[("Content-Type", "image/svg+xml")],
|
|
|
|
Body::from(read_image(PathBufD::current().extend(&[
|
|
|
|
data.0.dirs.media.as_str(),
|
|
|
|
"images",
|
|
|
|
"default-avatar.svg",
|
|
|
|
]))),
|
2025-05-04 16:19:34 -04:00
|
|
|
));
|
2025-04-09 00:10:58 -04:00
|
|
|
}
|
|
|
|
})
|
|
|
|
.await
|
2025-03-31 15:39:49 -04:00
|
|
|
} else {
|
|
|
|
data.get_user_by_username(&selector).await
|
2025-03-29 00:26:56 -04:00
|
|
|
} {
|
2025-03-22 22:17:47 -04:00
|
|
|
Ok(ua) => ua,
|
|
|
|
Err(_) => {
|
2025-05-04 16:19:34 -04:00
|
|
|
return Err((
|
2025-03-22 22:17:47 -04:00
|
|
|
[("Content-Type", "image/svg+xml")],
|
|
|
|
Body::from(read_image(PathBufD::current().extend(&[
|
|
|
|
data.0.dirs.media.as_str(),
|
|
|
|
"images",
|
|
|
|
"default-avatar.svg",
|
|
|
|
]))),
|
2025-05-04 16:19:34 -04:00
|
|
|
));
|
2025-03-22 22:17:47 -04:00
|
|
|
}
|
|
|
|
};
|
|
|
|
|
2025-03-31 11:45:34 -04:00
|
|
|
let path = PathBufD::current().extend(&[
|
|
|
|
data.0.dirs.media.as_str(),
|
|
|
|
"avatars",
|
2025-05-04 16:19:34 -04:00
|
|
|
&format!(
|
|
|
|
"{}.{}",
|
|
|
|
&(user.id as i64),
|
|
|
|
user.settings.avatar_mime.replace("image/", "")
|
|
|
|
),
|
2025-03-31 11:45:34 -04:00
|
|
|
]);
|
2025-03-22 22:17:47 -04:00
|
|
|
|
|
|
|
if !exists(&path).unwrap() {
|
2025-05-04 16:19:34 -04:00
|
|
|
return Err((
|
2025-03-22 22:17:47 -04:00
|
|
|
[("Content-Type", "image/svg+xml")],
|
|
|
|
Body::from(read_image(PathBufD::current().extend(&[
|
|
|
|
data.0.dirs.media.as_str(),
|
|
|
|
"images",
|
|
|
|
"default-avatar.svg",
|
|
|
|
]))),
|
2025-05-04 16:19:34 -04:00
|
|
|
));
|
2025-03-22 22:17:47 -04:00
|
|
|
}
|
|
|
|
|
2025-05-04 16:19:34 -04:00
|
|
|
Ok((
|
|
|
|
[(
|
|
|
|
"Content-Type".to_string(),
|
|
|
|
user.settings.avatar_mime.clone(),
|
|
|
|
)],
|
2025-03-22 22:17:47 -04:00
|
|
|
Body::from(read_image(path)),
|
2025-05-04 16:19:34 -04:00
|
|
|
))
|
2025-03-22 22:17:47 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
/// Get a profile's banner image
|
2025-04-04 21:42:08 -04:00
|
|
|
/// `/api/v1/auth/user/{id}/banner`
|
2025-03-22 22:17:47 -04:00
|
|
|
pub async fn banner_request(
|
|
|
|
Path(username): Path<String>,
|
|
|
|
Extension(data): Extension<State>,
|
|
|
|
) -> impl IntoResponse {
|
|
|
|
let data = &(data.read().await).0;
|
|
|
|
|
|
|
|
let user = match data.get_user_by_username(&username).await {
|
|
|
|
Ok(ua) => ua,
|
|
|
|
Err(_) => {
|
2025-05-04 16:19:34 -04:00
|
|
|
return Err((
|
2025-03-22 22:17:47 -04:00
|
|
|
[("Content-Type", "image/svg+xml")],
|
|
|
|
Body::from(read_image(PathBufD::current().extend(&[
|
|
|
|
data.0.dirs.media.as_str(),
|
|
|
|
"images",
|
|
|
|
"default-banner.svg",
|
|
|
|
]))),
|
2025-05-04 16:19:34 -04:00
|
|
|
));
|
2025-03-22 22:17:47 -04:00
|
|
|
}
|
|
|
|
};
|
|
|
|
|
2025-03-31 11:45:34 -04:00
|
|
|
let path = PathBufD::current().extend(&[
|
|
|
|
data.0.dirs.media.as_str(),
|
|
|
|
"banners",
|
2025-05-04 16:19:34 -04:00
|
|
|
&format!(
|
|
|
|
"{}.{}",
|
|
|
|
&(user.id as i64),
|
|
|
|
user.settings.banner_mime.replace("image/", "")
|
|
|
|
),
|
2025-03-31 11:45:34 -04:00
|
|
|
]);
|
2025-03-22 22:17:47 -04:00
|
|
|
|
|
|
|
if !exists(&path).unwrap() {
|
2025-05-04 16:19:34 -04:00
|
|
|
return Err((
|
2025-03-22 22:17:47 -04:00
|
|
|
[("Content-Type", "image/svg+xml")],
|
|
|
|
Body::from(read_image(PathBufD::current().extend(&[
|
|
|
|
data.0.dirs.media.as_str(),
|
|
|
|
"images",
|
|
|
|
"default-banner.svg",
|
|
|
|
]))),
|
2025-05-04 16:19:34 -04:00
|
|
|
));
|
2025-03-22 22:17:47 -04:00
|
|
|
}
|
|
|
|
|
2025-05-04 16:19:34 -04:00
|
|
|
Ok((
|
|
|
|
[(
|
|
|
|
"Content-Type".to_string(),
|
|
|
|
user.settings.banner_mime.clone(),
|
|
|
|
)],
|
2025-03-22 22:17:47 -04:00
|
|
|
Body::from(read_image(path)),
|
2025-05-04 16:19:34 -04:00
|
|
|
))
|
2025-03-22 22:17:47 -04:00
|
|
|
}
|
2025-03-23 18:03:11 -04:00
|
|
|
|
2025-03-29 00:26:56 -04:00
|
|
|
pub static MAXIUMUM_FILE_SIZE: usize = 8388608;
|
2025-05-04 16:19:34 -04:00
|
|
|
pub static MAXIUMUM_GIF_FILE_SIZE: usize = 2097152;
|
2025-03-23 18:03:11 -04:00
|
|
|
|
|
|
|
/// Upload avatar
|
|
|
|
pub async fn upload_avatar_request(
|
|
|
|
jar: CookieJar,
|
|
|
|
Extension(data): Extension<State>,
|
|
|
|
img: Image,
|
|
|
|
) -> impl IntoResponse {
|
|
|
|
// get user from token
|
|
|
|
let data = &(data.read().await).0;
|
2025-05-04 16:19:34 -04:00
|
|
|
let mut auth_user = match get_user_from_token!(jar, data) {
|
2025-03-23 18:03:11 -04:00
|
|
|
Some(ua) => ua,
|
|
|
|
None => return Json(Error::NotAllowed.into()),
|
|
|
|
};
|
|
|
|
|
2025-05-04 16:19:34 -04:00
|
|
|
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: (),
|
|
|
|
});
|
|
|
|
}
|
2025-03-23 18:03:11 -04:00
|
|
|
|
|
|
|
// check file size
|
|
|
|
if img.0.len() > MAXIUMUM_FILE_SIZE {
|
|
|
|
return Json(Error::DataTooLong("image".to_string()).into());
|
|
|
|
}
|
|
|
|
|
|
|
|
// upload image
|
|
|
|
let mut bytes = Vec::new();
|
|
|
|
|
|
|
|
for byte in img.0 {
|
|
|
|
bytes.push(byte);
|
|
|
|
}
|
|
|
|
|
2025-05-04 16:19:34 -04:00
|
|
|
match save_buffer(
|
|
|
|
&path,
|
|
|
|
bytes,
|
|
|
|
if mime == "image/gif" {
|
|
|
|
image::ImageFormat::Gif
|
|
|
|
} else {
|
|
|
|
image::ImageFormat::Avif
|
|
|
|
},
|
|
|
|
) {
|
2025-03-23 18:03:11 -04:00
|
|
|
Ok(_) => Json(ApiReturn {
|
|
|
|
ok: true,
|
|
|
|
message: "Avatar uploaded. It might take a bit to update".to_string(),
|
|
|
|
payload: (),
|
|
|
|
}),
|
|
|
|
Err(e) => Json(Error::MiscError(e.to_string()).into()),
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/// Upload banner
|
|
|
|
pub async fn upload_banner_request(
|
|
|
|
jar: CookieJar,
|
|
|
|
Extension(data): Extension<State>,
|
|
|
|
img: Image,
|
|
|
|
) -> impl IntoResponse {
|
|
|
|
// get user from token
|
|
|
|
let data = &(data.read().await).0;
|
2025-05-04 16:19:34 -04:00
|
|
|
let mut auth_user = match get_user_from_token!(jar, data) {
|
2025-03-23 18:03:11 -04:00
|
|
|
Some(ua) => ua,
|
|
|
|
None => return Json(Error::NotAllowed.into()),
|
|
|
|
};
|
|
|
|
|
2025-05-04 16:19:34 -04:00
|
|
|
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/", "")
|
|
|
|
);
|
|
|
|
|
2025-05-05 21:06:47 -04:00
|
|
|
if std::fs::exists(&path).unwrap() {
|
|
|
|
std::fs::remove_file(path).unwrap();
|
|
|
|
}
|
2025-05-04 16:19:34 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
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: (),
|
|
|
|
});
|
|
|
|
}
|
2025-03-23 18:03:11 -04:00
|
|
|
|
|
|
|
// check file size
|
|
|
|
if img.0.len() > MAXIUMUM_FILE_SIZE {
|
|
|
|
return Json(Error::DataTooLong("image".to_string()).into());
|
|
|
|
}
|
|
|
|
|
|
|
|
// upload image
|
|
|
|
let mut bytes = Vec::new();
|
|
|
|
|
|
|
|
for byte in img.0 {
|
|
|
|
bytes.push(byte);
|
|
|
|
}
|
|
|
|
|
2025-05-04 16:19:34 -04:00
|
|
|
match save_buffer(
|
|
|
|
&path,
|
|
|
|
bytes,
|
|
|
|
if mime == "image/gif" {
|
|
|
|
image::ImageFormat::Gif
|
|
|
|
} else {
|
|
|
|
image::ImageFormat::Avif
|
|
|
|
},
|
|
|
|
) {
|
2025-03-23 18:03:11 -04:00
|
|
|
Ok(_) => Json(ApiReturn {
|
|
|
|
ok: true,
|
|
|
|
message: "Banner uploaded. It might take a bit to update".to_string(),
|
|
|
|
payload: (),
|
|
|
|
}),
|
|
|
|
Err(e) => Json(Error::MiscError(e.to_string()).into()),
|
|
|
|
}
|
|
|
|
}
|