diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index a65c0cb..5cd2089 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -22,6 +22,7 @@ version = "1.0.0" "general:action.report" = "Report" "general:action.manage" = "Manage" "general:action.open" = "Open" +"general:action.view" = "View" "general:action.copy_link" = "Copy link" "general:label.safety" = "Safety" "general:label.share" = "Share" @@ -147,6 +148,7 @@ version = "1.0.0" "settings:tab.security" = "Security" "settings:tab.blocks" = "Blocks" "settings:tab.billing" = "Billing" +"settings:tab.uploads" = "Uploads" "mod_panel:label.open_reported_content" = "Open reported content" "mod_panel:label.manage_profile" = "Manage profile" diff --git a/crates/app/src/public/html/communities/create_post.html b/crates/app/src/public/html/communities/create_post.html index 93cb7d9..c19f3c3 100644 --- a/crates/app/src/public/html/communities/create_post.html +++ b/crates/app/src/public/html/communities/create_post.html @@ -97,8 +97,10 @@ // create body const body = new FormData(); - for (const file of e.target.file_picker.files) { - body.append(file.name, file); + if (e.target.file_picker) { + for (const file of e.target.file_picker.files) { + body.append(file.name, file); + } } body.append( diff --git a/crates/app/src/public/html/profile/settings.html b/crates/app/src/public/html/profile/settings.html index 62e9c48..e01cc3e 100644 --- a/crates/app/src/public/html/profile/settings.html +++ b/crates/app/src/public/html/profile/settings.html @@ -49,6 +49,11 @@ <span>{{ text "settings:tab.blocks" }}</span> </a> + <a data-tab-button="account/uploads" href="#/account/uploads"> + {{ icon "image-up" }} + <span>{{ text "settings:tab.uploads" }}</span> + </a> + {% if config.stripe %} <a data-tab-button="account/billing" href="#/account/billing"> {{ icon "credit-card" }} @@ -391,6 +396,86 @@ </div> </div> + <div class="w-full flex flex-col gap-2 hidden" data-tab="account/uploads"> + <div class="card tertiary flex flex-col gap-2"> + <a href="#/account" class="button secondary"> + {{ icon "arrow-left" }} + <span>{{ text "general:action.back" }}</span> + </a> + + <div class="card-nest"> + <div class="card flex items-center gap-2 small"> + {{ icon "image-up" }} + <span>{{ text "settings:tab.uploads" }}</span> + </div> + + <div class="card flex flex-col gap-2 secondary"> + {{ components::supporter_ad(body="Become a supporter to + upload images directly to posts!") }} {% for upload in + uploads %} + <div + class="card flex flex-wrap gap-2 items-center justify-between" + > + <div + class="flex gap-2 items-center" + onclick="trigger('ui::lightbox_open', ['/api/v1/uploads/{{ upload.id }}'])" + style="cursor: pointer" + > + {{ icon "file-image" }} + <b + ><span class="date">{{ upload.created }}</span> + ({{ upload.what }})</b + > + </div> + + <div class="flex gap-2"> + <button + class="quaternary small" + onclick="trigger('ui::lightbox_open', ['/api/v1/uploads/{{ upload.id }}'])" + > + {{ icon "view" }} + <span>{{ text "general:action.view" }}</span> + </button> + + <button + class="quaternary small red" + onclick="remove_upload('{{ upload.id }}')" + > + {{ icon "x" }} + <span>{{ text "stacks:label.remove" }}</span> + </button> + </div> + </div> + {% endfor %} {{ components::pagination(page=page, + items=uploads|length, key="#/account/uploads") }} + + <script> + globalThis.remove_upload = async (id) => { + if ( + !(await trigger("atto::confirm", [ + "Are you sure you would like to do this? This action is permanent.", + ])) + ) { + return; + } + + fetch(`/api/v1/uploads/${id}`, { + method: "DELETE", + }) + .then((res) => res.json()) + .then((res) => { + trigger("atto::toast", [ + res.ok ? "success" : "error", + res.message, + ]); + }); + }; + </script> + </div> + </div> + </div> + </div> + <div class="w-full flex flex-col gap-2 hidden" data-tab="account/billing"> <div class="card tertiary flex flex-col gap-2"> <a href="#/account" class="button secondary"> diff --git a/crates/app/src/routes/api/v1/auth/images.rs b/crates/app/src/routes/api/v1/auth/images.rs index dc5fdbb..67a5e5a 100644 --- a/crates/app/src/routes/api/v1/auth/images.rs +++ b/crates/app/src/routes/api/v1/auth/images.rs @@ -164,8 +164,8 @@ pub async fn banner_request( )) } -pub static MAXIUMUM_FILE_SIZE: usize = 8388608; -pub static MAXIUMUM_GIF_FILE_SIZE: usize = 2097152; +pub static MAXIMUM_FILE_SIZE: usize = 8388608; +pub static MAXIMUM_GIF_FILE_SIZE: usize = 2097152; /// Upload avatar pub async fn upload_avatar_request( @@ -223,7 +223,7 @@ pub async fn upload_avatar_request( // upload image (gif) if mime == "image/gif" { // gif image, don't encode - if img.0.len() > MAXIUMUM_GIF_FILE_SIZE { + if img.0.len() > MAXIMUM_GIF_FILE_SIZE { return Json(Error::DataTooLong("gif".to_string()).into()); } @@ -236,7 +236,7 @@ pub async fn upload_avatar_request( } // check file size - if img.0.len() > MAXIUMUM_FILE_SIZE { + if img.0.len() > MAXIMUM_FILE_SIZE { return Json(Error::DataTooLong("image".to_string()).into()); } @@ -321,7 +321,7 @@ pub async fn upload_banner_request( // upload image (gif) if mime == "image/gif" { // gif image, don't encode - if img.0.len() > MAXIUMUM_GIF_FILE_SIZE { + if img.0.len() > MAXIMUM_GIF_FILE_SIZE { return Json(Error::DataTooLong("gif".to_string()).into()); } @@ -334,7 +334,7 @@ pub async fn upload_banner_request( } // check file size - if img.0.len() > MAXIUMUM_FILE_SIZE { + if img.0.len() > MAXIMUM_FILE_SIZE { return Json(Error::DataTooLong("image".to_string()).into()); } diff --git a/crates/app/src/routes/api/v1/communities/emojis.rs b/crates/app/src/routes/api/v1/communities/emojis.rs index 6e31d86..2676d6c 100644 --- a/crates/app/src/routes/api/v1/communities/emojis.rs +++ b/crates/app/src/routes/api/v1/communities/emojis.rs @@ -81,7 +81,7 @@ pub async fn get_request( } // maximum file dimensions: 512x512px (256KiB) -pub const MAXIUMUM_FILE_SIZE: usize = 262144; +pub const MAXIMUM_FILE_SIZE: usize = 262144; pub async fn create_request( jar: CookieJar, @@ -96,7 +96,7 @@ pub async fn create_request( }; // check file size - if img.0.len() > MAXIUMUM_FILE_SIZE { + if img.0.len() > MAXIMUM_FILE_SIZE { return Json(Error::DataTooLong("image".to_string()).into()); } diff --git a/crates/app/src/routes/api/v1/communities/images.rs b/crates/app/src/routes/api/v1/communities/images.rs index 4a7a2c1..a49712f 100644 --- a/crates/app/src/routes/api/v1/communities/images.rs +++ b/crates/app/src/routes/api/v1/communities/images.rs @@ -8,7 +8,7 @@ use crate::{ State, image::{Image, save_buffer}, get_user_from_token, - routes::api::v1::auth::images::{MAXIUMUM_FILE_SIZE, read_image}, + routes::api::v1::auth::images::{MAXIMUM_FILE_SIZE, read_image}, }; /// Get a community's avatar image @@ -135,7 +135,7 @@ pub async fn upload_avatar_request( ); // check file size - if img.0.len() > MAXIUMUM_FILE_SIZE { + if img.0.len() > MAXIMUM_FILE_SIZE { return Json(Error::DataTooLong("image".to_string()).into()); } @@ -190,7 +190,7 @@ pub async fn upload_banner_request( ); // check file size - if img.0.len() > MAXIUMUM_FILE_SIZE { + if img.0.len() > MAXIMUM_FILE_SIZE { return Json(Error::DataTooLong("image".to_string()).into()); } diff --git a/crates/app/src/routes/api/v1/communities/posts.rs b/crates/app/src/routes/api/v1/communities/posts.rs index bae8b89..8f565bc 100644 --- a/crates/app/src/routes/api/v1/communities/posts.rs +++ b/crates/app/src/routes/api/v1/communities/posts.rs @@ -1,8 +1,6 @@ -use std::fs::exists; -use axum::{body::Body, extract::Path, response::IntoResponse, Extension, Json}; +use axum::{extract::Path, response::IntoResponse, Extension, Json}; use axum_extra::extract::CookieJar; use image::ImageFormat; -use pathbufd::PathBufD; use tetratto_core::model::{ communities::Post, permissions::FinePermission, @@ -12,14 +10,12 @@ use tetratto_core::model::{ use crate::{ get_user_from_token, image::{save_buffer, JsonMultipart}, - routes::api::v1::{ - auth::images::read_image, CreatePost, CreateRepost, UpdatePostContent, UpdatePostContext, - }, + routes::api::v1::{CreatePost, CreateRepost, UpdatePostContent, UpdatePostContext}, State, }; // maximum file dimensions: 2048x2048px (4 MiB) -pub const MAXIUMUM_FILE_SIZE: usize = 4194304; +pub const MAXIMUM_FILE_SIZE: usize = 4194304; pub async fn create_request( jar: CookieJar, @@ -74,7 +70,7 @@ pub async fn create_request( // check sizes for img in &images { - if img.len() > MAXIUMUM_FILE_SIZE { + if img.len() > MAXIMUM_FILE_SIZE { return Json(Error::DataTooLong("image".to_string()).into()); } } @@ -166,32 +162,6 @@ pub async fn create_repost_request( } } -pub async fn get_upload_request( - Path(id): Path<usize>, - Extension(data): Extension<State>, -) -> impl IntoResponse { - let data = &(data.read().await).0; - - let upload = data.get_upload_by_id(id).await.unwrap(); - let path = upload.path(&data.0); - - if !exists(&path).unwrap() { - return Err(( - [("Content-Type", "image/svg+xml")], - Body::from(read_image(PathBufD::current().extend(&[ - data.0.dirs.media.as_str(), - "images", - "default-banner.svg", - ]))), - )); - } - - Ok(( - [("Content-Type", upload.what.mime())], - Body::from(read_image(path)), - )) -} - pub async fn delete_request( jar: CookieJar, Extension(data): Extension<State>, diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index e08423f..1cea099 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -5,6 +5,7 @@ pub mod reactions; pub mod reports; pub mod requests; pub mod stacks; +pub mod uploads; pub mod util; #[cfg(feature = "redis")] @@ -349,7 +350,8 @@ pub fn routes() -> Router { .route("/stacks/{id}/users", delete(stacks::remove_user_request)) .route("/stacks/{id}", delete(stacks::delete_request)) // uploads - .route("/uploads/{id}", get(communities::posts::get_upload_request)) + .route("/uploads/{id}", get(uploads::get_request)) + .route("/uploads/{id}", delete(uploads::delete_request)) } #[derive(Deserialize)] diff --git a/crates/app/src/routes/api/v1/uploads.rs b/crates/app/src/routes/api/v1/uploads.rs new file mode 100644 index 0000000..77aec0c --- /dev/null +++ b/crates/app/src/routes/api/v1/uploads.rs @@ -0,0 +1,54 @@ +use std::fs::exists; +use axum::{body::Body, extract::Path, response::IntoResponse, Extension, Json}; +use axum_extra::extract::CookieJar; +use pathbufd::PathBufD; +use crate::{get_user_from_token, State}; +use super::auth::images::read_image; +use tetratto_core::model::{ApiReturn, Error}; + +pub async fn get_request( + Path(id): Path<usize>, + Extension(data): Extension<State>, +) -> impl IntoResponse { + let data = &(data.read().await).0; + + let upload = data.get_upload_by_id(id).await.unwrap(); + let path = upload.path(&data.0); + + if !exists(&path).unwrap() { + return Err(( + [("Content-Type", "image/svg+xml")], + Body::from(read_image(PathBufD::current().extend(&[ + data.0.dirs.media.as_str(), + "images", + "default-avatar.svg", + ]))), + )); + } + + Ok(( + [("Content-Type", upload.what.mime())], + Body::from(read_image(path)), + )) +} + +pub async fn delete_request( + jar: CookieJar, + Extension(data): Extension<State>, + Path(id): Path<usize>, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.delete_upload_checked(id, &user).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Upload deleted".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} diff --git a/crates/app/src/routes/pages/profile.rs b/crates/app/src/routes/pages/profile.rs index f2f9e48..ed3af57 100644 --- a/crates/app/src/routes/pages/profile.rs +++ b/crates/app/src/routes/pages/profile.rs @@ -19,6 +19,8 @@ use contrasted::{Color, MINIMUM_CONTRAST_THRESHOLD}; pub struct SettingsProps { #[serde(default)] pub username: String, + #[serde(default)] + pub page: usize, } /// `/settings` @@ -65,12 +67,21 @@ pub async fn settings_request( Err(e) => return Err(Html(render_error(e, &jar, &data, &None).await)), }; + let uploads = match data.0.get_uploads_by_owner(profile.id, 12, req.page).await { + Ok(ua) => ua, + Err(e) => { + return Err(Html(render_error(e, &jar, &data, &None).await)); + } + }; + let tokens = profile.tokens.clone(); let lang = get_lang!(jar, data.0); let mut context = initial_context(&data.0.0, lang, &Some(user)).await; context.insert("profile", &profile); + context.insert("page", &req.page); + context.insert("uploads", &uploads); context.insert("stacks", &stacks); context.insert("blocks", &blocks); context.insert("user_settings_serde", &clean_settings(&profile.settings)); diff --git a/crates/core/src/database/uploads.rs b/crates/core/src/database/uploads.rs index 0189148..2a00823 100644 --- a/crates/core/src/database/uploads.rs +++ b/crates/core/src/database/uploads.rs @@ -1,5 +1,7 @@ use super::*; use crate::cache::Cache; +use crate::model::auth::User; +use crate::model::permissions::FinePermission; use crate::model::{Error, Result, uploads::MediaUpload}; use crate::{auto_method, execute, get, query_row, query_rows, params}; @@ -50,6 +52,37 @@ impl DataManager { Ok(res.unwrap()) } + /// Get all uploads by their owner (paginated). + /// + /// # Arguments + /// * `owner` - the ID of the owner of the upload + /// * `batch` - the limit of items in each page + /// * `page` - the page number + pub async fn get_uploads_by_owner( + &self, + owner: usize, + batch: usize, + page: usize, + ) -> Result<Vec<MediaUpload>> { + let conn = match self.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = query_rows!( + &conn, + "SELECT * FROM uploads WHERE owner = $1 ORDER BY created DESC LIMIT $2 OFFSET $3", + &[&(owner as i64), &(batch as i64), &((page * batch) as i64)], + |x| { Self::get_upload_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("upload".to_string())); + } + + Ok(res.unwrap()) + } + /// Create a new upload in the database. /// /// # Arguments @@ -109,4 +142,31 @@ impl DataManager { // return Ok(()) } + + pub async fn delete_upload_checked(&self, id: usize, user: &User) -> Result<()> { + let upload = self.get_upload_by_id(id).await?; + + // check user permission + if user.id != upload.owner && !user.permissions.check(FinePermission::MANAGE_UPLOADS) { + return Err(Error::NotAllowed); + } + + // delete file + upload.remove(&self.0)?; + + // ... + let conn = match self.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!(&conn, "DELETE FROM uploads WHERE id = $1", &[&(id as i64)]); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + self.2.remove(format!("atto.upload:{}", id)).await; + Ok(()) + } }