diff --git a/Cargo.lock b/Cargo.lock index 5e3a90f..ccf0b51 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3282,7 +3282,7 @@ dependencies = [ [[package]] name = "tetratto" -version = "3.0.0" +version = "3.1.0" dependencies = [ "ammonia", "async-stripe", @@ -3313,7 +3313,7 @@ dependencies = [ [[package]] name = "tetratto-core" -version = "3.0.0" +version = "3.1.0" dependencies = [ "async-recursion", "base16ct", @@ -3337,7 +3337,7 @@ dependencies = [ [[package]] name = "tetratto-l10n" -version = "3.0.0" +version = "3.1.0" dependencies = [ "pathbufd", "serde", @@ -3346,7 +3346,7 @@ dependencies = [ [[package]] name = "tetratto-shared" -version = "3.0.0" +version = "3.1.0" dependencies = [ "ammonia", "chrono", diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 6fce0f0..408b1b0 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto" -version = "3.0.0" +version = "3.1.0" edition = "2024" [features] diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index 28ee298..90d5004 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -26,6 +26,7 @@ version = "1.0.0" "general:action.open" = "Open" "general:action.view" = "View" "general:action.copy_link" = "Copy link" +"general:action.post" = "Post" "general:label.safety" = "Safety" "general:label.share" = "Share" "general:action.add_account" = "Add account" @@ -121,6 +122,8 @@ version = "1.0.0" "communities:tab.emojis" = "Emojis" "communities:label.upload" = "Upload" "communities:label.file" = "File" +"communities:label.drafts" = "Drafts" +"communities:label.load" = "Load" "notifs:action.mark_as_read" = "Mark as read" "notifs:action.mark_as_unread" = "Mark as unread" diff --git a/crates/app/src/public/css/style.css b/crates/app/src/public/css/style.css index 4f09959..c9d77b7 100644 --- a/crates/app/src/public/css/style.css +++ b/crates/app/src/public/css/style.css @@ -511,6 +511,7 @@ table ol { .card.secondary { background: var(--color-surface); + color: var(--color-text); } .card.tertiary { diff --git a/crates/app/src/public/html/communities/base.html b/crates/app/src/public/html/communities/base.html index af180aa..39eee00 100644 --- a/crates/app/src/public/html/communities/base.html +++ b/crates/app/src/public/html/communities/base.html @@ -187,6 +187,16 @@ {{ text "communities:label.chats" }} + {% if user and can_post %} + + {{ icon "plus" }} + {{ text "general:action.post" }} + + {% endif %} + + + {% if drafts|length > 0 %} + + + + {% endif %} {% endblock %} diff --git a/crates/app/src/routes/api/v1/communities/drafts.rs b/crates/app/src/routes/api/v1/communities/drafts.rs new file mode 100644 index 0000000..f181f05 --- /dev/null +++ b/crates/app/src/routes/api/v1/communities/drafts.rs @@ -0,0 +1,75 @@ +use axum::{extract::Path, response::IntoResponse, Extension, Json}; +use axum_extra::extract::CookieJar; +use tetratto_core::model::{communities::PostDraft, ApiReturn, Error}; +use crate::{ + get_user_from_token, + routes::api::v1::{CreatePostDraft, UpdatePostContent}, + State, +}; + +pub async fn create_request( + jar: CookieJar, + Extension(data): Extension, + Json(req): Json, +) -> 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 + .create_draft(PostDraft::new(req.content, user.id)) + .await + { + Ok(id) => Json(ApiReturn { + ok: true, + message: "Draft created".to_string(), + payload: Some(id.to_string()), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn delete_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, +) -> 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_draft(id, user).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Draft deleted".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_content_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, + Json(req): Json, +) -> 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.update_draft_content(id, user, req.content).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Draft updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} diff --git a/crates/app/src/routes/api/v1/communities/mod.rs b/crates/app/src/routes/api/v1/communities/mod.rs index ab6e2bb..a3dbd7a 100644 --- a/crates/app/src/routes/api/v1/communities/mod.rs +++ b/crates/app/src/routes/api/v1/communities/mod.rs @@ -1,4 +1,5 @@ pub mod communities; +pub mod drafts; pub mod emojis; pub mod images; pub mod posts; diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 51e0119..da317bf 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -113,6 +113,13 @@ pub fn routes() -> Router { "/posts/{id}/context", post(communities::posts::update_context_request), ) + // drafts + .route("/drafts", post(communities::drafts::create_request)) + .route("/drafts/{id}", delete(communities::drafts::delete_request)) + .route( + "/drafts/{id}/content", + post(communities::drafts::update_content_request), + ) // questions .route("/questions", post(communities::questions::create_request)) .route( @@ -581,3 +588,8 @@ pub struct AddOrRemoveStackUser { pub struct UpdateEmojiName { pub name: String, } + +#[derive(Deserialize)] +pub struct CreatePostDraft { + pub content: String, +} diff --git a/crates/app/src/routes/pages/communities.rs b/crates/app/src/routes/pages/communities.rs index 453157b..9a4f4a2 100644 --- a/crates/app/src/routes/pages/communities.rs +++ b/crates/app/src/routes/pages/communities.rs @@ -8,6 +8,7 @@ use axum::{ response::{Html, IntoResponse}, }; use axum_extra::extract::CookieJar; +use serde::Deserialize; use tera::Context; use tetratto_core::model::{ auth::User, @@ -236,10 +237,19 @@ pub async fn search_request( )) } +#[derive(Deserialize)] +pub struct CreatePostProps { + #[serde(default)] + pub community: usize, + #[serde(default)] + pub from_draft: usize, +} + /// `/communities/intents/post` pub async fn create_post_request( jar: CookieJar, Extension(data): Extension, + Query(props): Query, ) -> impl IntoResponse { let data = data.read().await; let user = match get_user_from_token!(jar, data.0) { @@ -271,9 +281,32 @@ pub async fn create_post_request( communities.push(community) } + // get draft + let draft = if props.from_draft != 0 { + match data.0.get_draft_by_id(props.from_draft).await { + Ok(d) => { + // drafts can only be used by their owner + if d.owner == user.id { Some(d) } else { None } + } + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + } + } else { + None + }; + + let drafts = match data.0.get_drafts_by_user_all(user.id).await { + Ok(l) => l, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }; + + // ... let lang = get_lang!(jar, data.0); let mut context = initial_context(&data.0.0, lang, &Some(user)).await; + + context.insert("draft", &draft); + context.insert("drafts", &drafts); context.insert("communities", &communities); + context.insert("selected_community", &props.community); // return Ok(Html( @@ -1118,10 +1151,16 @@ pub async fn question_request( false }; + let is_sender = if let Some(ref ua) = user { + ua.id == question.owner + } else { + false + }; + // check permissions let (can_read, _) = check_permissions!(community, jar, data, user); - if !can_read { + if !can_read && !is_sender { return Err(Html( render_error(Error::NotAllowed, &jar, &data, &user).await, )); diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 07b97cf..adc8e8d 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto-core" -version = "3.0.0" +version = "3.1.0" edition = "2024" [features] diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs index e98a5d1..37d4ee5 100644 --- a/crates/core/src/database/auth.rs +++ b/crates/core/src/database/auth.rs @@ -291,6 +291,28 @@ impl DataManager { return Err(Error::DatabaseError(e.to_string())); } + // delete stacks + let res = execute!( + &conn, + "DELETE FROM stacks WHERE owner = $1", + &[&(id as i64)] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // delete drafts + let res = execute!( + &conn, + "DELETE FROM drafts WHERE owner = $1", + &[&(id as i64)] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + // delete posts let res = execute!(&conn, "DELETE FROM posts WHERE owner = $1", &[&(id as i64)]); diff --git a/crates/core/src/database/common.rs b/crates/core/src/database/common.rs index 399005b..397cf00 100644 --- a/crates/core/src/database/common.rs +++ b/crates/core/src/database/common.rs @@ -33,6 +33,7 @@ impl DataManager { execute!(&conn, common::CREATE_TABLE_UPLOADS).unwrap(); execute!(&conn, common::CREATE_TABLE_EMOJIS).unwrap(); execute!(&conn, common::CREATE_TABLE_STACKS).unwrap(); + execute!(&conn, common::CREATE_TABLE_DRAFTS).unwrap(); self.2 .set("atto.active_connections:users".to_string(), "0".to_string()) diff --git a/crates/core/src/database/drafts.rs b/crates/core/src/database/drafts.rs new file mode 100644 index 0000000..239d035 --- /dev/null +++ b/crates/core/src/database/drafts.rs @@ -0,0 +1,185 @@ +use super::*; +use crate::cache::Cache; +use crate::model::moderation::AuditLogEntry; +use crate::model::{Error, Result, auth::User, communities::PostDraft, permissions::FinePermission}; +use crate::{auto_method, execute, get, query_row, query_rows, params}; + +#[cfg(feature = "sqlite")] +use rusqlite::Row; + +#[cfg(feature = "postgres")] +use tokio_postgres::Row; + +impl DataManager { + /// Get a [`PostDraft`] from an SQL row. + pub(crate) fn get_draft_from_row( + #[cfg(feature = "sqlite")] x: &Row<'_>, + #[cfg(feature = "postgres")] x: &Row, + ) -> PostDraft { + PostDraft { + id: get!(x->0(i64)) as usize, + created: get!(x->1(i64)) as usize, + content: get!(x->2(String)), + owner: get!(x->3(i64)) as usize, + } + } + + auto_method!(get_draft_by_id()@get_draft_from_row -> "SELECT * FROM drafts WHERE id = $1" --name="draft" --returns=PostDraft --cache-key-tmpl="atto.draft:{}"); + + /// Get all drafts from the given user (from most recent, paginated). + /// + /// # Arguments + /// * `id` - the ID of the user the requested drafts belong to + /// * `batch` - the limit of posts in each page + /// * `page` - the page number + pub async fn get_drafts_by_user( + &self, + id: usize, + batch: usize, + page: usize, + ) -> Result> { + 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 drafts WHERE owner = $1 ORDER BY created DESC LIMIT $2 OFFSET $3", + &[&(id as i64), &(batch as i64), &((page * batch) as i64)], + |x| { Self::get_draft_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("draft".to_string())); + } + + Ok(res.unwrap()) + } + + /// Get all drafts from the given user (from most recent). + /// + /// # Arguments + /// * `id` - the ID of the user the requested drafts belong to + pub async fn get_drafts_by_user_all(&self, id: usize) -> Result> { + 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 drafts WHERE owner = $1 ORDER BY created DESC", + &[&(id as i64)], + |x| { Self::get_draft_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("draft".to_string())); + } + + Ok(res.unwrap()) + } + + /// Create a new post draft in the database. + /// + /// # Arguments + /// * `data` - a mock [`PostDraft`] object to insert + pub async fn create_draft(&self, data: PostDraft) -> Result { + // check values + if data.content.len() < 2 { + return Err(Error::DataTooShort("content".to_string())); + } else if data.content.len() > 4096 { + return Err(Error::DataTooLong("content".to_string())); + } + + // ... + let conn = match self.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!( + &conn, + "INSERT INTO drafts VALUES ($1, $2, $3, $4)", + params![ + &(data.id as i64), + &(data.created as i64), + &data.content, + &(data.owner as i64), + ] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + Ok(data.id) + } + + pub async fn delete_draft(&self, id: usize, user: User) -> Result<()> { + let y = self.get_draft_by_id(id).await?; + + if user.id != y.owner { + if !user.permissions.check(FinePermission::MANAGE_POSTS) { + return Err(Error::NotAllowed); + } else { + self.create_audit_log_entry(AuditLogEntry::new( + user.id, + format!("invoked `delete_draft` with x value `{id}`"), + )) + .await? + } + } + let conn = match self.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!(&conn, "DELETE FROM drafts WHERE id = $1", &[&(id as i64)]); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + self.2.remove(format!("atto.draft:{}", id)).await; + + Ok(()) + } + + pub async fn update_draft_content(&self, id: usize, user: User, x: String) -> Result<()> { + let y = self.get_draft_by_id(id).await?; + + if user.id != y.owner { + if !user.permissions.check(FinePermission::MANAGE_POSTS) { + return Err(Error::NotAllowed); + } else { + self.create_audit_log_entry(AuditLogEntry::new( + user.id, + format!("invoked `update_draft_content` with x value `{id}`"), + )) + .await? + } + } + + // ... + let conn = match self.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!( + &conn, + "UPDATE drafts SET content = $1 WHERE id = $2", + params![&x, &(id as i64)] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + Ok(()) + } +} diff --git a/crates/core/src/database/drivers/common.rs b/crates/core/src/database/drivers/common.rs index 6c67bb0..fda8e77 100644 --- a/crates/core/src/database/drivers/common.rs +++ b/crates/core/src/database/drivers/common.rs @@ -18,3 +18,4 @@ pub const CREATE_TABLE_MESSAGES: &str = include_str!("./sql/create_messages.sql" pub const CREATE_TABLE_UPLOADS: &str = include_str!("./sql/create_uploads.sql"); pub const CREATE_TABLE_EMOJIS: &str = include_str!("./sql/create_emojis.sql"); pub const CREATE_TABLE_STACKS: &str = include_str!("./sql/create_stacks.sql"); +pub const CREATE_TABLE_DRAFTS: &str = include_str!("./sql/create_drafts.sql"); diff --git a/crates/core/src/database/drivers/sql/create_drafts.sql b/crates/core/src/database/drivers/sql/create_drafts.sql new file mode 100644 index 0000000..39cba10 --- /dev/null +++ b/crates/core/src/database/drivers/sql/create_drafts.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS drafts ( + id BIGINT NOT NULL PRIMARY KEY, + created BIGINT NOT NULL, + content TEXT NOT NULL, + owner BIGINT NOT NULL +) diff --git a/crates/core/src/database/mod.rs b/crates/core/src/database/mod.rs index 4cabd4e..e02caff 100644 --- a/crates/core/src/database/mod.rs +++ b/crates/core/src/database/mod.rs @@ -3,6 +3,7 @@ mod auth; mod common; mod communities; pub mod connections; +mod drafts; mod drivers; mod emojis; mod ipbans; diff --git a/crates/core/src/database/posts.rs b/crates/core/src/database/posts.rs index e1366be..592a373 100644 --- a/crates/core/src/database/posts.rs +++ b/crates/core/src/database/posts.rs @@ -918,10 +918,10 @@ impl DataManager { } } - /// Create a new journal entry in the database. + /// Create a new post in the database. /// /// # Arguments - /// * `data` - a mock [`JournalEntry`] object to insert + /// * `data` - a mock [`Post`] object to insert pub async fn create_post(&self, mut data: Post) -> Result { // check values (if this isn't reposting something else) let is_reposting = if let Some(ref repost) = data.context.repost { diff --git a/crates/core/src/model/communities.rs b/crates/core/src/model/communities.rs index 886dfd0..5fa153b 100644 --- a/crates/core/src/model/communities.rs +++ b/crates/core/src/model/communities.rs @@ -349,3 +349,23 @@ pub struct QuestionContext { #[serde(default)] pub is_nsfw: bool, } + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct PostDraft { + pub id: usize, + pub created: usize, + pub content: String, + pub owner: usize, +} + +impl PostDraft { + /// Create a new [`PostDraft`]. + pub fn new(content: String, owner: usize) -> Self { + Self { + id: Snowflake::new().to_string().parse::().unwrap(), + created: unix_epoch_timestamp() as usize, + content, + owner, + } + } +} diff --git a/crates/l10n/Cargo.toml b/crates/l10n/Cargo.toml index ba20710..ff1a8f0 100644 --- a/crates/l10n/Cargo.toml +++ b/crates/l10n/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto-l10n" -version = "3.0.0" +version = "3.1.0" edition = "2024" authors.workspace = true repository.workspace = true diff --git a/crates/shared/Cargo.toml b/crates/shared/Cargo.toml index 3130978..1b64125 100644 --- a/crates/shared/Cargo.toml +++ b/crates/shared/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto-shared" -version = "3.0.0" +version = "3.1.0" edition = "2024" authors.workspace = true repository.workspace = true