add: circle stacks

This commit is contained in:
trisua 2025-06-15 16:09:02 -04:00
parent 50704d27a9
commit 56cea83933
27 changed files with 419 additions and 107 deletions

14
Cargo.lock generated
View file

@ -2621,9 +2621,9 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
[[package]] [[package]]
name = "reqwest" name = "reqwest"
version = "0.12.19" version = "0.12.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2f8e5513d63f2e5b386eb5106dc67eaf3f84e95258e210489136b8b92ad6119" checksum = "eabf4c97d9130e2bf606614eb937e86edac8292eaa6f422f995d7e8de1eb1813"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"bytes", "bytes",
@ -2638,12 +2638,10 @@ dependencies = [
"hyper-rustls", "hyper-rustls",
"hyper-tls 0.6.0", "hyper-tls 0.6.0",
"hyper-util", "hyper-util",
"ipnet",
"js-sys", "js-sys",
"log", "log",
"mime", "mime",
"native-tls", "native-tls",
"once_cell",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"rustls-pki-types", "rustls-pki-types",
@ -3233,7 +3231,7 @@ dependencies = [
[[package]] [[package]]
name = "tetratto" name = "tetratto"
version = "7.0.0" version = "8.0.0"
dependencies = [ dependencies = [
"ammonia", "ammonia",
"async-stripe", "async-stripe",
@ -3264,7 +3262,7 @@ dependencies = [
[[package]] [[package]]
name = "tetratto-core" name = "tetratto-core"
version = "7.0.0" version = "8.0.0"
dependencies = [ dependencies = [
"async-recursion", "async-recursion",
"base16ct", "base16ct",
@ -3286,7 +3284,7 @@ dependencies = [
[[package]] [[package]]
name = "tetratto-l10n" name = "tetratto-l10n"
version = "7.0.0" version = "8.0.0"
dependencies = [ dependencies = [
"pathbufd", "pathbufd",
"serde", "serde",
@ -3295,7 +3293,7 @@ dependencies = [
[[package]] [[package]]
name = "tetratto-shared" name = "tetratto-shared"
version = "7.0.0" version = "8.0.0"
dependencies = [ dependencies = [
"ammonia", "ammonia",
"chrono", "chrono",

View file

@ -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: Everything Tetratto needs will be built into the main binary. You can build Tetratto with the following command:
```bash ```bash
cargo build cargo build -r
``` ```
Tetratto **requires** a PostgreSQL server, as well as a Redis (or Redis fork) instance. Tetratto **requires** a PostgreSQL server, as well as a Redis (or Redis fork) instance.

View file

@ -1,6 +1,6 @@
[package] [package]
name = "tetratto" name = "tetratto"
version = "7.0.0" version = "8.0.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
@ -19,7 +19,7 @@ tetratto-core = { path = "../core" }
tetratto-l10n = { path = "../l10n" } tetratto-l10n = { path = "../l10n" }
image = "0.25.6" image = "0.25.6"
reqwest = { version = "0.12.19", features = ["json", "stream"] } reqwest = { version = "0.12.20", features = ["json", "stream"] }
regex = "1.11.1" regex = "1.11.1"
serde_json = "1.0.140" serde_json = "1.0.140"
mime_guess = "2.0.5" mime_guess = "2.0.5"

View file

@ -127,11 +127,8 @@ where
Err(_) => return Err((StatusCode::BAD_REQUEST, "json data isn't utf8".to_string())), Err(_) => return Err((StatusCode::BAD_REQUEST, "json data isn't utf8".to_string())),
}) { }) {
Ok(s) => s, Ok(s) => s,
Err(_) => { Err(e) => {
return Err(( return Err((StatusCode::BAD_REQUEST, e.to_string()));
StatusCode::BAD_REQUEST,
"could not parse json data as json".to_string(),
));
} }
}; };

View file

@ -97,6 +97,13 @@
("value" "{{ community.id }}") ("value" "{{ community.id }}")
("selected" "{% if selected_community == community.id -%}true{% else %}false{%- endif %}") ("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 "{% 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 %}"))) (text "{% endfor %}")))
(form (form
("class" "card flex flex-col gap-2") ("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.append(
\"body\", \"body\",
JSON.stringify({ JSON.stringify({
content: e.target.content.value, content: e.target.content.value,
community: document.getElementById( community: !is_selected_stack ? selected_community : \"0\",
\"community_to_post_to\", stack: is_selected_stack ? selected_community : \"0\",
).selectedOptions[0].value,
poll: poll_data[1], poll: poll_data[1],
title: e.target.title.value, title: e.target.title.value,
}), }),
@ -316,12 +329,15 @@
(text "{% else %}") (text "{% else %}")
(script (script
(text "async function create_post_from_form(e) { (text "async function create_post_from_form(e) {
e.preventDefault();
const id = await trigger(\"me::repost\", [ const id = await trigger(\"me::repost\", [
\"{{ quoting[1].id }}\", \"{{ quoting[1].id }}\",
e.target.content.value, e.target.content.value,
document.getElementById(\"community_to_post_to\") document.getElementById(\"community_to_post_to\")
.selectedOptions[0].value, .selectedOptions[0].value,
false, false,
document.getElementById(\"community_to_post_to\")
.selectedOptions[0].getAttribute(\"is_stack\") === \"true\",
]); ]);
// update settings // update settings
@ -394,27 +410,34 @@
(text "{%- endif %}")) (text "{%- endif %}"))
(script (script
(text "const town_square = \"{{ config.town_square }}\"; (text "(() => {const town_square = \"{{ config.town_square }}\";
const user_id = \"{{ user.id }}\"; const user_id = \"{{ user.id }}\";
function update_community_avatar(e) { window.update_community_avatar = (e) => {
const element = e.target.parentElement.querySelector(\".avatar\"); 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; const id = e.target.selectedOptions[0].value;
element.setAttribute(\"title\", id); element.setAttribute(\"title\", id);
element.setAttribute(\"alt\", `${id}'s avatar`); 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`; element.src = `/api/v1/auth/user/${user_id}/avatar?selector_type=id`;
} else { } else {
element.src = `/api/v1/communities/${id}/avatar`; 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 element = document.getElementById(\"title_field\");
const is_stack = e.target.selectedOptions[0].getAttribute(\"is_stack\") === \"true\";
const id = e.target.selectedOptions[0].value; const id = e.target.selectedOptions[0].value;
if (is_stack) {
element.classList.add(\"hidden\");
return;
}
fetch(`/api/v1/communities/${id}/supports_titles`) fetch(`/api/v1/communities/${id}/supports_titles`)
.then((res) => res.json()) .then((res) => res.json())
.then((res) => { .then((res) => {
@ -436,7 +459,7 @@
}); });
}, 150); }, 150);
async function cancel_create_post() { window.cancel_create_post = async () => {
if ( if (
!(await trigger(\"atto::confirm\", [ !(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this? Your post content will be lost.\", \"Are you sure you would like to do this? Your post content will be lost.\",
@ -446,6 +469,6 @@
} }
window.history.back(); window.history.back();
}")) }})();"))
(text "{% endblock %}") (text "{% endblock %}")

View file

@ -173,8 +173,13 @@
("class" "flex items-center") ("class" "flex items-center")
("style" "color: var(--color-primary)") ("style" "color: var(--color-primary)")
(text "{{ icon \"square-asterisk\" }}")) (text "{{ icon \"square-asterisk\" }}"))
(text "{%- endif %}") (text "{%- endif %} {% if post.stack -%}")
(text "{% if community and community.is_forge -%} {% if post.is_open -%}") (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 (span
("title" "Open") ("title" "Open")
("class" "flex items-center green") ("class" "flex items-center green")

View file

@ -298,6 +298,7 @@
JSON.stringify({ JSON.stringify({
content: e.target.content.value, content: e.target.content.value,
community: \"{{ community.id }}\", community: \"{{ community.id }}\",
stack: \"{{ post.stack }}\",
replying_to: \"{{ post.id }}\", replying_to: \"{{ post.id }}\",
poll: poll_data[1], poll: poll_data[1],
}), }),

View file

@ -581,7 +581,9 @@
(li (li
(text "Ability to create more than 1 app")) (text "Ability to create more than 1 app"))
(li (li
(text "Create up to 10 stack blocks"))) (text "Create up to 10 stack blocks"))
(li
(text "Add unlimited users to stacks")))
(a (a
("href" "{{ config.stripe.payment_link }}?client_reference_id={{ user.id }}") ("href" "{{ config.stripe.payment_link }}?client_reference_id={{ user.id }}")
("class" "button") ("class" "button")

View file

@ -17,14 +17,27 @@
(text "{{ components::avatar(username=stack.owner, selector_type=\"id\") }}")) (text "{{ components::avatar(username=stack.owner, selector_type=\"id\") }}"))
(span (span
(text "{{ stack.name }}"))) (text "{{ stack.name }}")))
(text "{% if user and user.id == stack.owner -%}") (div
(a ("class" "flex gap-2")
("href" "/stacks/{{ stack.id }}/manage") (text "{% if stack.mode == 'Circle' -%}")
("class" "button lowered small") ; post button for circle stacks
(text "{{ icon \"pencil\" }}") (a
(span ("href" "/communities/intents/post?stack={{ stack.id }}")
(text "{{ text \"general:action.manage\" }}"))) ("class" "button lowered small")
(text "{%- endif %}")) (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 (div
("class" "card w-full flex flex-col gap-2") ("class" "card w-full flex flex-col gap-2")
(text "{% if list|length == 0 -%}") (text "{% if list|length == 0 -%}")
@ -37,6 +50,7 @@
(text "{%- endif %}") (text "{%- endif %}")
(text "{% if stack.mode == 'BlockList' -%}") (text "{% if stack.mode == 'BlockList' -%}")
; block button + user list for blocklist only
(text "{% if not is_blocked -%}") (text "{% if not is_blocked -%}")
(button (button
("onclick" "block_all()") ("onclick" "block_all()")
@ -50,6 +64,12 @@
("class" "flex gap-2 flex-wrap w-full") ("class" "flex gap-2 flex-wrap w-full")
(text "{% for user in list %} {{ components::user_plate(user=user, secondary=true) }} {% endfor %}")) (text "{% for user in list %} {{ components::user_plate(user=user, secondary=true) }} {% endfor %}"))
(text "{% else %}") (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 %} (text "{% for post in list %}
{% if post[2].read_access == \"Everybody\" -%} {% if post[2].read_access == \"Everybody\" -%}
{% if post[0].context.repost and post[0].context.repost.reposting -%} {% if post[0].context.repost and post[0].context.repost.reposting -%}

View file

@ -67,7 +67,11 @@
(option (option
("value" "BlockList") ("value" "BlockList")
("selected" "{% if stack.mode == 'BlockList' -%}true{% else %}false{%- endif %}") ("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 (div
("class" "card-nest") ("class" "card-nest")
("ui_ident" "sort") ("ui_ident" "sort")

View file

@ -259,7 +259,14 @@
self.define( self.define(
"repost", "repost",
(_, id, content, community, do_not_redirect = false) => { (
_,
id,
content,
community,
do_not_redirect = false,
is_stack = false,
) => {
return new Promise((resolve, _) => { return new Promise((resolve, _) => {
fetch(`/api/v1/posts/${id}/repost`, { fetch(`/api/v1/posts/${id}/repost`, {
method: "POST", method: "POST",
@ -268,7 +275,8 @@
}, },
body: JSON.stringify({ body: JSON.stringify({
content, content,
community, community: !is_stack ? community : "0",
stack: is_stack ? community : "0",
}), }),
}) })
.then((res) => res.json()) .then((res) => res.json())

View file

@ -124,6 +124,10 @@ pub async fn create_request(
}; };
} else { } else {
props.title = req.title; props.title = req.title;
props.stack = match req.stack.parse::<usize>() {
Ok(x) => x,
Err(e) => return Json(Error::MiscError(e.to_string()).into()),
};
} }
// check sizes // check sizes
@ -197,18 +201,23 @@ pub async fn create_repost_request(
None => return Json(Error::NotAllowed.into()), None => return Json(Error::NotAllowed.into()),
}; };
match data let mut props = Post::repost(
.create_post(Post::repost( req.content,
req.content, match req.community.parse::<usize>() {
match req.community.parse::<usize>() { Ok(x) => x,
Ok(x) => x, Err(e) => return Json(Error::MiscError(e.to_string()).into()),
Err(e) => return Json(Error::MiscError(e.to_string()).into()), },
}, user.id,
user.id, id,
id, );
))
.await props.stack = match req.stack.parse::<usize>() {
{ Ok(x) => x,
Err(e) => return Json(Error::MiscError(e.to_string()).into()),
};
// ...
match data.create_post(props).await {
Ok(id) => Json(ApiReturn { Ok(id) => Json(ApiReturn {
ok: true, ok: true,
message: "Post reposted".to_string(), message: "Post reposted".to_string(),

View file

@ -616,12 +616,15 @@ pub struct CreatePost {
pub poll: Option<CreatePostPoll>, pub poll: Option<CreatePostPoll>,
#[serde(default)] #[serde(default)]
pub title: String, pub title: String,
#[serde(default)]
pub stack: String,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct CreateRepost { pub struct CreateRepost {
pub content: String, pub content: String,
pub community: String, pub community: String,
pub stack: String,
} }
#[derive(Deserialize)] #[derive(Deserialize)]

View file

@ -5,11 +5,14 @@ use axum::{
Extension, Json, Extension, Json,
}; };
use axum_extra::extract::CookieJar; use axum_extra::extract::CookieJar;
use tetratto_core::model::{ use tetratto_core::{
oauth, model::{
permissions::FinePermission, oauth,
stacks::{StackBlock, StackPrivacy, UserStack}, permissions::FinePermission,
ApiReturn, Error, stacks::{StackBlock, StackMode, StackPrivacy, UserStack},
ApiReturn, Error,
},
DataManager,
}; };
use super::{ use super::{
AddOrRemoveStackUser, CreateStack, UpdateStackMode, UpdateStackName, UpdateStackPrivacy, AddOrRemoveStackUser, CreateStack, UpdateStackMode, UpdateStackName, UpdateStackPrivacy,
@ -161,6 +164,25 @@ pub async fn add_user_request(
}; };
stack.users.push(other_user.id); 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 { match data.update_stack_users(id, &user, stack.users).await {
Ok(_) => Json(ApiReturn { Ok(_) => Json(ApiReturn {
ok: true, ok: true,
@ -250,6 +272,7 @@ pub async fn get_users_request(
if stack.privacy == StackPrivacy::Private if stack.privacy == StackPrivacy::Private
&& user.id != stack.owner && user.id != stack.owner
&& !(stack.mode == StackMode::Circle && stack.users.contains(&user.id))
&& !user.permissions.check(FinePermission::MANAGE_STACKS) && !user.permissions.check(FinePermission::MANAGE_STACKS)
{ {
return Json(Error::NotAllowed.into()); return Json(Error::NotAllowed.into());

View file

@ -11,8 +11,12 @@ use axum_extra::extract::CookieJar;
use serde::Deserialize; use serde::Deserialize;
use tera::Context; use tera::Context;
use tetratto_core::model::{ use tetratto_core::model::{
auth::User, communities::Community, communities_permissions::CommunityPermission, auth::User,
permissions::FinePermission, Error, communities::Community,
communities_permissions::CommunityPermission,
permissions::FinePermission,
stacks::{StackMode, UserStack},
Error,
}; };
#[macro_export] #[macro_export]
@ -245,6 +249,8 @@ pub struct CreatePostProps {
#[serde(default)] #[serde(default)]
pub community: usize, pub community: usize,
#[serde(default)] #[serde(default)]
pub stack: usize,
#[serde(default)]
pub from_draft: usize, pub from_draft: usize,
#[serde(default)] #[serde(default)]
pub quote: usize, pub quote: usize,
@ -286,6 +292,16 @@ pub async fn create_post_request(
communities.push(community) 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 // get draft
let draft = if props.from_draft != 0 { let draft = if props.from_draft != 0 {
match data.0.get_draft_by_id(props.from_draft).await { 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("draft", &draft);
context.insert("drafts", &drafts); context.insert("drafts", &drafts);
context.insert("stacks", &stacks);
context.insert("quoting", &quoting); context.insert("quoting", &quoting);
context.insert("communities", &communities); context.insert("communities", &communities);
context.insert("selected_stack", &props.stack);
context.insert("selected_community", &props.community); context.insert("selected_community", &props.community);
// return // 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 { let community = match data.0.get_community_by_id(post.community).await {
Ok(c) => c, Ok(c) => c,
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),

View file

@ -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, Ok(ua) => ua,
Err(e) => { Err(e) => {
return Err(Html(render_error(e, &jar, &data, &None).await)); return Err(Html(render_error(e, &jar, &data, &None).await));

View file

@ -25,7 +25,7 @@ pub async fn list_request(jar: CookieJar, Extension(data): Extension<State>) ->
} }
}; };
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, Ok(p) => p,
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)),
}; };
@ -63,6 +63,7 @@ pub async fn feed_request(
if stack.privacy == StackPrivacy::Private if stack.privacy == StackPrivacy::Private
&& user.id != stack.owner && user.id != stack.owner
&& !(stack.mode == StackMode::Circle && stack.users.contains(&user.id))
&& !user.permissions.check(FinePermission::MANAGE_STACKS) && !user.permissions.check(FinePermission::MANAGE_STACKS)
{ {
return Err(Html( return Err(Html(

View file

@ -1,6 +1,6 @@
[package] [package]
name = "tetratto-core" name = "tetratto-core"
version = "7.0.0" version = "8.0.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
@ -11,7 +11,7 @@ tetratto-shared = { path = "../shared" }
tetratto-l10n = { path = "../l10n" } tetratto-l10n = { path = "../l10n" }
serde_json = "1.0.140" serde_json = "1.0.140"
totp-rs = { version = "5.7.0", features = ["qr", "gen_secret"] } 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" bitflags = "2.9.1"
async-recursion = "1.1.1" async-recursion = "1.1.1"
md-5 = "0.10.6" md-5 = "0.10.6"

View file

@ -95,7 +95,7 @@ impl DataManager {
let owner = self.get_user_by_id(data.owner).await?; let owner = self.get_user_by_id(data.owner).await?;
if !owner.permissions.check(FinePermission::SUPPORTER) { 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 { if stacks.len() >= Self::MAXIMUM_FREE_DRAFTS {
return Err(Error::MiscError( return Err(Error::MiscError(

View file

@ -5,7 +5,7 @@ use crate::model::auth::Notification;
use crate::model::communities::{Poll, Question}; use crate::model::communities::{Poll, Question};
use crate::model::communities_permissions::CommunityPermission; use crate::model::communities_permissions::CommunityPermission;
use crate::model::moderation::AuditLogEntry; use crate::model::moderation::AuditLogEntry;
use crate::model::stacks::StackSort; use crate::model::stacks::{StackMode, StackSort, UserStack};
use crate::model::{ use crate::model::{
Error, Result, Error, Result,
auth::User, auth::User,
@ -25,6 +25,7 @@ pub type FullPost = (
Option<(User, Post)>, Option<(User, Post)>,
Option<(Question, User)>, Option<(Question, User)>,
Option<(Poll, bool, bool)>, Option<(Poll, bool, bool)>,
Option<UserStack>,
); );
macro_rules! private_post_replying { macro_rules! private_post_replying {
@ -114,7 +115,7 @@ impl DataManager {
poll_id: get!(x->13(i64)) as usize, poll_id: get!(x->13(i64)) as usize,
title: get!(x->14(String)), title: get!(x->14(String)),
is_open: get!(x->15(i32)) as i8 == 1, 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<usize, UserStack>,
post: &Post,
as_user_id: usize,
) -> (bool, Option<UserStack>) {
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. /// Complete a vector of just posts with their owner as well.
pub async fn fill_posts( pub async fn fill_posts(
&self, &self,
@ -288,12 +322,14 @@ impl DataManager {
Option<(User, Post)>, Option<(User, Post)>,
Option<(Question, User)>, Option<(Question, User)>,
Option<(Poll, bool, bool)>, Option<(Poll, bool, bool)>,
Option<UserStack>,
)>, )>,
> { > {
let mut out = Vec::new(); let mut out = Vec::new();
let mut users: HashMap<usize, User> = HashMap::new(); let mut users: HashMap<usize, User> = HashMap::new();
let mut seen_user_follow_statuses: HashMap<(usize, usize), bool> = HashMap::new(); let mut seen_user_follow_statuses: HashMap<(usize, usize), bool> = HashMap::new();
let mut seen_stacks: HashMap<usize, UserStack> = HashMap::new();
let mut replying_posts: HashMap<usize, Post> = HashMap::new(); let mut replying_posts: HashMap<usize, Post> = HashMap::new();
for post in posts { for post in posts {
@ -304,12 +340,25 @@ impl DataManager {
let owner = post.owner; let owner = post.owner;
if let Some(ua) = users.get(&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(( out.push((
post.clone(), post.clone(),
ua.clone(), ua.clone(),
self.get_post_reposting(&post, ignore_users, user).await, self.get_post_reposting(&post, ignore_users, user).await,
self.get_post_question(&post, ignore_users).await?, self.get_post_question(&post, ignore_users).await?,
self.get_post_poll(&post, user).await?, self.get_post_poll(&post, user).await?,
stack,
)); ));
} else { } else {
let ua = self.get_user_by_id(owner).await?; 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()); users.insert(owner, ua.clone());
out.push(( out.push((
@ -365,6 +426,7 @@ impl DataManager {
self.get_post_reposting(&post, ignore_users, user).await, self.get_post_reposting(&post, ignore_users, user).await,
self.get_post_question(&post, ignore_users).await?, self.get_post_question(&post, ignore_users).await?,
self.get_post_poll(&post, user).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_before: HashMap<(usize, usize), (User, Community)> = HashMap::new();
let mut seen_user_follow_statuses: HashMap<(usize, usize), bool> = HashMap::new(); let mut seen_user_follow_statuses: HashMap<(usize, usize), bool> = HashMap::new();
let mut seen_stacks: HashMap<usize, UserStack> = HashMap::new();
let mut replying_posts: HashMap<usize, Post> = HashMap::new(); let mut replying_posts: HashMap<usize, Post> = HashMap::new();
for post in posts { for post in posts {
@ -395,6 +458,18 @@ impl DataManager {
let community = post.community; let community = post.community;
if let Some((ua, community)) = seen_before.get(&(owner, 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(( out.push((
post.clone(), post.clone(),
ua.clone(), ua.clone(),
@ -402,6 +477,7 @@ impl DataManager {
self.get_post_reposting(&post, ignore_users, user).await, self.get_post_reposting(&post, ignore_users, user).await,
self.get_post_question(&post, ignore_users).await?, self.get_post_question(&post, ignore_users).await?,
self.get_post_poll(&post, user).await?, self.get_post_poll(&post, user).await?,
stack,
)); ));
} else { } else {
let ua = self.get_user_by_id(owner).await?; 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?; let community = self.get_community_by_id(community).await?;
seen_before.insert((owner, community.id), (ua.clone(), community.clone())); 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_reposting(&post, ignore_users, user).await,
self.get_post_question(&post, ignore_users).await?, self.get_post_question(&post, ignore_users).await?,
self.get_post_poll(&post, user).await?, self.get_post_poll(&post, user).await?,
stack,
)); ));
} }
} }
@ -933,6 +1022,37 @@ impl DataManager {
Ok(res.unwrap()) 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<Vec<Post>> {
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). /// Get all pinned posts from the given community (from most recent).
/// ///
/// # Arguments /// # 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) // check values (if this isn't reposting something else)
let is_reposting = if let Some(ref repost) = data.context.repost { let is_reposting = if let Some(ref repost) = data.context.repost {
@ -1466,6 +1609,10 @@ impl DataManager {
}; };
if let Some(ref rt) = reposting { 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() { if data.content.is_empty() {
// reposting but NOT quoting... we shouldn't be able to repost a direct repost // reposting but NOT quoting... we shouldn't be able to repost a direct repost
data.context.reposts_enabled = false; data.context.reposts_enabled = false;
@ -1507,7 +1654,7 @@ impl DataManager {
// send notification // send notification
// this would look better if rustfmt didn't give up on this line // 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( self.create_notification(
Notification::new( Notification::new(
format!( format!(
@ -1631,7 +1778,7 @@ impl DataManager {
&(data.poll_id as i64), &(data.poll_id as i64),
&data.title, &data.title,
&{ if data.is_open { 1 } else { 0 } }, &{ if data.is_open { 1 } else { 0 } },
&(data.circle as i64), &(data.stack as i64),
] ]
); );
@ -1781,7 +1928,7 @@ impl DataManager {
let res = execute!( let res = execute!(
&conn, &conn,
"UPDATE posts SET is_deleted = $1 WHERE id = $2", "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 { if let Err(e) = res {
@ -1793,7 +1940,9 @@ impl DataManager {
if is_deleted { if is_deleted {
// decr parent comment count // decr parent comment count
if let Some(replying_to) = y.replying_to { 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 // decr user post count
@ -1893,7 +2042,7 @@ impl DataManager {
let res = execute!( let res = execute!(
&conn, &conn,
"UPDATE posts SET is_open = $1 WHERE id = $2", "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 { 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!(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!(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);
} }

View file

@ -1,10 +1,12 @@
use oiseau::cache::Cache; use oiseau::cache::Cache;
use crate::model::{ use crate::{
Error, Result, database::posts::FullPost,
auth::User, model::{
permissions::FinePermission, auth::User,
stacks::{StackPrivacy, UserStack, StackMode, StackSort}, permissions::FinePermission,
communities::{Community, Poll, Post, Question}, stacks::{StackMode, StackPrivacy, StackSort, UserStack},
Error, Result,
},
}; };
use crate::{auto_method, DataManager}; use crate::{auto_method, DataManager};
@ -37,16 +39,7 @@ impl DataManager {
page: usize, page: usize,
ignore_users: &Vec<usize>, ignore_users: &Vec<usize>,
user: &Option<User>, user: &Option<User>,
) -> Result< ) -> Result<Vec<FullPost>> {
Vec<(
Post,
User,
Community,
Option<(User, Post)>,
Option<(Question, User)>,
Option<(Poll, bool, bool)>,
)>,
> {
let stack = self.get_stack_by_id(id).await?; let stack = self.get_stack_by_id(id).await?;
Ok(match stack.mode { Ok(match stack.mode {
@ -89,6 +82,19 @@ impl DataManager {
"You should use `get_stack_users` for this type".to_string(), "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. /// Get all stacks by user.
/// ///
/// Also pulls stacks that are of "Circle" type AND the user is added to the `users` list.
///
/// # Arguments /// # Arguments
/// * `id` - the ID of the user to fetch stacks for /// * `id` - the ID of the user to fetch stacks for
pub async fn get_stacks_by_owner(&self, id: usize) -> Result<Vec<UserStack>> { pub async fn get_stacks_by_user(&self, id: usize) -> Result<Vec<UserStack>> {
let conn = match self.0.connect().await { let conn = match self.0.connect().await {
Ok(c) => c, Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())), Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
@ -129,8 +137,8 @@ impl DataManager {
let res = query_rows!( let res = query_rows!(
&conn, &conn,
"SELECT * FROM stacks WHERE owner = $1 ORDER BY name ASC", "SELECT * FROM stacks WHERE owner = $1 OR (mode = '\"Circle\"' AND users LIKE $2) ORDER BY name ASC",
&[&(id as i64)], &[&(id as i64), &format!("%{id}%")],
|x| { Self::get_stack_from_row(x) } |x| { Self::get_stack_from_row(x) }
); );
@ -142,6 +150,7 @@ impl DataManager {
} }
const MAXIMUM_FREE_STACKS: usize = 5; const MAXIMUM_FREE_STACKS: usize = 5;
pub const MAXIMUM_FREE_STACK_USERS: usize = 50;
/// Create a new stack in the database. /// Create a new stack in the database.
/// ///
@ -159,7 +168,7 @@ impl DataManager {
let owner = self.get_user_by_id(data.owner).await?; let owner = self.get_user_by_id(data.owner).await?;
if !owner.permissions.check(FinePermission::SUPPORTER) { 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 { if stacks.len() >= Self::MAXIMUM_FREE_STACKS {
return Err(Error::MiscError( return Err(Error::MiscError(
@ -216,6 +225,25 @@ impl DataManager {
return Err(Error::DatabaseError(e.to_string())); 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; self.0.1.remove(format!("atto.stack:{}", id)).await;
Ok(()) Ok(())
} }

View file

@ -260,10 +260,10 @@ pub struct Post {
pub title: String, pub title: String,
/// If the post is "open". Posts can act as tickets in a forge community. /// If the post is "open". Posts can act as tickets in a forge community.
pub is_open: bool, 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). /// If stack is not 0, community should be 0 (and vice versa).
pub circle: usize, pub stack: usize,
} }
impl Post { impl Post {
@ -291,7 +291,7 @@ impl Post {
poll_id, poll_id,
title: String::new(), title: String::new(),
is_open: true, is_open: true,
circle: 0, stack: 0,
} }
} }

View file

@ -76,8 +76,6 @@ pub enum AppScope {
UserCreateCommunities, UserCreateCommunities,
/// Create stacks on behalf of the user. /// Create stacks on behalf of the user.
UserCreateStacks, UserCreateStacks,
/// Create circles on behalf of the user.
UserCreateCircles,
/// Delete posts owned by the user. /// Delete posts owned by the user.
UserDeletePosts, UserDeletePosts,
/// Delete messages owned by the user. /// Delete messages owned by the user.
@ -108,8 +106,6 @@ pub enum AppScope {
UserManageRequests, UserManageRequests,
/// Manage the user's uploads. /// Manage the user's uploads.
UserManageUploads, UserManageUploads,
/// Manage the user's circles (add/remove users or delete).
UserManageCircles,
/// Edit posts created by the user. /// Edit posts created by the user.
UserEditPosts, UserEditPosts,
/// Edit drafts created by the user. /// Edit drafts created by the user.

View file

@ -1,7 +1,7 @@
use serde::{Serialize, Deserialize}; use serde::{Serialize, Deserialize};
use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp}; use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp};
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub enum StackPrivacy { pub enum StackPrivacy {
/// Can be viewed by anyone. /// Can be viewed by anyone.
Public, 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 { pub enum StackMode {
/// `users` vec contains ID of users to INCLUDE into the timeline; /// `users` vec contains ID of users to INCLUDE into the timeline;
/// every other user is excluded /// 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`). /// Other users can block the entire list (creating a `StackBlock`, not a `UserBlock`).
BlockList, BlockList,
/// `users` vec contains ID of users who are allowed to view posts posted to the stack.
Circle,
} }
impl Default for StackMode { 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 { pub enum StackSort {
Created, Created,
Likes, Likes,
@ -48,7 +50,7 @@ impl Default for StackSort {
} }
} }
#[derive(Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub struct UserStack { pub struct UserStack {
pub id: usize, pub id: usize,
pub created: usize, pub created: usize,
@ -76,7 +78,7 @@ impl UserStack {
} }
} }
#[derive(Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub struct StackBlock { pub struct StackBlock {
pub id: usize, pub id: usize,
pub created: usize, pub created: usize,

View file

@ -1,6 +1,6 @@
[package] [package]
name = "tetratto-l10n" name = "tetratto-l10n"
version = "7.0.0" version = "8.0.0"
edition = "2024" edition = "2024"
authors.workspace = true authors.workspace = true
repository.workspace = true repository.workspace = true

View file

@ -1,6 +1,6 @@
[package] [package]
name = "tetratto-shared" name = "tetratto-shared"
version = "7.0.0" version = "8.0.0"
edition = "2024" edition = "2024"
authors.workspace = true authors.workspace = true
repository.workspace = true repository.workspace = true

View file

@ -1,2 +1,5 @@
ALTER TABLE posts ALTER TABLE posts
ADD COLUMN circle BIGINT NOT NULL DEFAULT 0; ADD COLUMN circle BIGINT NOT NULL DEFAULT 0;
ALTER TABLE posts
ADD COLUMN stack BIGINT NOT NULL DEFAULT 0;