From 70965298b53ce5a9ad4e6dc1d4dc0b469431a8fd Mon Sep 17 00:00:00 2001 From: trisua Date: Sun, 11 May 2025 14:27:55 -0400 Subject: [PATCH] add: post image uploads (supporter) --- crates/app/src/image.rs | 83 ++++++++++++ crates/app/src/public/css/style.css | 56 +++++++- .../public/html/communities/create_post.html | 37 +++++- .../app/src/public/html/communities/feed.html | 18 ++- crates/app/src/public/html/components.html | 105 +++++++++++++-- crates/app/src/public/html/post/post.html | 19 ++- crates/app/src/public/html/root.html | 13 +- crates/app/src/public/js/atto.js | 10 ++ .../routes/api/v1/communities/communities.rs | 2 +- .../src/routes/api/v1/communities/posts.rs | 121 ++++++++++++++++-- crates/app/src/routes/api/v1/mod.rs | 2 + crates/app/src/routes/pages/communities.rs | 15 ++- .../src/database/drivers/sql/create_posts.sql | 4 +- crates/core/src/database/emojis.rs | 1 - crates/core/src/database/memberships.rs | 2 +- crates/core/src/database/posts.rs | 12 +- crates/core/src/model/communities.rs | 3 + sql_changes/posts_uploads.sql | 2 + 18 files changed, 455 insertions(+), 50 deletions(-) create mode 100644 sql_changes/posts_uploads.sql diff --git a/crates/app/src/image.rs b/crates/app/src/image.rs index c415aa0..2182b7b 100644 --- a/crates/app/src/image.rs +++ b/crates/app/src/image.rs @@ -4,6 +4,7 @@ use axum::{ http::{StatusCode, header::CONTENT_TYPE}, }; use axum_extra::extract::Multipart; +use serde::de::DeserializeOwned; use std::{fs::File, io::BufWriter}; /// An image extractor accepting: @@ -80,3 +81,85 @@ pub fn save_buffer(path: &str, bytes: Vec, format: image::ImageFormat) -> st Ok(()) } + +/// A file extractor accepting: +/// * `multipart/form-data` +/// +/// Will also attempt to parse out the **last** field in the multipart upload +/// as the given struct from JSON. Every other field is put into a vector of bytes, +/// as they are seen as raw binary data. +pub struct JsonMultipart(pub Vec, pub T); + +impl FromRequest for JsonMultipart +where + Bytes: FromRequest, + S: Send + Sync, + T: DeserializeOwned, +{ + type Rejection = (StatusCode, String); + + async fn from_request(req: Request, state: &S) -> Result { + let Some(content_type) = req.headers().get(CONTENT_TYPE) else { + return Err(( + StatusCode::BAD_REQUEST, + "no content type header".to_string(), + )); + }; + + let content_type = content_type.to_str().unwrap(); + + if !content_type.starts_with("multipart/form-data") { + return Err(( + StatusCode::BAD_REQUEST, + "expected multipart/form-data".to_string(), + )); + } + + let mut multipart = Multipart::from_request(req, state).await.map_err(|_| { + ( + StatusCode::BAD_REQUEST, + "could not read multipart".to_string(), + ) + })?; + + let mut body: Vec = { + let mut out = Vec::new(); + + while let Ok(Some(field)) = multipart.next_field().await { + out.push(field.bytes().await.map_err(|_| { + ( + StatusCode::BAD_REQUEST, + "could not read field as bytes".to_string(), + ) + })?); + } + + out + }; + + let last = match body.pop() { + Some(b) => b, + None => { + return Err(( + StatusCode::BAD_REQUEST, + "could not read json data".to_string(), + )); + } + }; + + let json: T = match serde_json::from_str(&match String::from_utf8(last.to_vec()) { + Ok(s) => s, + Err(_) => return Err((StatusCode::BAD_REQUEST, "json data isn't utf8".to_string())), + }) { + Ok(s) => s, + Err(_) => { + return Err(( + StatusCode::BAD_REQUEST, + "could not parse json data as json".to_string(), + )); + } + }; + + Ok(Self(body, json)) + } +} diff --git a/crates/app/src/public/css/style.css b/crates/app/src/public/css/style.css index a5ea29d..7bb2eef 100644 --- a/crates/app/src/public/css/style.css +++ b/crates/app/src/public/css/style.css @@ -317,6 +317,60 @@ img.emoji { aspect-ratio: 1 / 1; } +.media_gallery { + display: grid; + grid-auto-columns: 1fr 1fr; + grid-auto-flow: column dense; + border-radius: var(--radius); + width: fit-content; + max-width: 100%; +} + +@media screen and (max-width: 900px) { + .media_gallery { + grid-auto-flow: row dense; + } +} + +.media_gallery img { + border-radius: var(--radius); + object-fit: cover; + width: 100%; + height: 100%; + cursor: pointer; + transition: filter 0.15s; +} + +.media_gallery img:hover { + filter: brightness(80%); +} + +.lightbox { + position: absolute; + z-index: 9999; + background: hsla(0, 0%, 0%, 50%); + backdrop-filter: blur(5px); + display: flex; + justify-content: center; + align-items: center; + width: 100dvw; + height: 100dvh; + top: 0; + left: 0; +} + +.lightbox_exit { + top: 1rem; + right: 1rem; + position: absolute; +} + +.lightbox img { + box-shadow: var(--shadow-x-offset) var(--shadow-y-offset) var(--shadow-size) + var(--color-shadow); + border-radius: var(--radius); +} + /* avatar/banner */ .avatar { --size: 50px; @@ -802,7 +856,7 @@ nav .button:not(.title):not(.active):hover { top: unset; } - body { + #page { padding-bottom: 72px; } diff --git a/crates/app/src/public/html/communities/create_post.html b/crates/app/src/public/html/communities/create_post.html index 1d84d42..93cb7d9 100644 --- a/crates/app/src/public/html/communities/create_post.html +++ b/crates/app/src/public/html/communities/create_post.html @@ -61,9 +61,13 @@ > +
+
{{ components::emoji_picker(element_id="content", - render_dialog=true) }} + render_dialog=true) }} {% if is_supporter %} {{ + components::file_picker(files_list_id="files_list") }} + {% endif %}
+{% endif %} {%- endmacro %} {% macro post_media(upload_ids) -%} {% if +upload_ids|length > 0%} + {% endif %} {%- endmacro %} {% macro notification(notification) -%}
@@ -1262,8 +1278,77 @@ show_kick=false, secondary=false) -%}
-{% endif %} {%- endmacro %} {% macro supporter_ad(body="") -%} {% if -config.stripe and not is_supporter %} +{% endif %} {%- endmacro %} {% macro file_picker(files_list_id) -%} + + + + + + + +{%- endmacro %} {% macro supporter_ad(body="") -%} {% if config.stripe and not +is_supporter %}
res.json()) .then((res) => { diff --git a/crates/app/src/public/html/root.html b/crates/app/src/public/html/root.html index 75e6dba..d58266d 100644 --- a/crates/app/src/public/html/root.html +++ b/crates/app/src/public/html/root.html @@ -67,7 +67,7 @@ macros -%}
-
+
{% if user and user.id == 0 %}
@@ -281,6 +281,17 @@ macros -%}
+ + {% if user %}
diff --git a/crates/app/src/public/js/atto.js b/crates/app/src/public/js/atto.js index 0d1b2f0..cae62b4 100644 --- a/crates/app/src/public/js/atto.js +++ b/crates/app/src/public/js/atto.js @@ -1028,6 +1028,16 @@ ${option.input_element_type === "textarea" ? `${option.value}` : ""} return get_permissions_html; }, ); + + // lightbox + self.define("lightbox_open", (_, src) => { + document.getElementById("lightbox_img").src = src; + document.getElementById("lightbox").classList.remove("hidden"); + }); + + self.define("lightbox_close", () => { + document.getElementById("lightbox").classList.add("hidden"); + }); })(); (() => { diff --git a/crates/app/src/routes/api/v1/communities/communities.rs b/crates/app/src/routes/api/v1/communities/communities.rs index 6baa276..1601d69 100644 --- a/crates/app/src/routes/api/v1/communities/communities.rs +++ b/crates/app/src/routes/api/v1/communities/communities.rs @@ -309,7 +309,7 @@ pub async fn delete_membership( Err(e) => return Json(e.into()), }; - match data.delete_membership(membership.id, user).await { + match data.delete_membership(membership.id, &user).await { Ok(_) => Json(ApiReturn { ok: true, message: "Membership deleted".to_string(), diff --git a/crates/app/src/routes/api/v1/communities/posts.rs b/crates/app/src/routes/api/v1/communities/posts.rs index a4a11d8..fb2dad0 100644 --- a/crates/app/src/routes/api/v1/communities/posts.rs +++ b/crates/app/src/routes/api/v1/communities/posts.rs @@ -1,17 +1,30 @@ -use axum::{Extension, Json, extract::Path, response::IntoResponse}; +use std::fs::exists; +use axum::{body::Body, extract::Path, response::IntoResponse, Extension, Json}; use axum_extra::extract::CookieJar; -use tetratto_core::model::{ApiReturn, Error, communities::Post}; - +use image::ImageFormat; +use pathbufd::PathBufD; +use tetratto_core::model::{ + communities::Post, + permissions::FinePermission, + uploads::{MediaType, MediaUpload}, + ApiReturn, Error, +}; use crate::{ get_user_from_token, - routes::api::v1::{CreatePost, CreateRepost, UpdatePostContent, UpdatePostContext}, + image::{save_buffer, JsonMultipart}, + routes::api::v1::{ + auth::images::read_image, CreatePost, CreateRepost, UpdatePostContent, UpdatePostContext, + }, State, }; +// maximum file dimensions: 2048x2048px (4 MiB) +pub const MAXIUMUM_FILE_SIZE: usize = 4194304; + pub async fn create_request( jar: CookieJar, Extension(data): Extension, - Json(req): Json, + JsonMultipart(images, req): JsonMultipart, ) -> impl IntoResponse { let data = &(data.read().await).0; let user = match get_user_from_token!(jar, data) { @@ -19,6 +32,15 @@ pub async fn create_request( None => return Json(Error::NotAllowed.into()), }; + if !user.permissions.check(FinePermission::SUPPORTER) { + if images.len() > 0 { + // this is currently supporter only until it's been tested better... + // after it's fully release, file limit will be raised to 8 MiB for supporters, + // and left at 4 for non-supporters + return Json(Error::RequiresSupporter.into()); + } + } + let mut props = Post::new( req.content, match req.community.parse::() { @@ -44,12 +66,63 @@ pub async fn create_request( }; } - match data.create_post(props).await { - Ok(id) => Json(ApiReturn { - ok: true, - message: "Post created".to_string(), - payload: Some(id.to_string()), - }), + // check sizes + for img in &images { + if img.len() > MAXIUMUM_FILE_SIZE { + return Json(Error::DataTooLong("image".to_string()).into()); + } + } + + // create uploads + for _ in 0..images.len() { + props.uploads.push( + match data + .create_upload(MediaUpload::new(MediaType::Webp, props.owner)) + .await + { + Ok(u) => u.id, + Err(e) => return Json(e.into()), + }, + ); + } + + // ... + match data.create_post(props.clone()).await { + Ok(id) => { + // write to uploads + for (i, upload_id) in props.uploads.iter().enumerate() { + let image = match images.get(i) { + Some(img) => img, + None => { + if let Err(e) = data.delete_upload(*upload_id).await { + return Json(e.into()); + } + + continue; + } + }; + + let upload = match data.get_upload_by_id(*upload_id).await { + Ok(u) => u, + Err(e) => return Json(e.into()), + }; + + if let Err(e) = save_buffer( + &upload.path(&data.0).to_string(), + image.to_vec(), + ImageFormat::WebP, + ) { + return Json(Error::MiscError(e.to_string()).into()); + } + } + + // return + Json(ApiReturn { + ok: true, + message: "Post created".to_string(), + payload: Some(id.to_string()), + }) + } Err(e) => Json(e.into()), } } @@ -87,6 +160,32 @@ pub async fn create_repost_request( } } +pub async fn get_upload_request( + Path(id): Path, + Extension(data): Extension, +) -> 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, diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index e867bca..e08423f 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -348,6 +348,8 @@ pub fn routes() -> Router { .route("/stacks/{id}/users", post(stacks::add_user_request)) .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)) } #[derive(Deserialize)] diff --git a/crates/app/src/routes/pages/communities.rs b/crates/app/src/routes/pages/communities.rs index a1cb12f..ae9e0e1 100644 --- a/crates/app/src/routes/pages/communities.rs +++ b/crates/app/src/routes/pages/communities.rs @@ -167,9 +167,20 @@ pub async fn list_request(jar: CookieJar, Extension(data): Extension) -> let mut communities: Vec = Vec::new(); for membership in &list { - match data.0.get_community_by_id(membership.community).await { + match data + .0 + .get_community_by_id_no_void(membership.community) + .await + { Ok(c) => communities.push(c), - Err(e) => return Err(Html(render_error(e, &jar, &data, &None).await)), + Err(_) => { + // delete membership; community doesn't exist + if let Err(e) = data.0.delete_membership(membership.id, &user).await { + return Err(Html(render_error(e, &jar, &data, &Some(user)).await)); + } + + continue; + } } } diff --git a/crates/core/src/database/drivers/sql/create_posts.sql b/crates/core/src/database/drivers/sql/create_posts.sql index 92bbc40..ab0bc6f 100644 --- a/crates/core/src/database/drivers/sql/create_posts.sql +++ b/crates/core/src/database/drivers/sql/create_posts.sql @@ -10,5 +10,7 @@ CREATE TABLE IF NOT EXISTS posts ( likes INT NOT NULL, dislikes INT NOT NULL, -- other counts - comment_count INT NOT NULL + comment_count INT NOT NULL, + -- ... + uploads TEXT NOT NULL ) diff --git a/crates/core/src/database/emojis.rs b/crates/core/src/database/emojis.rs index 4df2fa2..dd10eb1 100644 --- a/crates/core/src/database/emojis.rs +++ b/crates/core/src/database/emojis.rs @@ -1,5 +1,4 @@ use std::collections::HashMap; - use super::*; use crate::cache::Cache; use crate::model::{ diff --git a/crates/core/src/database/memberships.rs b/crates/core/src/database/memberships.rs index be4ab79..76feb0a 100644 --- a/crates/core/src/database/memberships.rs +++ b/crates/core/src/database/memberships.rs @@ -250,7 +250,7 @@ impl DataManager { } /// Delete a membership given its `id` - pub async fn delete_membership(&self, id: usize, user: User) -> Result<()> { + pub async fn delete_membership(&self, id: usize, user: &User) -> Result<()> { let y = self.get_membership_by_id(id).await?; if user.id != y.owner { diff --git a/crates/core/src/database/posts.rs b/crates/core/src/database/posts.rs index fd7685a..f2bddad 100644 --- a/crates/core/src/database/posts.rs +++ b/crates/core/src/database/posts.rs @@ -41,6 +41,8 @@ impl DataManager { dislikes: get!(x->8(i32)) as isize, // other counts comment_count: get!(x->9(i32)) as usize, + // ... + uploads: serde_json::from_str(&get!(x->10(String))).unwrap(), } } @@ -1038,7 +1040,7 @@ impl DataManager { let res = execute!( &conn, - "INSERT INTO posts VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", + "INSERT INTO posts VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)", params![ &(data.id as i64), &(data.created as i64), @@ -1053,7 +1055,8 @@ impl DataManager { }, &0_i32, &0_i32, - &0_i32 + &0_i32, + &serde_json::to_string(&data.uploads).unwrap() ] ); @@ -1148,6 +1151,11 @@ impl DataManager { } } + // delete uploads + for upload in y.uploads { + self.delete_upload(upload).await?; + } + // return Ok(()) } diff --git a/crates/core/src/model/communities.rs b/crates/core/src/model/communities.rs index dc014d3..1e7094e 100644 --- a/crates/core/src/model/communities.rs +++ b/crates/core/src/model/communities.rs @@ -236,6 +236,8 @@ pub struct Post { pub likes: isize, pub dislikes: isize, pub comment_count: usize, + /// IDs of all uploads linked to this post. + pub uploads: Vec, } impl Post { @@ -257,6 +259,7 @@ impl Post { likes: 0, dislikes: 0, comment_count: 0, + uploads: Vec::new(), } } diff --git a/sql_changes/posts_uploads.sql b/sql_changes/posts_uploads.sql new file mode 100644 index 0000000..3e2ecd9 --- /dev/null +++ b/sql_changes/posts_uploads.sql @@ -0,0 +1,2 @@ +ALTER TABLE posts +ADD COLUMN uploads TEXT NOT NULL DEFAULT '[]';