From 56cea8393301730a4f815825b733c349aa78e138 Mon Sep 17 00:00:00 2001 From: trisua Date: Sun, 15 Jun 2025 16:09:02 -0400 Subject: [PATCH] add: circle stacks --- Cargo.lock | 14 +- README.md | 2 +- crates/app/Cargo.toml | 4 +- crates/app/src/image.rs | 7 +- .../public/html/communities/create_post.lisp | 41 ++++- crates/app/src/public/html/components.lisp | 9 +- crates/app/src/public/html/post/post.lisp | 1 + .../app/src/public/html/profile/settings.lisp | 4 +- crates/app/src/public/html/stacks/feed.lisp | 36 +++- crates/app/src/public/html/stacks/manage.lisp | 6 +- crates/app/src/public/js/me.js | 12 +- .../src/routes/api/v1/communities/posts.rs | 33 ++-- crates/app/src/routes/api/v1/mod.rs | 3 + crates/app/src/routes/api/v1/stacks.rs | 33 +++- crates/app/src/routes/pages/communities.rs | 44 ++++- crates/app/src/routes/pages/profile.rs | 2 +- crates/app/src/routes/pages/stacks.rs | 3 +- crates/core/Cargo.toml | 4 +- crates/core/src/database/drafts.rs | 2 +- crates/core/src/database/posts.rs | 167 +++++++++++++++++- crates/core/src/database/stacks.rs | 68 ++++--- crates/core/src/model/communities.rs | 8 +- crates/core/src/model/oauth.rs | 4 - crates/core/src/model/stacks.rs | 12 +- crates/l10n/Cargo.toml | 2 +- crates/shared/Cargo.toml | 2 +- sql_changes/posts_circle.sql | 3 + 27 files changed, 419 insertions(+), 107 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fd8c94a..48e412e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2621,9 +2621,9 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] name = "reqwest" -version = "0.12.19" +version = "0.12.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2f8e5513d63f2e5b386eb5106dc67eaf3f84e95258e210489136b8b92ad6119" +checksum = "eabf4c97d9130e2bf606614eb937e86edac8292eaa6f422f995d7e8de1eb1813" dependencies = [ "base64 0.22.1", "bytes", @@ -2638,12 +2638,10 @@ dependencies = [ "hyper-rustls", "hyper-tls 0.6.0", "hyper-util", - "ipnet", "js-sys", "log", "mime", "native-tls", - "once_cell", "percent-encoding", "pin-project-lite", "rustls-pki-types", @@ -3233,7 +3231,7 @@ dependencies = [ [[package]] name = "tetratto" -version = "7.0.0" +version = "8.0.0" dependencies = [ "ammonia", "async-stripe", @@ -3264,7 +3262,7 @@ dependencies = [ [[package]] name = "tetratto-core" -version = "7.0.0" +version = "8.0.0" dependencies = [ "async-recursion", "base16ct", @@ -3286,7 +3284,7 @@ dependencies = [ [[package]] name = "tetratto-l10n" -version = "7.0.0" +version = "8.0.0" dependencies = [ "pathbufd", "serde", @@ -3295,7 +3293,7 @@ dependencies = [ [[package]] name = "tetratto-shared" -version = "7.0.0" +version = "8.0.0" dependencies = [ "ammonia", "chrono", diff --git a/README.md b/README.md index 87e0a38..050f8ce 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Make sure you have AT LEAST rustc version 1.89.0-nightly. Everything Tetratto needs will be built into the main binary. You can build Tetratto with the following command: ```bash -cargo build +cargo build -r ``` Tetratto **requires** a PostgreSQL server, as well as a Redis (or Redis fork) instance. diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 333bff5..706ee8d 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto" -version = "7.0.0" +version = "8.0.0" edition = "2024" [dependencies] @@ -19,7 +19,7 @@ tetratto-core = { path = "../core" } tetratto-l10n = { path = "../l10n" } image = "0.25.6" -reqwest = { version = "0.12.19", features = ["json", "stream"] } +reqwest = { version = "0.12.20", features = ["json", "stream"] } regex = "1.11.1" serde_json = "1.0.140" mime_guess = "2.0.5" diff --git a/crates/app/src/image.rs b/crates/app/src/image.rs index 75b231c..a6fd32e 100644 --- a/crates/app/src/image.rs +++ b/crates/app/src/image.rs @@ -127,11 +127,8 @@ where 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(), - )); + Err(e) => { + return Err((StatusCode::BAD_REQUEST, e.to_string())); } }; diff --git a/crates/app/src/public/html/communities/create_post.lisp b/crates/app/src/public/html/communities/create_post.lisp index 51b7ebf..754e915 100644 --- a/crates/app/src/public/html/communities/create_post.lisp +++ b/crates/app/src/public/html/communities/create_post.lisp @@ -97,6 +97,13 @@ ("value" "{{ community.id }}") ("selected" "{% if selected_community == community.id -%}true{% else %}false{%- endif %}") (text "{% if community.context.display_name -%} {{ community.context.display_name }} {% else %} {{ community.title }} {%- endif %}")) + (text "{% endfor %}") + (text "{% for stack in stacks %}") + (option + ("value" "{{ stack.id }}") + ("selected" "{% if selected_stack == stack.id -%}true{% else %}false{%- endif %}") + ("is_stack" "true") + (text "{{ stack.name }} (circle)")) (text "{% endfor %}"))) (form ("class" "card flex flex-col gap-2") @@ -184,13 +191,19 @@ } } + const is_selected_stack = document.getElementById( + \"community_to_post_to\", + ).selectedOptions[0].getAttribute(\"is_stack\") === \"true\"; + const selected_community = document.getElementById( + \"community_to_post_to\", + ).selectedOptions[0].value; + body.append( \"body\", JSON.stringify({ content: e.target.content.value, - community: document.getElementById( - \"community_to_post_to\", - ).selectedOptions[0].value, + community: !is_selected_stack ? selected_community : \"0\", + stack: is_selected_stack ? selected_community : \"0\", poll: poll_data[1], title: e.target.title.value, }), @@ -316,12 +329,15 @@ (text "{% else %}") (script (text "async function create_post_from_form(e) { + e.preventDefault(); const id = await trigger(\"me::repost\", [ \"{{ quoting[1].id }}\", e.target.content.value, document.getElementById(\"community_to_post_to\") .selectedOptions[0].value, false, + document.getElementById(\"community_to_post_to\") + .selectedOptions[0].getAttribute(\"is_stack\") === \"true\", ]); // update settings @@ -394,27 +410,34 @@ (text "{%- endif %}")) (script - (text "const town_square = \"{{ config.town_square }}\"; + (text "(() => {const town_square = \"{{ config.town_square }}\"; const user_id = \"{{ user.id }}\"; - function update_community_avatar(e) { + window.update_community_avatar = (e) => { const element = e.target.parentElement.querySelector(\".avatar\"); + const is_stack = e.target.selectedOptions[0].getAttribute(\"is_stack\") === \"true\"; const id = e.target.selectedOptions[0].value; element.setAttribute(\"title\", id); element.setAttribute(\"alt\", `${id}'s avatar`); - if (id === town_square) { + if (id === town_square || is_stack) { element.src = `/api/v1/auth/user/${user_id}/avatar?selector_type=id`; } else { element.src = `/api/v1/communities/${id}/avatar`; } } - function check_community_supports_title(e) { + window.check_community_supports_title = async (e) => { const element = document.getElementById(\"title_field\"); + const is_stack = e.target.selectedOptions[0].getAttribute(\"is_stack\") === \"true\"; const id = e.target.selectedOptions[0].value; + if (is_stack) { + element.classList.add(\"hidden\"); + return; + } + fetch(`/api/v1/communities/${id}/supports_titles`) .then((res) => res.json()) .then((res) => { @@ -436,7 +459,7 @@ }); }, 150); - async function cancel_create_post() { + window.cancel_create_post = async () => { if ( !(await trigger(\"atto::confirm\", [ \"Are you sure you would like to do this? Your post content will be lost.\", @@ -446,6 +469,6 @@ } window.history.back(); - }")) + }})();")) (text "{% endblock %}") diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index 239d376..1c87a44 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -173,8 +173,13 @@ ("class" "flex items-center") ("style" "color: var(--color-primary)") (text "{{ icon \"square-asterisk\" }}")) - (text "{%- endif %}") - (text "{% if community and community.is_forge -%} {% if post.is_open -%}") + (text "{%- endif %} {% if post.stack -%}") + (span + ("title" "Posted to a stack you're in") + ("class" "flex items-center") + ("style" "color: var(--color-primary)") + (text "{{ icon \"layers\" }}")) + (text "{%- endif %} {% if community and community.is_forge -%} {% if post.is_open -%}") (span ("title" "Open") ("class" "flex items-center green") diff --git a/crates/app/src/public/html/post/post.lisp b/crates/app/src/public/html/post/post.lisp index 705cec2..22f64e9 100644 --- a/crates/app/src/public/html/post/post.lisp +++ b/crates/app/src/public/html/post/post.lisp @@ -298,6 +298,7 @@ JSON.stringify({ content: e.target.content.value, community: \"{{ community.id }}\", + stack: \"{{ post.stack }}\", replying_to: \"{{ post.id }}\", poll: poll_data[1], }), diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index 33f6117..a89286f 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -581,7 +581,9 @@ (li (text "Ability to create more than 1 app")) (li - (text "Create up to 10 stack blocks"))) + (text "Create up to 10 stack blocks")) + (li + (text "Add unlimited users to stacks"))) (a ("href" "{{ config.stripe.payment_link }}?client_reference_id={{ user.id }}") ("class" "button") diff --git a/crates/app/src/public/html/stacks/feed.lisp b/crates/app/src/public/html/stacks/feed.lisp index a723066..0317469 100644 --- a/crates/app/src/public/html/stacks/feed.lisp +++ b/crates/app/src/public/html/stacks/feed.lisp @@ -17,14 +17,27 @@ (text "{{ components::avatar(username=stack.owner, selector_type=\"id\") }}")) (span (text "{{ stack.name }}"))) - (text "{% if user and user.id == stack.owner -%}") - (a - ("href" "/stacks/{{ stack.id }}/manage") - ("class" "button lowered small") - (text "{{ icon \"pencil\" }}") - (span - (text "{{ text \"general:action.manage\" }}"))) - (text "{%- endif %}")) + (div + ("class" "flex gap-2") + (text "{% if stack.mode == 'Circle' -%}") + ; post button for circle stacks + (a + ("href" "/communities/intents/post?stack={{ stack.id }}") + ("class" "button lowered small") + (text "{{ icon \"plus\" }}") + (span + (text "{{ text \"general:action.post\" }}"))) + (text "{%- endif %}") + + (text "{% if user and user.id == stack.owner -%}") + ; manage button for stack owner only + (a + ("href" "/stacks/{{ stack.id }}/manage") + ("class" "button lowered small") + (text "{{ icon \"pencil\" }}") + (span + (text "{{ text \"general:action.manage\" }}"))) + (text "{%- endif %}"))) (div ("class" "card w-full flex flex-col gap-2") (text "{% if list|length == 0 -%}") @@ -37,6 +50,7 @@ (text "{%- endif %}") (text "{% if stack.mode == 'BlockList' -%}") + ; block button + user list for blocklist only (text "{% if not is_blocked -%}") (button ("onclick" "block_all()") @@ -50,6 +64,12 @@ ("class" "flex gap-2 flex-wrap w-full") (text "{% for user in list %} {{ components::user_plate(user=user, secondary=true) }} {% endfor %}")) (text "{% else %}") + ; user icons for circle stack + (text "{% if stack.mode == 'Circle' -%} {% for user in stack.users %}") + (text "{{ components::avatar(username=user, selector_type=\"id\", size=\"24px\") }}") + (text "{% endfor %} {%- endif %}") + + ; posts for all stacks except blocklist (text "{% for post in list %} {% if post[2].read_access == \"Everybody\" -%} {% if post[0].context.repost and post[0].context.repost.reposting -%} diff --git a/crates/app/src/public/html/stacks/manage.lisp b/crates/app/src/public/html/stacks/manage.lisp index ef608c5..95f8545 100644 --- a/crates/app/src/public/html/stacks/manage.lisp +++ b/crates/app/src/public/html/stacks/manage.lisp @@ -67,7 +67,11 @@ (option ("value" "BlockList") ("selected" "{% if stack.mode == 'BlockList' -%}true{% else %}false{%- endif %}") - (text "Block list"))))) + (text "Block list")) + (option + ("value" "Circle") + ("selected" "{% if stack.mode == 'Circle' -%}true{% else %}false{%- endif %}") + (text "Circle"))))) (div ("class" "card-nest") ("ui_ident" "sort") diff --git a/crates/app/src/public/js/me.js b/crates/app/src/public/js/me.js index 1a91bcd..e8f4ae2 100644 --- a/crates/app/src/public/js/me.js +++ b/crates/app/src/public/js/me.js @@ -259,7 +259,14 @@ self.define( "repost", - (_, id, content, community, do_not_redirect = false) => { + ( + _, + id, + content, + community, + do_not_redirect = false, + is_stack = false, + ) => { return new Promise((resolve, _) => { fetch(`/api/v1/posts/${id}/repost`, { method: "POST", @@ -268,7 +275,8 @@ }, body: JSON.stringify({ content, - community, + community: !is_stack ? community : "0", + stack: is_stack ? community : "0", }), }) .then((res) => res.json()) diff --git a/crates/app/src/routes/api/v1/communities/posts.rs b/crates/app/src/routes/api/v1/communities/posts.rs index 32f4b77..81a1fae 100644 --- a/crates/app/src/routes/api/v1/communities/posts.rs +++ b/crates/app/src/routes/api/v1/communities/posts.rs @@ -124,6 +124,10 @@ pub async fn create_request( }; } else { props.title = req.title; + props.stack = match req.stack.parse::() { + Ok(x) => x, + Err(e) => return Json(Error::MiscError(e.to_string()).into()), + }; } // check sizes @@ -197,18 +201,23 @@ pub async fn create_repost_request( None => return Json(Error::NotAllowed.into()), }; - match data - .create_post(Post::repost( - req.content, - match req.community.parse::() { - Ok(x) => x, - Err(e) => return Json(Error::MiscError(e.to_string()).into()), - }, - user.id, - id, - )) - .await - { + let mut props = Post::repost( + req.content, + match req.community.parse::() { + Ok(x) => x, + Err(e) => return Json(Error::MiscError(e.to_string()).into()), + }, + user.id, + id, + ); + + props.stack = match req.stack.parse::() { + Ok(x) => x, + Err(e) => return Json(Error::MiscError(e.to_string()).into()), + }; + + // ... + match data.create_post(props).await { Ok(id) => Json(ApiReturn { ok: true, message: "Post reposted".to_string(), diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 5c54aa9..80212b1 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -616,12 +616,15 @@ pub struct CreatePost { pub poll: Option, #[serde(default)] pub title: String, + #[serde(default)] + pub stack: String, } #[derive(Deserialize)] pub struct CreateRepost { pub content: String, pub community: String, + pub stack: String, } #[derive(Deserialize)] diff --git a/crates/app/src/routes/api/v1/stacks.rs b/crates/app/src/routes/api/v1/stacks.rs index 94b2c5a..ee4e5b7 100644 --- a/crates/app/src/routes/api/v1/stacks.rs +++ b/crates/app/src/routes/api/v1/stacks.rs @@ -5,11 +5,14 @@ use axum::{ Extension, Json, }; use axum_extra::extract::CookieJar; -use tetratto_core::model::{ - oauth, - permissions::FinePermission, - stacks::{StackBlock, StackPrivacy, UserStack}, - ApiReturn, Error, +use tetratto_core::{ + model::{ + oauth, + permissions::FinePermission, + stacks::{StackBlock, StackMode, StackPrivacy, UserStack}, + ApiReturn, Error, + }, + DataManager, }; use super::{ AddOrRemoveStackUser, CreateStack, UpdateStackMode, UpdateStackName, UpdateStackPrivacy, @@ -161,6 +164,25 @@ pub async fn add_user_request( }; stack.users.push(other_user.id); + + // check number of stacks + let owner = match data.get_user_by_id(stack.owner).await { + Ok(ua) => ua, + Err(e) => return Json(e.into()), + }; + + if !owner.permissions.check(FinePermission::SUPPORTER) { + if stack.users.len() >= DataManager::MAXIMUM_FREE_STACK_USERS { + return Json( + Error::MiscError( + "This stack already has the maximum users it can have".to_string(), + ) + .into(), + ); + } + } + + // ... match data.update_stack_users(id, &user, stack.users).await { Ok(_) => Json(ApiReturn { ok: true, @@ -250,6 +272,7 @@ pub async fn get_users_request( if stack.privacy == StackPrivacy::Private && user.id != stack.owner + && !(stack.mode == StackMode::Circle && stack.users.contains(&user.id)) && !user.permissions.check(FinePermission::MANAGE_STACKS) { return Json(Error::NotAllowed.into()); diff --git a/crates/app/src/routes/pages/communities.rs b/crates/app/src/routes/pages/communities.rs index d2363b8..556728e 100644 --- a/crates/app/src/routes/pages/communities.rs +++ b/crates/app/src/routes/pages/communities.rs @@ -11,8 +11,12 @@ use axum_extra::extract::CookieJar; use serde::Deserialize; use tera::Context; use tetratto_core::model::{ - auth::User, communities::Community, communities_permissions::CommunityPermission, - permissions::FinePermission, Error, + auth::User, + communities::Community, + communities_permissions::CommunityPermission, + permissions::FinePermission, + stacks::{StackMode, UserStack}, + Error, }; #[macro_export] @@ -245,6 +249,8 @@ pub struct CreatePostProps { #[serde(default)] pub community: usize, #[serde(default)] + pub stack: usize, + #[serde(default)] pub from_draft: usize, #[serde(default)] pub quote: usize, @@ -286,6 +292,16 @@ pub async fn create_post_request( communities.push(community) } + let stacks = match data.0.get_stacks_by_user(user.id).await { + Ok(s) => s, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }; + + let stacks: Vec<&UserStack> = stacks + .iter() + .filter(|x| x.mode == StackMode::Circle) + .collect(); + // get draft let draft = if props.from_draft != 0 { match data.0.get_draft_by_id(props.from_draft).await { @@ -326,8 +342,10 @@ pub async fn create_post_request( context.insert("draft", &draft); context.insert("drafts", &drafts); + context.insert("stacks", &stacks); context.insert("quoting", "ing); context.insert("communities", &communities); + context.insert("selected_stack", &props.stack); context.insert("selected_community", &props.community); // return @@ -663,6 +681,28 @@ pub async fn post_request( } } + // check stack + if post.stack != 0 { + let stack = match data.0.get_stack_by_id(post.stack).await { + Ok(c) => c, + Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), + }; + + if let Some(ref ua) = user { + if (stack.owner != ua.id) && !stack.users.contains(&ua.id) { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &user).await, + )); + } + } else { + // we MUST be authenticated to view posts in a stack + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &user).await, + )); + } + } + + // ... let community = match data.0.get_community_by_id(post.community).await { Ok(c) => c, Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), diff --git a/crates/app/src/routes/pages/profile.rs b/crates/app/src/routes/pages/profile.rs index 2713b26..cd780e4 100644 --- a/crates/app/src/routes/pages/profile.rs +++ b/crates/app/src/routes/pages/profile.rs @@ -50,7 +50,7 @@ pub async fn settings_request( } }; - let stacks = match data.0.get_stacks_by_owner(profile.id).await { + let stacks = match data.0.get_stacks_by_user(profile.id).await { Ok(ua) => ua, Err(e) => { return Err(Html(render_error(e, &jar, &data, &None).await)); diff --git a/crates/app/src/routes/pages/stacks.rs b/crates/app/src/routes/pages/stacks.rs index 656fee3..822b9b7 100644 --- a/crates/app/src/routes/pages/stacks.rs +++ b/crates/app/src/routes/pages/stacks.rs @@ -25,7 +25,7 @@ pub async fn list_request(jar: CookieJar, Extension(data): Extension) -> } }; - let list = match data.0.get_stacks_by_owner(user.id).await { + let list = match data.0.get_stacks_by_user(user.id).await { Ok(p) => p, Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), }; @@ -63,6 +63,7 @@ pub async fn feed_request( if stack.privacy == StackPrivacy::Private && user.id != stack.owner + && !(stack.mode == StackMode::Circle && stack.users.contains(&user.id)) && !user.permissions.check(FinePermission::MANAGE_STACKS) { return Err(Html( diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 87988b0..1a208f5 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto-core" -version = "7.0.0" +version = "8.0.0" edition = "2024" [dependencies] @@ -11,7 +11,7 @@ tetratto-shared = { path = "../shared" } tetratto-l10n = { path = "../l10n" } serde_json = "1.0.140" totp-rs = { version = "5.7.0", features = ["qr", "gen_secret"] } -reqwest = { version = "0.12.19", features = ["json"] } +reqwest = { version = "0.12.20", features = ["json"] } bitflags = "2.9.1" async-recursion = "1.1.1" md-5 = "0.10.6" diff --git a/crates/core/src/database/drafts.rs b/crates/core/src/database/drafts.rs index a573bcc..95c2acf 100644 --- a/crates/core/src/database/drafts.rs +++ b/crates/core/src/database/drafts.rs @@ -95,7 +95,7 @@ impl DataManager { 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 stacks = self.get_stacks_by_user(data.owner).await?; if stacks.len() >= Self::MAXIMUM_FREE_DRAFTS { return Err(Error::MiscError( diff --git a/crates/core/src/database/posts.rs b/crates/core/src/database/posts.rs index 1853e98..0d3f6dd 100644 --- a/crates/core/src/database/posts.rs +++ b/crates/core/src/database/posts.rs @@ -5,7 +5,7 @@ use crate::model::auth::Notification; use crate::model::communities::{Poll, Question}; use crate::model::communities_permissions::CommunityPermission; use crate::model::moderation::AuditLogEntry; -use crate::model::stacks::StackSort; +use crate::model::stacks::{StackMode, StackSort, UserStack}; use crate::model::{ Error, Result, auth::User, @@ -25,6 +25,7 @@ pub type FullPost = ( Option<(User, Post)>, Option<(Question, User)>, Option<(Poll, bool, bool)>, + Option, ); macro_rules! private_post_replying { @@ -114,7 +115,7 @@ impl DataManager { poll_id: get!(x->13(i64)) as usize, title: get!(x->14(String)), is_open: get!(x->15(i32)) as i8 == 1, - circle: get!(x->16(i64)) as usize, + stack: get!(x->16(i64)) as usize, } } @@ -275,6 +276,39 @@ impl DataManager { } } + /// Get the stack of the given post (if some). + /// + /// # Returns + /// `(can view post, stack)` + pub async fn get_post_stack( + &self, + seen_stacks: &mut HashMap, + post: &Post, + as_user_id: usize, + ) -> (bool, Option) { + if post.stack != 0 { + if let Some(s) = seen_stacks.get(&post.stack) { + ( + (s.owner == as_user_id) | s.users.contains(&as_user_id), + Some(s.to_owned()), + ) + } else { + let s = match self.get_stack_by_id(post.stack).await { + Ok(s) => s, + Err(_) => return (true, None), + }; + + seen_stacks.insert(s.id, s.to_owned()); + ( + (s.owner == as_user_id) | s.users.contains(&as_user_id), + Some(s.to_owned()), + ) + } + } else { + (true, None) + } + } + /// Complete a vector of just posts with their owner as well. pub async fn fill_posts( &self, @@ -288,12 +322,14 @@ impl DataManager { Option<(User, Post)>, Option<(Question, User)>, Option<(Poll, bool, bool)>, + Option, )>, > { let mut out = Vec::new(); let mut users: HashMap = HashMap::new(); let mut seen_user_follow_statuses: HashMap<(usize, usize), bool> = HashMap::new(); + let mut seen_stacks: HashMap = HashMap::new(); let mut replying_posts: HashMap = HashMap::new(); for post in posts { @@ -304,12 +340,25 @@ impl DataManager { let owner = post.owner; if let Some(ua) = users.get(&owner) { + let (can_view, stack) = self + .get_post_stack( + &mut seen_stacks, + &post, + if let Some(ua) = user { ua.id } else { 0 }, + ) + .await; + + if !can_view { + continue; + } + out.push(( post.clone(), ua.clone(), self.get_post_reposting(&post, ignore_users, user).await, self.get_post_question(&post, ignore_users).await?, self.get_post_poll(&post, user).await?, + stack, )); } else { let ua = self.get_user_by_id(owner).await?; @@ -357,6 +406,18 @@ impl DataManager { } } + let (can_view, stack) = self + .get_post_stack( + &mut seen_stacks, + &post, + if let Some(ua) = user { ua.id } else { 0 }, + ) + .await; + + if !can_view { + continue; + } + // ... users.insert(owner, ua.clone()); out.push(( @@ -365,6 +426,7 @@ impl DataManager { self.get_post_reposting(&post, ignore_users, user).await, self.get_post_question(&post, ignore_users).await?, self.get_post_poll(&post, user).await?, + stack, )); } } @@ -384,6 +446,7 @@ impl DataManager { let mut seen_before: HashMap<(usize, usize), (User, Community)> = HashMap::new(); let mut seen_user_follow_statuses: HashMap<(usize, usize), bool> = HashMap::new(); + let mut seen_stacks: HashMap = HashMap::new(); let mut replying_posts: HashMap = HashMap::new(); for post in posts { @@ -395,6 +458,18 @@ impl DataManager { let community = post.community; if let Some((ua, community)) = seen_before.get(&(owner, community)) { + let (can_view, stack) = self + .get_post_stack( + &mut seen_stacks, + &post, + if let Some(ua) = user { ua.id } else { 0 }, + ) + .await; + + if !can_view { + continue; + } + out.push(( post.clone(), ua.clone(), @@ -402,6 +477,7 @@ impl DataManager { self.get_post_reposting(&post, ignore_users, user).await, self.get_post_question(&post, ignore_users).await?, self.get_post_poll(&post, user).await?, + stack, )); } else { let ua = self.get_user_by_id(owner).await?; @@ -440,6 +516,18 @@ impl DataManager { } } + let (can_view, stack) = self + .get_post_stack( + &mut seen_stacks, + &post, + if let Some(ua) = user { ua.id } else { 0 }, + ) + .await; + + if !can_view { + continue; + } + // ... let community = self.get_community_by_id(community).await?; seen_before.insert((owner, community.id), (ua.clone(), community.clone())); @@ -450,6 +538,7 @@ impl DataManager { self.get_post_reposting(&post, ignore_users, user).await, self.get_post_question(&post, ignore_users).await?, self.get_post_poll(&post, user).await?, + stack, )); } } @@ -933,6 +1022,37 @@ impl DataManager { Ok(res.unwrap()) } + /// Get all posts from the given stack (from most recent). + /// + /// # Arguments + /// * `id` - the ID of the stack the requested posts belong to + /// * `batch` - the limit of posts in each page + /// * `page` - the page number + pub async fn get_posts_by_stack( + &self, + id: usize, + batch: usize, + page: usize, + ) -> Result> { + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = query_rows!( + &conn, + "SELECT * FROM posts WHERE stack = $1 AND replying_to = 0 AND is_deleted = 0 ORDER BY created DESC LIMIT $2 OFFSET $3", + &[&(id as i64), &(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()) + } + /// Get all pinned posts from the given community (from most recent). /// /// # Arguments @@ -1370,7 +1490,30 @@ impl DataManager { } } - let community = self.get_community_by_id(data.community).await?; + // check stack + if data.stack != 0 { + let stack = self.get_stack_by_id(data.stack).await?; + + if stack.mode != StackMode::Circle { + return Err(Error::MiscError( + "You must use a \"Circle\" stack for this".to_string(), + )); + } + + if stack.owner != data.owner && !stack.users.contains(&data.owner) { + return Err(Error::NotAllowed); + } + } + + // ... + let community = if data.stack != 0 { + // if we're posting to a stack, the community should always be the town square + data.community = self.0.0.town_square; + self.get_community_by_id(self.0.0.town_square).await? + } else { + // otherwise, load whatever community the post is requesting + self.get_community_by_id(data.community).await? + }; // check values (if this isn't reposting something else) let is_reposting = if let Some(ref repost) = data.context.repost { @@ -1466,6 +1609,10 @@ impl DataManager { }; if let Some(ref rt) = reposting { + if rt.stack != data.stack && rt.stack != 0 { + return Err(Error::MiscError("Cannot repost out of stack".to_string())); + } + if data.content.is_empty() { // reposting but NOT quoting... we shouldn't be able to repost a direct repost data.context.reposts_enabled = false; @@ -1507,7 +1654,7 @@ impl DataManager { // send notification // this would look better if rustfmt didn't give up on this line - if owner.id != rt.owner && !owner.settings.private_profile { + if owner.id != rt.owner && !owner.settings.private_profile && data.stack == 0 { self.create_notification( Notification::new( format!( @@ -1631,7 +1778,7 @@ impl DataManager { &(data.poll_id as i64), &data.title, &{ if data.is_open { 1 } else { 0 } }, - &(data.circle as i64), + &(data.stack as i64), ] ); @@ -1781,7 +1928,7 @@ impl DataManager { let res = execute!( &conn, "UPDATE posts SET is_deleted = $1 WHERE id = $2", - params![if is_deleted { 1 } else { 0 }, &(id as i64)] + params![&if is_deleted { 1 } else { 0 }, &(id as i64)] ); if let Err(e) = res { @@ -1793,7 +1940,9 @@ impl DataManager { if is_deleted { // decr parent comment count if let Some(replying_to) = y.replying_to { - self.decr_post_comments(replying_to).await.unwrap(); + if replying_to != 0 { + self.decr_post_comments(replying_to).await.unwrap(); + } } // decr user post count @@ -1893,7 +2042,7 @@ impl DataManager { let res = execute!( &conn, "UPDATE posts SET is_open = $1 WHERE id = $2", - params![if is_open { 1 } else { 0 }, &(id as i64)] + params![&if is_open { 1 } else { 0 }, &(id as i64)] ); if let Err(e) = res { @@ -2091,5 +2240,5 @@ impl DataManager { auto_method!(decr_post_dislikes() -> "UPDATE posts SET dislikes = dislikes - 1 WHERE id = $1" --cache-key-tmpl="atto.post:{}" --decr); auto_method!(incr_post_comments() -> "UPDATE posts SET comment_count = comment_count + 1 WHERE id = $1" --cache-key-tmpl="atto.post:{}" --incr); - auto_method!(decr_post_comments() -> "UPDATE posts SET comment_count = comment_count - 1 WHERE id = $1" --cache-key-tmpl="atto.post:{}" --decr); + auto_method!(decr_post_comments()@get_post_by_id -> "UPDATE posts SET comment_count = comment_count - 1 WHERE id = $1" --cache-key-tmpl="atto.post:{}" --decr=comment_count); } diff --git a/crates/core/src/database/stacks.rs b/crates/core/src/database/stacks.rs index 5c92a37..47f5e53 100644 --- a/crates/core/src/database/stacks.rs +++ b/crates/core/src/database/stacks.rs @@ -1,10 +1,12 @@ use oiseau::cache::Cache; -use crate::model::{ - Error, Result, - auth::User, - permissions::FinePermission, - stacks::{StackPrivacy, UserStack, StackMode, StackSort}, - communities::{Community, Poll, Post, Question}, +use crate::{ + database::posts::FullPost, + model::{ + auth::User, + permissions::FinePermission, + stacks::{StackMode, StackPrivacy, StackSort, UserStack}, + Error, Result, + }, }; use crate::{auto_method, DataManager}; @@ -37,16 +39,7 @@ impl DataManager { page: usize, ignore_users: &Vec, user: &Option, - ) -> Result< - Vec<( - Post, - User, - Community, - Option<(User, Post)>, - Option<(Question, User)>, - Option<(Poll, bool, bool)>, - )>, - > { + ) -> Result> { let stack = self.get_stack_by_id(id).await?; Ok(match stack.mode { @@ -89,6 +82,19 @@ impl DataManager { "You should use `get_stack_users` for this type".to_string(), )); } + StackMode::Circle => { + if !stack.users.contains(&as_user_id) && as_user_id != stack.owner { + return Err(Error::NotAllowed); + } + + self.fill_posts_with_community( + self.get_posts_by_stack(stack.id, batch, page).await?, + as_user_id, + &ignore_users, + user, + ) + .await? + } }) } @@ -119,9 +125,11 @@ impl DataManager { /// Get all stacks by user. /// + /// Also pulls stacks that are of "Circle" type AND the user is added to the `users` list. + /// /// # Arguments /// * `id` - the ID of the user to fetch stacks for - pub async fn get_stacks_by_owner(&self, id: usize) -> Result> { + pub async fn get_stacks_by_user(&self, id: usize) -> Result> { let conn = match self.0.connect().await { Ok(c) => c, Err(e) => return Err(Error::DatabaseConnection(e.to_string())), @@ -129,8 +137,8 @@ impl DataManager { let res = query_rows!( &conn, - "SELECT * FROM stacks WHERE owner = $1 ORDER BY name ASC", - &[&(id as i64)], + "SELECT * FROM stacks WHERE owner = $1 OR (mode = '\"Circle\"' AND users LIKE $2) ORDER BY name ASC", + &[&(id as i64), &format!("%{id}%")], |x| { Self::get_stack_from_row(x) } ); @@ -142,6 +150,7 @@ impl DataManager { } const MAXIMUM_FREE_STACKS: usize = 5; + pub const MAXIMUM_FREE_STACK_USERS: usize = 50; /// Create a new stack in the database. /// @@ -159,7 +168,7 @@ impl DataManager { 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 stacks = self.get_stacks_by_user(data.owner).await?; if stacks.len() >= Self::MAXIMUM_FREE_STACKS { return Err(Error::MiscError( @@ -216,6 +225,25 @@ impl DataManager { return Err(Error::DatabaseError(e.to_string())); } + // delete stackblocks + let res = execute!( + &conn, + "DELETE FROM stackblocks WHERE stack = $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 stack = $1", &[&(id as i64)]); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // ... self.0.1.remove(format!("atto.stack:{}", id)).await; Ok(()) } diff --git a/crates/core/src/model/communities.rs b/crates/core/src/model/communities.rs index c4419c1..41508ff 100644 --- a/crates/core/src/model/communities.rs +++ b/crates/core/src/model/communities.rs @@ -260,10 +260,10 @@ pub struct Post { pub title: String, /// If the post is "open". Posts can act as tickets in a forge community. pub is_open: bool, - /// The ID of the circle this post belongs to. 0 means no circle is connected. + /// The ID of the stack this post belongs to. 0 means no stack is connected. /// - /// If circle is not 0, community should be 0 (and vice versa). - pub circle: usize, + /// If stack is not 0, community should be 0 (and vice versa). + pub stack: usize, } impl Post { @@ -291,7 +291,7 @@ impl Post { poll_id, title: String::new(), is_open: true, - circle: 0, + stack: 0, } } diff --git a/crates/core/src/model/oauth.rs b/crates/core/src/model/oauth.rs index b2a3798..ea87034 100644 --- a/crates/core/src/model/oauth.rs +++ b/crates/core/src/model/oauth.rs @@ -76,8 +76,6 @@ pub enum AppScope { UserCreateCommunities, /// Create stacks on behalf of the user. UserCreateStacks, - /// Create circles on behalf of the user. - UserCreateCircles, /// Delete posts owned by the user. UserDeletePosts, /// Delete messages owned by the user. @@ -108,8 +106,6 @@ pub enum AppScope { UserManageRequests, /// Manage the user's uploads. UserManageUploads, - /// Manage the user's circles (add/remove users or delete). - UserManageCircles, /// Edit posts created by the user. UserEditPosts, /// Edit drafts created by the user. diff --git a/crates/core/src/model/stacks.rs b/crates/core/src/model/stacks.rs index a2e7487..437f2cc 100644 --- a/crates/core/src/model/stacks.rs +++ b/crates/core/src/model/stacks.rs @@ -1,7 +1,7 @@ use serde::{Serialize, Deserialize}; use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp}; -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] pub enum StackPrivacy { /// Can be viewed by anyone. Public, @@ -15,7 +15,7 @@ impl Default for StackPrivacy { } } -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] pub enum StackMode { /// `users` vec contains ID of users to INCLUDE into the timeline; /// every other user is excluded @@ -28,6 +28,8 @@ pub enum StackMode { /// /// Other users can block the entire list (creating a `StackBlock`, not a `UserBlock`). BlockList, + /// `users` vec contains ID of users who are allowed to view posts posted to the stack. + Circle, } impl Default for StackMode { @@ -36,7 +38,7 @@ impl Default for StackMode { } } -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] pub enum StackSort { Created, Likes, @@ -48,7 +50,7 @@ impl Default for StackSort { } } -#[derive(Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize)] pub struct UserStack { pub id: usize, pub created: usize, @@ -76,7 +78,7 @@ impl UserStack { } } -#[derive(Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize)] pub struct StackBlock { pub id: usize, pub created: usize, diff --git a/crates/l10n/Cargo.toml b/crates/l10n/Cargo.toml index f3f3e62..3f11dfd 100644 --- a/crates/l10n/Cargo.toml +++ b/crates/l10n/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto-l10n" -version = "7.0.0" +version = "8.0.0" edition = "2024" authors.workspace = true repository.workspace = true diff --git a/crates/shared/Cargo.toml b/crates/shared/Cargo.toml index 43caae5..dd48ed3 100644 --- a/crates/shared/Cargo.toml +++ b/crates/shared/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto-shared" -version = "7.0.0" +version = "8.0.0" edition = "2024" authors.workspace = true repository.workspace = true diff --git a/sql_changes/posts_circle.sql b/sql_changes/posts_circle.sql index ad4d620..9d8d312 100644 --- a/sql_changes/posts_circle.sql +++ b/sql_changes/posts_circle.sql @@ -1,2 +1,5 @@ ALTER TABLE posts ADD COLUMN circle BIGINT NOT NULL DEFAULT 0; + +ALTER TABLE posts +ADD COLUMN stack BIGINT NOT NULL DEFAULT 0;