From 75d72460ae35126d2d1bced7c08829a834c05d76 Mon Sep 17 00:00:00 2001 From: trisua Date: Thu, 8 May 2025 22:18:04 -0400 Subject: [PATCH] add: user stacks --- Cargo.lock | 8 +- crates/app/Cargo.toml | 2 +- crates/app/src/assets.rs | 8 + crates/app/src/langs/en-US.toml | 9 + crates/app/src/public/html/macros.html | 5 + crates/app/src/public/html/profile/posts.html | 2 +- .../app/src/public/html/profile/settings.html | 1 + crates/app/src/public/html/stacks/list.html | 93 +++++++ crates/app/src/public/html/stacks/manage.html | 237 ++++++++++++++++++ crates/app/src/public/html/stacks/posts.html | 74 ++++++ crates/app/src/routes/api/v1/mod.rs | 29 +++ crates/app/src/routes/api/v1/stacks.rs | 176 +++++++++++++ crates/app/src/routes/pages/mod.rs | 5 + crates/app/src/routes/pages/stacks.rs | 136 ++++++++++ crates/core/Cargo.toml | 2 +- crates/core/src/config.rs | 1 + crates/core/src/database/auth.rs | 10 + crates/core/src/database/common.rs | 1 + crates/core/src/database/drivers/common.rs | 1 + .../database/drivers/sql/create_stacks.sql | 8 + crates/core/src/database/mod.rs | 1 + crates/core/src/database/posts.rs | 49 ++++ crates/core/src/database/stacks.rs | 133 ++++++++++ crates/core/src/model/mod.rs | 1 + crates/core/src/model/permissions.rs | 1 + crates/core/src/model/stacks.rs | 40 +++ crates/l10n/Cargo.toml | 2 +- crates/shared/Cargo.toml | 2 +- 28 files changed, 1028 insertions(+), 9 deletions(-) create mode 100644 crates/app/src/public/html/stacks/list.html create mode 100644 crates/app/src/public/html/stacks/manage.html create mode 100644 crates/app/src/public/html/stacks/posts.html create mode 100644 crates/app/src/routes/api/v1/stacks.rs create mode 100644 crates/app/src/routes/pages/stacks.rs create mode 100644 crates/core/src/database/drivers/sql/create_stacks.sql create mode 100644 crates/core/src/database/stacks.rs create mode 100644 crates/core/src/model/stacks.rs diff --git a/Cargo.lock b/Cargo.lock index 75fc36e..64f33fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3606,7 +3606,7 @@ dependencies = [ [[package]] name = "tetratto" -version = "2.2.0" +version = "2.3.0" dependencies = [ "ammonia", "async-stripe", @@ -3636,7 +3636,7 @@ dependencies = [ [[package]] name = "tetratto-core" -version = "2.2.0" +version = "2.3.0" dependencies = [ "async-recursion", "base16ct", @@ -3660,7 +3660,7 @@ dependencies = [ [[package]] name = "tetratto-l10n" -version = "2.2.0" +version = "2.3.0" dependencies = [ "pathbufd", "serde", @@ -3669,7 +3669,7 @@ dependencies = [ [[package]] name = "tetratto-shared" -version = "2.2.0" +version = "2.3.0" dependencies = [ "ammonia", "chrono", diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 67ff1a5..6a459a8 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto" -version = "2.2.0" +version = "2.3.0" edition = "2024" [features] diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs index e410ed1..93ce067 100644 --- a/crates/app/src/assets.rs +++ b/crates/app/src/assets.rs @@ -99,6 +99,10 @@ pub const CHATS_STREAM: &str = include_str!("./public/html/chats/stream.html"); pub const CHATS_MESSAGE: &str = include_str!("./public/html/chats/message.html"); pub const CHATS_CHANNELS: &str = include_str!("./public/html/chats/channels.html"); +pub const STACKS_LIST: &str = include_str!("./public/html/stacks/list.html"); +pub const STACKS_POSTS: &str = include_str!("./public/html/stacks/posts.html"); +pub const STACKS_MANAGE: &str = include_str!("./public/html/stacks/manage.html"); + // langs pub const LANG_EN_US: &str = include_str!("./langs/en-US.toml"); @@ -289,6 +293,10 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD { write_template!(html_path->"chats/message.html"(crate::assets::CHATS_MESSAGE) --config=config); write_template!(html_path->"chats/channels.html"(crate::assets::CHATS_CHANNELS) --config=config); + write_template!(html_path->"stacks/list.html"(crate::assets::STACKS_LIST) -d "stacks" --config=config); + write_template!(html_path->"stacks/posts.html"(crate::assets::STACKS_POSTS) --config=config); + write_template!(html_path->"stacks/manage.html"(crate::assets::STACKS_MANAGE) --config=config); + html_path } diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index f6109d1..dfffe98 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -164,3 +164,12 @@ version = "1.0.0" "chats:action.add_someone" = "Add someone" "chats:action.kick_member" = "Kick member" "chats:action.mention_user" = "Mention user" + +"stacks:link.stacks" = "Stacks" +"stacks:label.my_stacks" = "My stacks" +"stacks:label.create_new" = "Create new stack" +"stacks:label.change_name" = "Change name" +"stacks:tab.general" = "General" +"stacks:tab.users" = "Users" +"stacks:label.add_user" = "Add user" +"stacks:label.remove" = "Remove" diff --git a/crates/app/src/public/html/macros.html b/crates/app/src/public/html/macros.html index 2ee65c8..5bf711d 100644 --- a/crates/app/src/public/html/macros.html +++ b/crates/app/src/public/html/macros.html @@ -124,6 +124,11 @@ {{ icon "newspaper" }} {{ text "general:link.home" }} + + + {{ icon "layers" }} + {{ text "stacks:link.stacks" }} + {% else %} {{ icon "earth" }} diff --git a/crates/app/src/public/html/profile/posts.html b/crates/app/src/public/html/profile/posts.html index 9eb46bf..26bc551 100644 --- a/crates/app/src/public/html/profile/posts.html +++ b/crates/app/src/public/html/profile/posts.html @@ -48,7 +48,7 @@ profile.settings.allow_anonymous_questions) %} {% endif %} {% endfor %} - {{ components::pagination(page=page, items=posts|length) }} + {{ components::pagination(page=page, items=posts|length, key="%tag=", value=tag) }} {% endblock %} diff --git a/crates/app/src/public/html/profile/settings.html b/crates/app/src/public/html/profile/settings.html index 5d55df7..a3a0299 100644 --- a/crates/app/src/public/html/profile/settings.html +++ b/crates/app/src/public/html/profile/settings.html @@ -59,6 +59,7 @@
  • Use custom CSS on your profile
  • Ability to use community emojis outside of their community (soon)
  • Ability to upload and use gif emojis (soon)
  • +
  • Create infinite stack timelines
  • Become a supporter diff --git a/crates/app/src/public/html/stacks/list.html b/crates/app/src/public/html/stacks/list.html new file mode 100644 index 0000000..c2bd30e --- /dev/null +++ b/crates/app/src/public/html/stacks/list.html @@ -0,0 +1,93 @@ +{% extends "root.html" %} {% block head %} +My stacks - {{ config.name }} +{% endblock %} {% block body %} {{ macros::nav() }} +
    + {{ macros::timelines_nav(selected="stacks") }} {% if user %} +
    +
    + {{ text "stacks:label.create_new" }} +
    + +
    +
    + + +
    + + +
    +
    + {% endif %} + +
    +
    +
    + {{ icon "award" }} + {{ text "stacks:label.my_stacks" }} +
    +
    + + +
    +
    + + +{% endblock %} diff --git a/crates/app/src/public/html/stacks/manage.html b/crates/app/src/public/html/stacks/manage.html new file mode 100644 index 0000000..49020f4 --- /dev/null +++ b/crates/app/src/public/html/stacks/manage.html @@ -0,0 +1,237 @@ +{% extends "root.html" %} {% block head %} +Community settings - {{ config.name }} +{% endblock %} {% block body %} {{ macros::nav() }} +
    + + +
    +
    +
    +
    + Privacy +
    + +
    + +
    +
    + +
    +
    + {{ text "stacks:label.change_name" }} +
    + +
    +
    + + +
    + + +
    +
    +
    + +
    +
    + {{ icon "skull" }} + {{ text "communities:label.danger_zone" }} +
    + +
    + +
    +
    +
    + + + + +
    + + +{% endblock %} diff --git a/crates/app/src/public/html/stacks/posts.html b/crates/app/src/public/html/stacks/posts.html new file mode 100644 index 0000000..8da68b6 --- /dev/null +++ b/crates/app/src/public/html/stacks/posts.html @@ -0,0 +1,74 @@ +{% extends "root.html" %} {% block head %} +{{ stack.name }} - {{ config.name }} +{% endblock %} {% block body %} {{ macros::nav() }} +
    + {{ macros::timelines_nav(selected="stacks") }} +
    +
    +
    + {{ icon "list" }} + {{ stack.name }} +
    + + {% if user and user.id == stack.owner %} + + {{ icon "pencil" }} + {{ text "general:action.manage" }} + + {% endif %} +
    + + +
    + {% if list|length == 0 %} +

    No posts yet! Maybe add a user to this stack!

    + {% endif %} + + {% for post in list %} + {% if post[2].read_access == "Everybody" %} + {% if post[0].context.repost and post[0].context.repost.reposting %} + {{ components::repost(repost=post[3], post=post[0], owner=post[1], secondary=true, community=post[2], show_community=true) }} + {% else %} + {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2]) }} + {% endif %} + {% endif %} + {% endfor %} + + {{ components::pagination(page=page, items=list|length) }} +
    +
    +
    + + +{% endblock %} diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index e44254b..b8d2f71 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -4,6 +4,7 @@ pub mod notifications; pub mod reactions; pub mod reports; pub mod requests; +pub mod stacks; pub mod util; #[cfg(feature = "redis")] @@ -22,6 +23,7 @@ use tetratto_core::model::{ communities_permissions::CommunityPermission, permissions::FinePermission, reactions::AssetType, + stacks::StackPrivacy, }; pub fn routes() -> Router { @@ -320,6 +322,13 @@ pub fn routes() -> Router { "/lookup_emoji", post(communities::emojis::get_emoji_shortcode), ) + // stacks + .route("/stacks", post(stacks::create_request)) + .route("/stacks/{id}/name", post(stacks::update_name_request)) + .route("/stacks/{id}/privacy", post(stacks::update_privacy_request)) + .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)) } #[derive(Deserialize)] @@ -506,3 +515,23 @@ pub struct CreateMessage { pub struct KickMember { pub member: String, } + +#[derive(Deserialize)] +pub struct CreateStack { + pub name: String, +} + +#[derive(Deserialize)] +pub struct UpdateStackName { + pub name: String, +} + +#[derive(Deserialize)] +pub struct UpdateStackPrivacy { + pub privacy: StackPrivacy, +} + +#[derive(Deserialize)] +pub struct AddOrRemoveStackUser { + pub username: String, +} diff --git a/crates/app/src/routes/api/v1/stacks.rs b/crates/app/src/routes/api/v1/stacks.rs new file mode 100644 index 0000000..7bb45dc --- /dev/null +++ b/crates/app/src/routes/api/v1/stacks.rs @@ -0,0 +1,176 @@ +use crate::{State, get_user_from_token}; +use axum::{Extension, Json, extract::Path, response::IntoResponse}; +use axum_extra::extract::CookieJar; +use tetratto_core::model::{stacks::UserStack, ApiReturn, Error}; +use super::{AddOrRemoveStackUser, CreateStack, UpdateStackName, UpdateStackPrivacy}; + +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_stack(UserStack::new(req.name, user.id, Vec::new())) + .await + { + Ok(s) => Json(ApiReturn { + ok: true, + message: "Stack created".to_string(), + payload: s.id, + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_name_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_stack_name(id, user, &req.name).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Stack updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_privacy_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_stack_privacy(id, user, req.privacy).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Stack updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn add_user_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()), + }; + + let other_user = match data.get_user_by_username(&req.username).await { + Ok(c) => c, + Err(e) => return Json(Error::MiscError(e.to_string()).into()), + }; + + // check block status + if data + .get_userblock_by_initiator_receiver(other_user.id, user.id) + .await + .is_ok() + { + return Json(Error::NotAllowed.into()); + } + + // add user + let mut stack = match data.get_stack_by_id(id).await { + Ok(s) => s, + Err(e) => return Json(e.into()), + }; + + stack.users.push(other_user.id); + match data.update_stack_users(id, user, stack.users).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "User added".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn remove_user_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()), + }; + + let mut stack = match data.get_stack_by_id(id).await { + Ok(s) => s, + Err(e) => return Json(e.into()), + }; + + let other_user = match data.get_user_by_username(&req.username).await { + Ok(c) => c, + Err(e) => return Json(Error::MiscError(e.to_string()).into()), + }; + + stack + .users + .remove(match stack.users.iter().position(|x| x == &other_user.id) { + Some(idx) => idx, + None => return Json(Error::GeneralNotFound("user".to_string()).into()), + }); + + match data.update_stack_users(id, user, stack.users).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "User removed".to_string(), + payload: (), + }), + 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_stack(id, &user).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Stack deleted".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs index d931867..4ace446 100644 --- a/crates/app/src/routes/pages/mod.rs +++ b/crates/app/src/routes/pages/mod.rs @@ -3,6 +3,7 @@ pub mod communities; pub mod misc; pub mod mod_panel; pub mod profile; +pub mod stacks; #[cfg(feature = "redis")] pub mod chats; @@ -104,6 +105,10 @@ pub fn routes() -> Router { "/chats/{community}/{channel}/_channels", get(chats::channels_request), ) + // stacks + .route("/stacks", get(stacks::list_request)) + .route("/stacks/{id}", get(stacks::posts_request)) + .route("/stacks/{id}/manage", get(stacks::manage_request)) } pub async fn render_error( diff --git a/crates/app/src/routes/pages/stacks.rs b/crates/app/src/routes/pages/stacks.rs new file mode 100644 index 0000000..75eb39d --- /dev/null +++ b/crates/app/src/routes/pages/stacks.rs @@ -0,0 +1,136 @@ +use axum::{ + extract::{Path, Query}, + response::{Html, IntoResponse}, + Extension, +}; +use axum_extra::extract::CookieJar; +use tetratto_core::model::{permissions::FinePermission, stacks::StackPrivacy, Error, auth::User}; +use crate::{assets::initial_context, get_lang, get_user_from_token, State}; +use super::{render_error, PaginatedQuery}; + +/// `/stacks` +pub async fn list_request(jar: CookieJar, Extension(data): Extension) -> impl IntoResponse { + let data = data.read().await; + let user = match get_user_from_token!(jar, data.0) { + Some(ua) => ua, + None => { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &None).await, + )); + } + }; + + let list = match data.0.get_stacks_by_owner(user.id).await { + Ok(p) => p, + 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("list", &list); + + // return + Ok(Html(data.1.render("stacks/list.html", &context).unwrap())) +} + +/// `/stacks/{id}` +pub async fn posts_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, + Query(req): Query, +) -> impl IntoResponse { + let data = data.read().await; + let user = match get_user_from_token!(jar, data.0) { + Some(ua) => ua, + None => { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &None).await, + )); + } + }; + + let stack = match data.0.get_stack_by_id(id).await { + Ok(s) => s, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }; + + if stack.privacy == StackPrivacy::Private + && user.id != stack.owner + && !user.permissions.check(FinePermission::MANAGE_STACKS) + { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &None).await, + )); + } + + let ignore_users = data.0.get_userblocks_receivers(user.id).await; + let list = match data.0.get_posts_from_stack(stack.id, 12, req.page).await { + Ok(l) => match data + .0 + .fill_posts_with_community(l, user.id, &ignore_users) + .await + { + Ok(l) => l, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }, + 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("page", &req.page); + context.insert("stack", &stack); + context.insert("list", &list); + + // return + Ok(Html(data.1.render("stacks/posts.html", &context).unwrap())) +} + +/// `/stacks/{id}/manage` +pub async fn manage_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, +) -> impl IntoResponse { + let data = data.read().await; + let user = match get_user_from_token!(jar, data.0) { + Some(ua) => ua, + None => { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &None).await, + )); + } + }; + + let stack = match data.0.get_stack_by_id(id).await { + Ok(s) => s, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }; + + if user.id != stack.owner && !user.permissions.check(FinePermission::MANAGE_STACKS) { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &None).await, + )); + } + + let mut users: Vec = Vec::new(); + + for uid in &stack.users { + users.push(match data.0.get_user_by_id(uid.to_owned()).await { + Ok(ua) => ua, + 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("stack", &stack); + context.insert("users", &users); + + // return + Ok(Html(data.1.render("stacks/manage.html", &context).unwrap())) +} diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 1169c31..18cfc7d 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto-core" -version = "2.2.0" +version = "2.3.0" edition = "2024" [features] diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index d154334..4469bce 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -312,6 +312,7 @@ fn default_banned_usernames() -> Vec { "post".to_string(), "void".to_string(), "anonymous".to_string(), + "stack".to_string(), ] } diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs index ec869ff..1f7abb1 100644 --- a/crates/core/src/database/auth.rs +++ b/crates/core/src/database/auth.rs @@ -127,6 +127,16 @@ impl DataManager { return Err(Error::MiscError("This username cannot be used".to_string())); } + if data.username.contains(" ") { + return Err(Error::MiscError("Name cannot contain spaces".to_string())); + } else if data.username.contains("%") { + return Err(Error::MiscError("Name cannot contain \"%\"".to_string())); + } else if data.username.contains("?") { + return Err(Error::MiscError("Name cannot contain \"?\"".to_string())); + } else if data.username.contains("&") { + return Err(Error::MiscError("Name cannot contain \"&\"".to_string())); + } + // make sure username isn't taken if self.get_user_by_username(&data.username).await.is_ok() { return Err(Error::UsernameInUse); diff --git a/crates/core/src/database/common.rs b/crates/core/src/database/common.rs index 1001e34..a500067 100644 --- a/crates/core/src/database/common.rs +++ b/crates/core/src/database/common.rs @@ -32,6 +32,7 @@ impl DataManager { execute!(&conn, common::CREATE_TABLE_MESSAGES).unwrap(); execute!(&conn, common::CREATE_TABLE_UPLOADS).unwrap(); execute!(&conn, common::CREATE_TABLE_EMOJIS).unwrap(); + execute!(&conn, common::CREATE_TABLE_STACKS).unwrap(); self.2 .set("atto.active_connections:users".to_string(), "0".to_string()) diff --git a/crates/core/src/database/drivers/common.rs b/crates/core/src/database/drivers/common.rs index fc030e2..6c67bb0 100644 --- a/crates/core/src/database/drivers/common.rs +++ b/crates/core/src/database/drivers/common.rs @@ -17,3 +17,4 @@ pub const CREATE_TABLE_CHANNELS: &str = include_str!("./sql/create_channels.sql" 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"); diff --git a/crates/core/src/database/drivers/sql/create_stacks.sql b/crates/core/src/database/drivers/sql/create_stacks.sql new file mode 100644 index 0000000..6ec6e28 --- /dev/null +++ b/crates/core/src/database/drivers/sql/create_stacks.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS stacks ( + id BIGINT NOT NULL PRIMARY KEY, + created BIGINT NOT NULL, + owner BIGINT NOT NULL, + name TEXT NOT NULL, + users TEXT NOT NULL, + privacy TEXT NOT NULL +) diff --git a/crates/core/src/database/mod.rs b/crates/core/src/database/mod.rs index 62dac40..4cabd4e 100644 --- a/crates/core/src/database/mod.rs +++ b/crates/core/src/database/mod.rs @@ -14,6 +14,7 @@ mod questions; mod reactions; mod reports; mod requests; +mod stacks; mod uploads; mod user_warnings; mod userblocks; diff --git a/crates/core/src/database/posts.rs b/crates/core/src/database/posts.rs index 1941f4e..0c2dc00 100644 --- a/crates/core/src/database/posts.rs +++ b/crates/core/src/database/posts.rs @@ -732,6 +732,55 @@ impl DataManager { Ok(res.unwrap()) } + /// Get posts from all users in the given stack. + /// + /// # Arguments + /// * `id` - the ID of the stack + /// * `batch` - the limit of posts in each page + /// * `page` - the page number + pub async fn get_posts_from_stack( + &self, + id: usize, + batch: usize, + page: usize, + ) -> Result> { + let users = self.get_stack_by_id(id).await?.users; + let mut users = users.iter(); + + let first = match users.next() { + Some(f) => f, + None => return Ok(Vec::new()), + }; + + let mut query_string: String = String::new(); + + for user in users { + query_string.push_str(&format!(" OR owner = {}", user)); + } + + // ... + let conn = match self.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = query_rows!( + &conn, + &format!( + "SELECT * FROM posts WHERE (owner = {} {query_string}) AND replying_to = 0 ORDER BY created DESC LIMIT $1 OFFSET $2", + first + ), + &[&(batch as i64), &((page * batch) as i64)], + |x| { Self::get_post_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("post".to_string())); + } + + Ok(res.unwrap()) + } + /// Check if the given `uid` can post in the given `community`. pub async fn check_can_post(&self, community: &Community, uid: usize) -> bool { match community.write_access { diff --git a/crates/core/src/database/stacks.rs b/crates/core/src/database/stacks.rs new file mode 100644 index 0000000..7f08232 --- /dev/null +++ b/crates/core/src/database/stacks.rs @@ -0,0 +1,133 @@ +use super::*; +use crate::cache::Cache; +use crate::model::{ + Error, Result, + auth::User, + permissions::FinePermission, + stacks::{StackPrivacy, UserStack}, +}; +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 [`UserStack`] from an SQL row. + pub(crate) fn get_stack_from_row( + #[cfg(feature = "sqlite")] x: &Row<'_>, + #[cfg(feature = "postgres")] x: &Row, + ) -> UserStack { + UserStack { + id: get!(x->0(i64)) as usize, + created: get!(x->1(i64)) as usize, + owner: get!(x->2(i64)) as usize, + name: get!(x->3(String)), + users: serde_json::from_str(&get!(x->4(String))).unwrap(), + privacy: serde_json::from_str(&get!(x->5(String))).unwrap(), + } + } + + auto_method!(get_stack_by_id(usize as i64)@get_stack_from_row -> "SELECT * FROM stacks WHERE id = $1" --name="stack" --returns=UserStack --cache-key-tmpl="atto.stack:{}"); + + /// Get all stacks by user. + /// + /// # Arguments + /// * `id` - the ID of the user to fetch stacks for + pub async fn get_stacks_by_owner(&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 stacks WHERE owner = $1 ORDER BY name ASC", + &[&(id as i64)], + |x| { Self::get_stack_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("stack".to_string())); + } + + Ok(res.unwrap()) + } + + /// Create a new stack in the database. + /// + /// # Arguments + /// * `data` - a mock [`UserStack`] object to insert + pub async fn create_stack(&self, data: UserStack) -> Result { + // check number of stacks + let owner = self.get_user_by_id(data.owner).await?; + + if !owner.permissions.check(FinePermission::SUPPORTER) { + let stacks = self.get_stacks_by_owner(data.owner).await?; + let maximum_count = 5; + + if stacks.len() >= maximum_count { + return Err(Error::MiscError( + "You already have the maximum number of stacks you can have".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 stacks VALUES ($1, $2, $3, $4, $5, $6)", + params![ + &(data.id as i64), + &(data.created as i64), + &(data.owner as i64), + &data.name, + &serde_json::to_string(&data.users).unwrap(), + &serde_json::to_string(&data.privacy).unwrap(), + ] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + Ok(data) + } + + pub async fn delete_stack(&self, id: usize, user: &User) -> Result<()> { + let stack = self.get_stack_by_id(id).await?; + + // check user permission + if user.id != stack.owner { + if !user.permissions.check(FinePermission::MANAGE_STACKS) { + return Err(Error::NotAllowed); + } + } + + // ... + let conn = match self.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!(&conn, "DELETE FROM stacks WHERE id = $1", &[&(id as i64)]); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + self.2.remove(format!("atto.stack:{}", id)).await; + Ok(()) + } + + auto_method!(update_stack_name(&str)@get_stack_by_id:MANAGE_STACKS -> "UPDATE stacks SET name = $1 WHERE id = $2" --cache-key-tmpl="atto.stack:{}"); + auto_method!(update_stack_privacy(StackPrivacy)@get_stack_by_id:MANAGE_STACKS -> "UPDATE stacks SET privacy = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.stack:{}"); + auto_method!(update_stack_users(Vec)@get_stack_by_id:MANAGE_STACKS -> "UPDATE stacks SET users = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.stack:{}"); +} diff --git a/crates/core/src/model/mod.rs b/crates/core/src/model/mod.rs index 77ebb88..4ac7d83 100644 --- a/crates/core/src/model/mod.rs +++ b/crates/core/src/model/mod.rs @@ -6,6 +6,7 @@ pub mod oauth; pub mod permissions; pub mod reactions; pub mod requests; +pub mod stacks; pub mod uploads; #[cfg(feature = "redis")] diff --git a/crates/core/src/model/permissions.rs b/crates/core/src/model/permissions.rs index f90e2d6..207bd55 100644 --- a/crates/core/src/model/permissions.rs +++ b/crates/core/src/model/permissions.rs @@ -34,6 +34,7 @@ bitflags! { const MANAGE_MESSAGES = 1 << 23; const MANAGE_UPLOADS = 1 << 24; const MANAGE_EMOJIS = 1 << 25; + const MANAGE_STACKS = 1 << 26; const _ = !0; } diff --git a/crates/core/src/model/stacks.rs b/crates/core/src/model/stacks.rs new file mode 100644 index 0000000..afe7e3c --- /dev/null +++ b/crates/core/src/model/stacks.rs @@ -0,0 +1,40 @@ +use serde::{Serialize, Deserialize}; +use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp}; + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] +pub enum StackPrivacy { + /// Can be viewed by anyone. + Public, + /// Can only be viewed by the stack's owner (and users with `MANAGE_STACKS`). + Private, +} + +impl Default for StackPrivacy { + fn default() -> Self { + Self::Private + } +} + +#[derive(Serialize, Deserialize)] +pub struct UserStack { + pub id: usize, + pub created: usize, + pub owner: usize, + pub name: String, + pub users: Vec, + pub privacy: StackPrivacy, +} + +impl UserStack { + /// Create a new [`UserStack`]. + pub fn new(name: String, owner: usize, users: Vec) -> Self { + Self { + id: Snowflake::new().to_string().parse::().unwrap(), + created: unix_epoch_timestamp() as usize, + owner, + name, + users, + privacy: StackPrivacy::default(), + } + } +} diff --git a/crates/l10n/Cargo.toml b/crates/l10n/Cargo.toml index 8a55128..f8d3072 100644 --- a/crates/l10n/Cargo.toml +++ b/crates/l10n/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto-l10n" -version = "2.2.0" +version = "2.3.0" edition = "2024" authors.workspace = true repository.workspace = true diff --git a/crates/shared/Cargo.toml b/crates/shared/Cargo.toml index 22fc7b0..f83f96f 100644 --- a/crates/shared/Cargo.toml +++ b/crates/shared/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto-shared" -version = "2.2.0" +version = "2.3.0" edition = "2024" authors.workspace = true repository.workspace = true