diff --git a/Cargo.lock b/Cargo.lock index c67c3d3..5a57149 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3155,7 +3155,7 @@ dependencies = [ [[package]] name = "tetratto" -version = "1.0.2" +version = "1.0.3" dependencies = [ "ammonia", "axum", @@ -3180,7 +3180,7 @@ dependencies = [ [[package]] name = "tetratto-core" -version = "1.0.2" +version = "1.0.3" dependencies = [ "async-recursion", "bb8-postgres", @@ -3199,7 +3199,7 @@ dependencies = [ [[package]] name = "tetratto-l10n" -version = "1.0.2" +version = "1.0.3" dependencies = [ "pathbufd", "serde", @@ -3208,7 +3208,7 @@ dependencies = [ [[package]] name = "tetratto-shared" -version = "1.0.2" +version = "1.0.3" dependencies = [ "ammonia", "chrono", diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 7c582ee..5217ce8 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto" -version = "1.0.2" +version = "1.0.3" edition = "2024" [features] diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs index c5b239d..41333f7 100644 --- a/crates/app/src/assets.rs +++ b/crates/app/src/assets.rs @@ -35,6 +35,7 @@ pub const MISC_INDEX: &str = include_str!("./public/html/misc/index.html"); pub const MISC_ERROR: &str = include_str!("./public/html/misc/error.html"); pub const MISC_NOTIFICATIONS: &str = include_str!("./public/html/misc/notifications.html"); pub const MISC_MARKDOWN: &str = include_str!("./public/html/misc/markdown.html"); +pub const MISC_REQUESTS: &str = include_str!("./public/html/misc/requests.html"); pub const AUTH_BASE: &str = include_str!("./public/html/auth/base.html"); pub const AUTH_LOGIN: &str = include_str!("./public/html/auth/login.html"); @@ -56,6 +57,8 @@ pub const COMMUNITIES_MEMBERS: &str = include_str!("./public/html/communities/me pub const COMMUNITIES_SEARCH: &str = include_str!("./public/html/communities/search.html"); pub const COMMUNITIES_CREATE_POST: &str = include_str!("./public/html/communities/create_post.html"); +pub const COMMUNITIES_QUESTION: &str = include_str!("./public/html/communities/question.html"); +pub const COMMUNITIES_QUESTIONS: &str = include_str!("./public/html/communities/questions.html"); pub const TIMELINES_HOME: &str = include_str!("./public/html/timelines/home.html"); pub const TIMELINES_POPULAR: &str = include_str!("./public/html/timelines/popular.html"); @@ -169,6 +172,7 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD { write_template!(html_path->"misc/error.html"(crate::assets::MISC_ERROR) --config=config); write_template!(html_path->"misc/notifications.html"(crate::assets::MISC_NOTIFICATIONS) --config=config); write_template!(html_path->"misc/markdown.html"(crate::assets::MISC_MARKDOWN) --config=config); + write_template!(html_path->"misc/requests.html"(crate::assets::MISC_REQUESTS) --config=config); write_template!(html_path->"auth/base.html"(crate::assets::AUTH_BASE) -d "auth" --config=config); write_template!(html_path->"auth/login.html"(crate::assets::AUTH_LOGIN) --config=config); @@ -189,6 +193,8 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD { write_template!(html_path->"communities/members.html"(crate::assets::COMMUNITIES_MEMBERS) --config=config); write_template!(html_path->"communities/search.html"(crate::assets::COMMUNITIES_SEARCH) --config=config); write_template!(html_path->"communities/create_post.html"(crate::assets::COMMUNITIES_CREATE_POST) --config=config); + write_template!(html_path->"communities/question.html"(crate::assets::COMMUNITIES_QUESTION) --config=config); + write_template!(html_path->"communities/questions.html"(crate::assets::COMMUNITIES_QUESTIONS) --config=config); write_template!(html_path->"timelines/home.html"(crate::assets::TIMELINES_HOME) -d "timelines" --config=config); write_template!(html_path->"timelines/popular.html"(crate::assets::TIMELINES_POPULAR) --config=config); diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index af1b60e..b40b671 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -19,6 +19,7 @@ version = "1.0.0" "general:action.back" = "Back" "general:action.report" = "Report" "general:action.manage" = "Manage" +"general:action.open" = "Open" "general:label.safety" = "Safety" "general:label.share" = "Share" "general:action.add_account" = "Add account" @@ -65,6 +66,7 @@ version = "1.0.0" "communities:label.create_post" = "Create post" "communities:label.content" = "Content" "communities:label.posts" = "Posts" +"communities:label.questions" = "Questions" "communities:label.not_allowed_to_read" = "You're not allowed to view this community's posts" "communities:label.might_need_to_join" = "You might need to join this community in order to interact with it!" "communities:label.create_reply" = "Create reply" @@ -86,6 +88,8 @@ version = "1.0.0" "communities:label.search_results" = "Search results" "communities:label.query" = "Query" "communities:label.join_new" = "Join new" +"communities:tab.posts" = "Posts" +"communities:tab.questions" = "Questions" "notifs:action.mark_as_read" = "Mark as read" "notifs:action.mark_as_unread" = "Mark as unread" @@ -116,3 +120,9 @@ version = "1.0.0" "mod_panel:label.permissions_level_builder" = "Permission level builder" "mod_panel:label.warnings" = "Warnings" "mod_panel:label.create_warning" = "Create warning" + +"requests:label.requests" = "Requests" +"requests:label.community_join_request" = "Community join request" +"requests:label.review" = "Review" +"requests:label.ask_question" = "Ask question" +"requests:label.answer" = "Answer" diff --git a/crates/app/src/public/html/communities/feed.html b/crates/app/src/public/html/communities/feed.html index 7fc3778..7a49aa8 100644 --- a/crates/app/src/public/html/communities/feed.html +++ b/crates/app/src/public/html/communities/feed.html @@ -1,7 +1,8 @@ {% import "components.html" as components %} {% extends "communities/base.html" %} {% block content %}
- {% if user and can_post %} + {{ macros::community_nav(community=community, selected="posts") }} {% if + user and can_post %}
{{ icon "pencil" }} @@ -45,7 +46,7 @@ {% if post[0].context.repost and post[0].context.repost.reposting %} {{ components::repost(repost=post[2], post=post[0], owner=post[1], secondary=true, show_community=false, can_manage_post=can_manage_posts) }} {% else %} - {{ components::post(post=post[0], owner=post[1], secondary=true, show_community=false, can_manage_post=can_manage_posts) }} + {{ components::post(post=post[0], owner=post[1], question=post[3], secondary=true, show_community=false, can_manage_post=can_manage_posts) }} {% endif %} {% endfor %}
@@ -64,7 +65,7 @@ {% if post[0].context.repost and post[0].context.repost.reposting %} {{ components::repost(repost=post[2], post=post[0], owner=post[1], secondary=true, show_community=false, can_manage_post=can_manage_posts) }} {% else %} - {{ components::post(post=post[0], owner=post[1], secondary=true, show_community=false, can_manage_post=can_manage_posts) }} + {{ components::post(post=post[0], owner=post[1], question=post[3], secondary=true, show_community=false, can_manage_post=can_manage_posts) }} {% endif %} {% endfor %} diff --git a/crates/app/src/public/html/communities/post.html b/crates/app/src/public/html/communities/post.html index b7a36be..a62c4e2 100644 --- a/crates/app/src/public/html/communities/post.html +++ b/crates/app/src/public/html/communities/post.html @@ -14,7 +14,7 @@ {% if post.context.repost and post.context.repost.reposting %} {{ components::repost(repost=reposting, post=post, owner=owner, community=community, show_community=true, can_manage_post=can_manage_posts) }} {% else %} - {{ components::post(post=post, owner=owner, community=community, show_community=true, can_manage_post=can_manage_posts) }} + {{ components::post(post=post, owner=owner, question=question, community=community, show_community=true, can_manage_post=can_manage_posts) }} {% endif %}
@@ -231,7 +231,7 @@
{% for post in replies %} - {{ components::post(post=post[0], owner=post[1], secondary=true, show_community=false) }} + {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, show_community=false) }} {% endfor %} {{ components::pagination(page=page, items=replies|length) }} diff --git a/crates/app/src/public/html/communities/question.html b/crates/app/src/public/html/communities/question.html new file mode 100644 index 0000000..a548070 --- /dev/null +++ b/crates/app/src/public/html/communities/question.html @@ -0,0 +1,88 @@ +{% extends "root.html" %} {% block head %} +Question - {{ config.name }} +{% endblock %} {% block body %} {{ macros::nav() }} +
+
+ {{ components::question(question=question, owner=owner) }} +
+ + {% if user and (user.id == question.receiver or question.is_global) and not + has_answered %} +
+
+ {{ icon "square-pen" }} + {{ text "requests:label.answer" }} +
+ +
+
+ + +
+ + +
+
+ {% endif %} + +
+
+ {{ icon "newspaper" }} + {{ text "communities:label.replies" }} +
+ +
+ + {% for post in replies %} + {{ components::post(post=post[0], owner=post[1], question=false, secondary=true, show_community=false) }} + {% endfor %} + + {{ components::pagination(page=page, items=replies|length) }} +
+
+
+ + +{% endblock %} diff --git a/crates/app/src/public/html/communities/questions.html b/crates/app/src/public/html/communities/questions.html new file mode 100644 index 0000000..a4f11aa --- /dev/null +++ b/crates/app/src/public/html/communities/questions.html @@ -0,0 +1,45 @@ +{% import "components.html" as components %} {% extends "communities/base.html" +%} {% block content %} +
+ {{ macros::community_nav(community=community, selected="questions") }} + + + {% if user and can_post %} +
+ {{ components::create_question_form(community=community.id, + is_global=true) }} +
+ {% endif %} + +
+
+ {{ icon "newspaper" }} + {{ text "communities:label.questions" }} +
+ +
+ + {% for question in feed %} +
+ {{ components::question(question=question[0], owner=question[1], + show_community=false) }} + + +
+ {% endfor %} {{ components::pagination(page=page, items=feed|length) + }} +
+
+
+{% endblock %} diff --git a/crates/app/src/public/html/communities/settings.html b/crates/app/src/public/html/communities/settings.html index 55c8665..f2eb015 100644 --- a/crates/app/src/public/html/communities/settings.html +++ b/crates/app/src/public/html/communities/settings.html @@ -567,6 +567,14 @@ "{{ community.context.is_nsfw }}", "checkbox", ], + [ + [ + "enable_questions", + "Allow users to ask questions in this community", + ], + "{{ community.context.enable_questions }}", + "checkbox", + ], ], settings, ); diff --git a/crates/app/src/public/html/components.html b/crates/app/src/public/html/components.html index bbc3b98..6ecd789 100644 --- a/crates/app/src/public/html/components.html +++ b/crates/app/src/public/html/components.html @@ -143,10 +143,12 @@ community=false, show_community=true, can_manage_post=false) -%} );
-{%- endmacro %} {% macro post(post, owner, secondary=false, community=false, -show_community=true, can_manage_post=false) -%} {% if community and -show_community and community.id != config.town_square %} +{%- endmacro %} {% macro post(post, owner, question=false, secondary=false, +community=false, show_community=true, can_manage_post=false) -%} {% if community +and show_community and community.id != config.town_square or question %}
+ {% if question %} {{ components::question(question=question[0], + owner=question[1]) }} {% else %} - {% endif %} + {% endif %} {% endif %} - {% if community and show_community and community.id != config.town_square %} + {% if community and show_community and community.id != config.town_square or + question %}
{% endif %} {%- endmacro %} {% macro notification(notification) -%}
@@ -364,7 +367,7 @@ show_community and community.id != config.town_square %} {% if notification.read %} + +
+ + +{%- endmacro %} diff --git a/crates/app/src/public/html/macros.html b/crates/app/src/public/html/macros.html index 881bf2d..550ce74 100644 --- a/crates/app/src/public/html/macros.html +++ b/crates/app/src/public/html/macros.html @@ -15,14 +15,6 @@ {{ text "general:link.home" }} - - {{ icon "trending-up" }} - {{ text "general:link.popular" }} - - {% if user %} + + {{ icon "inbox" }} {% if user.request_count > 0 %} + {{ user.request_count }} + {% endif %} + + {% endif %}
-{%- endmacro %} +{%- endmacro %} {% macro community_nav(community, selected="") -%} {% if +community.context.enable_questions %} +
+ + {{ icon "newspaper" }} + {{ text "communities:tab.posts" }} + + + + {{ icon "message-circle-heart" }} + {{ text "communities:tab.questions" }} + +
+{% endif %} {%- endmacro %} diff --git a/crates/app/src/public/html/misc/notifications.html b/crates/app/src/public/html/misc/notifications.html index f6e382d..248875d 100644 --- a/crates/app/src/public/html/misc/notifications.html +++ b/crates/app/src/public/html/misc/notifications.html @@ -11,7 +11,7 @@ + + +
+ {% for request in requests %} {% if request.action_type == + "CommunityJoin" %} +
+
+ {{ icon "user-plus" }} + {{ text "requests:label.community_join_request" + }} +
+ +
+ + {{ icon "external-link" }} + {{ text "requests:label.review" }} + + + +
+
+ {% endif %} {% endfor %} {% for question in questions %} + +
+ {{ components::question(question=question[0], owner=question[1]) }} + +
+
+ + +
+ +
+ + +
+
+
+ {% endfor %} +
+ + + + +{% endblock %} diff --git a/crates/app/src/public/html/profile/base.html b/crates/app/src/public/html/profile/base.html index 138a076..f70ad7b 100644 --- a/crates/app/src/public/html/profile/base.html +++ b/crates/app/src/public/html/profile/base.html @@ -121,6 +121,11 @@ {{ profile.created }} +
+ Posts + {{ profile.post_count }} +
+ {% if not profile.settings.private_last_seen or is_self or is_helper %}
diff --git a/crates/app/src/public/html/profile/posts.html b/crates/app/src/public/html/profile/posts.html index 7cd69e3..5d9d687 100644 --- a/crates/app/src/public/html/profile/posts.html +++ b/crates/app/src/public/html/profile/posts.html @@ -1,5 +1,9 @@ -{% extends "profile/base.html" %} {% block content %} {% if pinned|length != 0 -%} +{% extends "profile/base.html" %} {% block content %} {% if +profile.settings.enable_questions and user %} +
+ {{ components::create_question_form(receiver=profile.id) }} +
+{% endif %} {% if pinned|length != 0 %}
{{ icon "pin" }} @@ -12,7 +16,7 @@ {% 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, can_manage_post=is_self) }} {% else %} - {{ components::post(post=post[0], owner=post[1], secondary=true, community=post[2], can_manage_post=is_self) }} + {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], can_manage_post=is_self) }} {% endif %} {% endfor %}
@@ -31,7 +35,7 @@ {% 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, can_manage_post=is_self) }} {% else %} - {{ components::post(post=post[0], owner=post[1], secondary=true, community=post[2], can_manage_post=is_self) }} + {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], can_manage_post=is_self) }} {% endif %} {% endfor %} diff --git a/crates/app/src/public/html/profile/settings.html b/crates/app/src/public/html/profile/settings.html index c766c1a..b3ba3dc 100644 --- a/crates/app/src/public/html/profile/settings.html +++ b/crates/app/src/public/html/profile/settings.html @@ -740,6 +740,14 @@ profile_settings, [ [[], "Privacy", "title"], + [ + [ + "enable_questions", + "Allow users to ask you questions", + ], + "{{ profile.settings.enable_questions }}", + "checkbox", + ], [ [ "private_profile", diff --git a/crates/app/src/public/html/timelines/all.html b/crates/app/src/public/html/timelines/all.html index 592fcf1..175fcdc 100644 --- a/crates/app/src/public/html/timelines/all.html +++ b/crates/app/src/public/html/timelines/all.html @@ -10,7 +10,7 @@ {% 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], secondary=true, community=post[2]) }} + {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2]) }} {% endif %} {% endfor %} diff --git a/crates/app/src/public/html/timelines/following.html b/crates/app/src/public/html/timelines/following.html index 3ed5d31..91d1891 100644 --- a/crates/app/src/public/html/timelines/following.html +++ b/crates/app/src/public/html/timelines/following.html @@ -10,7 +10,7 @@ {% 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], secondary=true, community=post[2]) }} + {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2]) }} {% endif %} {% endfor %} diff --git a/crates/app/src/public/html/timelines/home.html b/crates/app/src/public/html/timelines/home.html index 6a596a7..3ac0785 100644 --- a/crates/app/src/public/html/timelines/home.html +++ b/crates/app/src/public/html/timelines/home.html @@ -28,7 +28,7 @@ {% 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], secondary=true, community=post[2]) }} + {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2]) }} {% endif %} {% endfor %} diff --git a/crates/app/src/public/html/timelines/popular.html b/crates/app/src/public/html/timelines/popular.html index 2840e39..82a6eaa 100644 --- a/crates/app/src/public/html/timelines/popular.html +++ b/crates/app/src/public/html/timelines/popular.html @@ -10,7 +10,7 @@ {% 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], secondary=true, community=post[2]) }} + {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2]) }} {% endif %} {% endfor %} diff --git a/crates/app/src/public/js/me.js b/crates/app/src/public/js/me.js index c6e7748..826dd8c 100644 --- a/crates/app/src/public/js/me.js +++ b/crates/app/src/public/js/me.js @@ -109,7 +109,7 @@ }); }); - self.define("update_notification_read_statsu", (_, id, read) => { + self.define("update_notification_read_status", (_, id, read) => { fetch(`/api/v1/notifications/${id}/read_status`, { method: "POST", headers: { diff --git a/crates/app/src/routes/api/v1/communities/mod.rs b/crates/app/src/routes/api/v1/communities/mod.rs index 763fd1d..6e4b3bf 100644 --- a/crates/app/src/routes/api/v1/communities/mod.rs +++ b/crates/app/src/routes/api/v1/communities/mod.rs @@ -1,3 +1,4 @@ pub mod communities; pub mod images; pub mod posts; +pub mod questions; diff --git a/crates/app/src/routes/api/v1/communities/posts.rs b/crates/app/src/routes/api/v1/communities/posts.rs index 0fe8b32..a4a11d8 100644 --- a/crates/app/src/routes/api/v1/communities/posts.rs +++ b/crates/app/src/routes/api/v1/communities/posts.rs @@ -19,25 +19,32 @@ pub async fn create_request( None => return Json(Error::NotAllowed.into()), }; - match data - .create_post(Post::new( - req.content, - match req.community.parse::() { - Ok(x) => x, + let mut props = Post::new( + req.content, + match req.community.parse::() { + Ok(x) => x, + Err(e) => return Json(Error::MiscError(e.to_string()).into()), + }, + if let Some(rt) = req.replying_to { + match rt.parse::() { + Ok(x) => Some(x), Err(e) => return Json(Error::MiscError(e.to_string()).into()), - }, - if let Some(rt) = req.replying_to { - match rt.parse::() { - Ok(x) => Some(x), - Err(e) => return Json(Error::MiscError(e.to_string()).into()), - } - } else { - None - }, - user.id, - )) - .await - { + } + } else { + None + }, + user.id, + ); + + if !req.answering.is_empty() { + // we're answering a question! + props.context.answering = match req.answering.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 created".to_string(), diff --git a/crates/app/src/routes/api/v1/communities/questions.rs b/crates/app/src/routes/api/v1/communities/questions.rs new file mode 100644 index 0000000..2e26fa5 --- /dev/null +++ b/crates/app/src/routes/api/v1/communities/questions.rs @@ -0,0 +1,65 @@ +use axum::{Extension, Json, extract::Path, response::IntoResponse}; +use axum_extra::extract::CookieJar; +use tetratto_core::model::{communities::Question, ApiReturn, Error}; +use crate::{get_user_from_token, routes::api::v1::CreateQuestion, State}; + +pub async fn create_request( + jar: CookieJar, + Extension(data): Extension, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let mut props = Question::new( + user.id, + match req.receiver.parse::() { + Ok(x) => x, + Err(e) => return Json(Error::MiscError(e.to_string()).into()), + }, + req.content, + req.is_global, + ); + + if !req.community.is_empty() { + props.is_global = true; + props.receiver = 0; + props.community = match req.community.parse::() { + Ok(x) => x, + Err(e) => return Json(Error::MiscError(e.to_string()).into()), + } + } + + match data.create_question(props).await { + Ok(id) => Json(ApiReturn { + ok: true, + message: "Question created".to_string(), + payload: Some(id.to_string()), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn delete_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.delete_question(id, &user).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Question deleted".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 7f745f4..cdaee07 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -3,6 +3,7 @@ pub mod communities; pub mod notifications; pub mod reactions; pub mod reports; +pub mod requests; pub mod util; use axum::{ @@ -93,6 +94,12 @@ pub fn routes() -> Router { "/posts/{id}/context", post(communities::posts::update_context_request), ) + // questions + .route("/questions", post(communities::questions::create_request)) + .route( + "/questions/{id}", + delete(communities::questions::delete_request), + ) // auth // global .route("/auth/register", post(auth::register_request)) @@ -201,6 +208,12 @@ pub fn routes() -> Router { // reports .route("/reports", post(reports::create_request)) .route("/reports/{id}", delete(reports::delete_request)) + // requests + .route( + "/requests/{id}/{linked_asset}", + delete(requests::delete_request), + ) + .route("/requests/my", delete(requests::delete_all_request)) } #[derive(Deserialize)] @@ -255,6 +268,8 @@ pub struct CreatePost { pub community: String, #[serde(default)] pub replying_to: Option, + #[serde(default)] + pub answering: String, } #[derive(Deserialize)] @@ -337,3 +352,13 @@ pub struct DisableTotp { pub struct CreateUserWarning { pub content: String, } + +#[derive(Deserialize)] +pub struct CreateQuestion { + pub content: String, + pub is_global: bool, + #[serde(default)] + pub receiver: String, + #[serde(default)] + pub community: String, +} diff --git a/crates/app/src/routes/api/v1/requests.rs b/crates/app/src/routes/api/v1/requests.rs new file mode 100644 index 0000000..3e43c50 --- /dev/null +++ b/crates/app/src/routes/api/v1/requests.rs @@ -0,0 +1,45 @@ +use crate::{State, get_user_from_token}; +use axum::{Extension, Json, extract::Path, response::IntoResponse}; +use axum_extra::extract::CookieJar; +use tetratto_core::model::{ApiReturn, Error}; + +pub async fn delete_request( + jar: CookieJar, + Extension(data): Extension, + Path((id, linked_asset)): Path<(usize, usize)>, +) -> 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_request(id, linked_asset, &user).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Request deleted".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn delete_all_request( + jar: CookieJar, + Extension(data): Extension, +) -> 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_all_requests(&user).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Requests cleared".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} diff --git a/crates/app/src/routes/pages/communities.rs b/crates/app/src/routes/pages/communities.rs index 5bb8fc4..77d5ebd 100644 --- a/crates/app/src/routes/pages/communities.rs +++ b/crates/app/src/routes/pages/communities.rs @@ -362,6 +362,93 @@ pub async fn feed_request( )) } +/// `/community/{title}/questions` +pub async fn questions_request( + jar: CookieJar, + Path(title): Path, + Query(props): Query, + Extension(data): Extension, +) -> impl IntoResponse { + let data = data.read().await; + let user = get_user_from_token!(jar, data.0); + + let community = match data.0.get_community_by_title(&title.to_lowercase()).await { + Ok(ua) => ua, + Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), + }; + + if community.id == 0 { + // don't show page for void community + return Err(Html( + render_error( + Error::GeneralNotFound("community".to_string()), + &jar, + &data, + &user, + ) + .await, + )); + } + + if !community.context.enable_questions { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &user).await, + )); + } + + // check permissions + let (can_read, _) = check_permissions!(community, jar, data, user); + + // ... + let feed = match data + .0 + .get_questions_by_community(community.id, 12, props.page) + .await + { + Ok(p) => match data.0.fill_questions(p).await { + Ok(p) => p, + Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), + }, + Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), + }; + + // init context + let lang = get_lang!(jar, data.0); + let mut context = initial_context(&data.0.0, lang, &user).await; + + let ( + is_owner, + is_joined, + is_pending, + can_post, + can_manage_posts, + can_manage_community, + can_manage_roles, + ) = community_context_bools!(data, user, community); + + context.insert("feed", &feed); + context.insert("page", &props.page); + community_context( + &mut context, + &community, + is_owner, + is_joined, + is_pending, + can_post, + can_read, + can_manage_posts, + can_manage_community, + can_manage_roles, + ); + + // return + Ok(Html( + data.1 + .render("communities/questions.html", &context) + .unwrap(), + )) +} + /// `/community/{id}/manage` pub async fn settings_request( jar: CookieJar, @@ -440,26 +527,12 @@ pub async fn post_request( }; // check repost - let reposting = if let Some(ref repost) = post.context.repost { - if let Some(reposting) = repost.reposting { - let mut x = match data.0.get_post_by_id(reposting).await { - Ok(p) => p, - Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), - }; + let reposting = data.0.get_post_reposting(&post).await; - x.mark_as_repost(); - Some(( - match data.0.get_user_by_id(x.owner).await { - Ok(ua) => ua, - Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), - }, - x, - )) - } else { - None - } - } else { - None + // check question + let question = match data.0.get_post_question(&post).await { + Ok(q) => q, + Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), }; // check permissions @@ -490,6 +563,7 @@ pub async fn post_request( context.insert("post", &post); context.insert("reposting", &reposting); + context.insert("question", &question); context.insert("replies", &feed); context.insert("page", &props.page); context.insert( @@ -612,3 +686,96 @@ pub async fn members_request( data.1.render("communities/members.html", &context).unwrap(), )) } + +/// `/question/{id}` +pub async fn question_request( + jar: CookieJar, + Path(id): Path, + Query(props): Query, + Extension(data): Extension, +) -> impl IntoResponse { + let data = data.read().await; + let user = get_user_from_token!(jar, data.0); + + let question = match data.0.get_question_by_id(id).await { + Ok(p) => p, + Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), + }; + + let community = match data.0.get_community_by_id(question.community).await { + Ok(c) => c, + Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), + }; + + let has_answered = if let Some(ref ua) = user { + data.0 + .get_post_by_owner_question(ua.id, question.id) + .await + .is_ok() + } else { + false + }; + + // check permissions + let (can_read, _) = check_permissions!(community, jar, data, user); + + // ... + let feed = match data + .0 + .get_posts_by_question(question.id, 12, props.page) + .await + { + Ok(p) => match data.0.fill_posts(p).await { + Ok(p) => p, + Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), + }, + Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), + }; + + // init context + let lang = get_lang!(jar, data.0); + let mut context = initial_context(&data.0.0, lang, &user).await; + + let ( + is_owner, + is_joined, + is_pending, + can_post, + can_manage_posts, + can_manage_community, + can_manage_roles, + ) = community_context_bools!(data, user, community); + + context.insert("question", &question); + context.insert("replies", &feed); + context.insert("page", &props.page); + context.insert( + "owner", + &data + .0 + .get_user_by_id(question.owner) + .await + .unwrap_or(User::deleted()), + ); + context.insert("has_answered", &has_answered); + + community_context( + &mut context, + &community, + is_owner, + is_joined, + is_pending, + can_post, + can_read, + can_manage_posts, + can_manage_community, + can_manage_roles, + ); + + // return + Ok(Html( + data.1 + .render("communities/question.html", &context) + .unwrap(), + )) +} diff --git a/crates/app/src/routes/pages/misc.rs b/crates/app/src/routes/pages/misc.rs index d76d2d7..49112d3 100644 --- a/crates/app/src/routes/pages/misc.rs +++ b/crates/app/src/routes/pages/misc.rs @@ -6,7 +6,7 @@ use axum::{ Extension, }; use axum_extra::extract::CookieJar; -use tetratto_core::model::Error; +use tetratto_core::model::{requests::ActionType, Error}; use std::fs::read_to_string; use pathbufd::PathBufD; @@ -188,6 +188,59 @@ pub async fn notifications_request( )) } +/// `/requests` +pub async fn requests_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 requests = match data.0.get_requests_by_owner(user.id).await { + Ok(p) => p, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }; + + let questions = match data + .0 + .fill_questions({ + let mut q = Vec::new(); + + for req in &requests { + if req.action_type != ActionType::Answer { + continue; + } + + q.push(match data.0.get_question_by_id(req.linked_asset).await { + Ok(p) => p, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }); + } + + q + }) + .await + { + Ok(q) => q, + 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("requests", &requests); + context.insert("questions", &questions); + + // return + Ok(Html(data.1.render("misc/requests.html", &context).unwrap())) +} + /// `/doc/{file_name}` pub async fn markdown_document_request( jar: CookieJar, diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs index 08aaf8d..cb9db32 100644 --- a/crates/app/src/routes/pages/mod.rs +++ b/crates/app/src/routes/pages/mod.rs @@ -22,6 +22,7 @@ pub fn routes() -> Router { .route("/following", get(misc::following_request)) .route("/all", get(misc::all_request)) .route("/notifs", get(misc::notifications_request)) + .route("/requests", get(misc::requests_request)) .route("/doc/{*file_name}", get(misc::markdown_document_request)) .fallback_service(get(misc::not_found)) // mod @@ -56,12 +57,17 @@ pub fn routes() -> Router { get(communities::create_post_request), ) .route("/community/{title}", get(communities::feed_request)) + .route( + "/community/{title}/questions", + get(communities::questions_request), + ) .route("/community/{id}/manage", get(communities::settings_request)) .route( "/community/{title}/members", get(communities::members_request), ) .route("/post/{id}", get(communities::post_request)) + .route("/question/{id}", get(communities::question_request)) } pub async fn render_error( diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 684ca77..1c567bd 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto-core" -version = "1.0.2" +version = "1.0.3" edition = "2024" [features] diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs index 627db30..cdbe247 100644 --- a/crates/core/src/database/auth.rs +++ b/crates/core/src/database/auth.rs @@ -41,12 +41,39 @@ impl DataManager { last_seen: get!(x->12(i64)) as usize, totp: get!(x->13(String)), recovery_codes: serde_json::from_str(&get!(x->14(String)).to_string()).unwrap(), + post_count: get!(x->15(i32)) as usize, + request_count: get!(x->16(i32)) as usize, } } auto_method!(get_user_by_id(usize as i64)@get_user_from_row -> "SELECT * FROM users WHERE id = $1" --name="user" --returns=User --cache-key-tmpl="atto.user:{}"); auto_method!(get_user_by_username(&str)@get_user_from_row -> "SELECT * FROM users WHERE username = $1" --name="user" --returns=User --cache-key-tmpl="atto.user:{}"); + /// Get a user given just their ID. Returns the void user if the user doesn't exist. + /// + /// # Arguments + /// * `id` - the ID of the user + pub async fn get_user_by_id_with_void(&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_row!( + &conn, + "SELECT * FROM users WHERE id = $1", + &[&(id as i64)], + |x| Ok(Self::get_user_from_row(x)) + ); + + if res.is_err() { + return Ok(User::deleted()); + // return Err(Error::UserNotFound); + } + + Ok(res.unwrap()) + } + /// Get a user given just their auth token. /// /// # Arguments @@ -110,7 +137,7 @@ impl DataManager { let res = execute!( &conn, - "INSERT INTO users VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)", + "INSERT INTO users VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)", params![ &(data.id as i64), &(data.created as i64), @@ -126,7 +153,9 @@ impl DataManager { &0_i32, &(data.last_seen as i64), &String::new(), - &"[]" + &"[]", + &0_i32, + &0_i32 ] ); @@ -559,4 +588,10 @@ impl DataManager { auto_method!(incr_user_following_count()@get_user_by_id -> "UPDATE users SET following_count = following_count + 1 WHERE id = $1" --cache-key-tmpl=cache_clear_user --incr); auto_method!(decr_user_following_count()@get_user_by_id -> "UPDATE users SET following_count = following_count - 1 WHERE id = $1" --cache-key-tmpl=cache_clear_user --decr); + + auto_method!(incr_user_post_count()@get_user_by_id -> "UPDATE users SET post_count = post_count + 1 WHERE id = $1" --cache-key-tmpl=cache_clear_user --incr); + auto_method!(decr_user_post_count()@get_user_by_id -> "UPDATE users SET post_count = post_count - 1 WHERE id = $1" --cache-key-tmpl=cache_clear_user --decr); + + auto_method!(incr_user_request_count()@get_user_by_id -> "UPDATE users SET request_count = request_count + 1 WHERE id = $1" --cache-key-tmpl=cache_clear_user --incr); + auto_method!(decr_user_request_count()@get_user_by_id -> "UPDATE users SET request_count = request_count - 1 WHERE id = $1" --cache-key-tmpl=cache_clear_user --decr); } diff --git a/crates/core/src/database/common.rs b/crates/core/src/database/common.rs index 06e2061..95d3429 100644 --- a/crates/core/src/database/common.rs +++ b/crates/core/src/database/common.rs @@ -25,6 +25,8 @@ impl DataManager { execute!(&conn, common::CREATE_TABLE_AUDIT_LOG).unwrap(); execute!(&conn, common::CREATE_TABLE_REPORTS).unwrap(); execute!(&conn, common::CREATE_TABLE_USER_WARNINGS).unwrap(); + execute!(&conn, common::CREATE_TABLE_REQUESTS).unwrap(); + execute!(&conn, common::CREATE_TABLE_QUESTIONS).unwrap(); Ok(()) } diff --git a/crates/core/src/database/drivers/common.rs b/crates/core/src/database/drivers/common.rs index f6d8558..1dc424a 100644 --- a/crates/core/src/database/drivers/common.rs +++ b/crates/core/src/database/drivers/common.rs @@ -10,3 +10,5 @@ pub const CREATE_TABLE_IPBANS: &str = include_str!("./sql/create_ipbans.sql"); pub const CREATE_TABLE_AUDIT_LOG: &str = include_str!("./sql/create_audit_log.sql"); pub const CREATE_TABLE_REPORTS: &str = include_str!("./sql/create_reports.sql"); pub const CREATE_TABLE_USER_WARNINGS: &str = include_str!("./sql/create_user_warnings.sql"); +pub const CREATE_TABLE_REQUESTS: &str = include_str!("./sql/create_requests.sql"); +pub const CREATE_TABLE_QUESTIONS: &str = include_str!("./sql/create_questions.sql"); diff --git a/crates/core/src/database/drivers/sql/create_questions.sql b/crates/core/src/database/drivers/sql/create_questions.sql new file mode 100644 index 0000000..ebea0cc --- /dev/null +++ b/crates/core/src/database/drivers/sql/create_questions.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS questions ( + id BIGINT NOT NULL PRIMARY KEY, + created BIGINT NOT NULL, + owner BIGINT NOT NULL, + receiver BIGINT NOT NULL, + content TEXT NOT NULL, + is_global INT NOT NULL, + answer_count INT NOT NULL, + community BIGINT NOT NULL +) diff --git a/crates/core/src/database/drivers/sql/create_requests.sql b/crates/core/src/database/drivers/sql/create_requests.sql new file mode 100644 index 0000000..fb64684 --- /dev/null +++ b/crates/core/src/database/drivers/sql/create_requests.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS requests ( + id BIGINT NOT NULL PRIMARY KEY, + created BIGINT NOT NULL, + owner BIGINT NOT NULL, + action_type TEXT NOT NULL, + linked_asset BIGINT NOT NULL +) diff --git a/crates/core/src/database/drivers/sql/create_users.sql b/crates/core/src/database/drivers/sql/create_users.sql index e4d7c19..92dda7e 100644 --- a/crates/core/src/database/drivers/sql/create_users.sql +++ b/crates/core/src/database/drivers/sql/create_users.sql @@ -11,5 +11,9 @@ CREATE TABLE IF NOT EXISTS users ( notification_count INT NOT NULL, follower_count INT NOT NULL, following_count INT NOT NULL, - last_seen BIGINT NOT NULL + last_seen BIGINT NOT NULL, + totp TEXT NOT NULL, + recovery_codes TEXT NOT NULL, + post_count INT NOT NULL, + request_count INT NOT NULL ) diff --git a/crates/core/src/database/memberships.rs b/crates/core/src/database/memberships.rs index ef30ee7..346f3d3 100644 --- a/crates/core/src/database/memberships.rs +++ b/crates/core/src/database/memberships.rs @@ -1,7 +1,7 @@ use super::*; use crate::cache::Cache; -use crate::model::auth::Notification; use crate::model::communities::Community; +use crate::model::requests::{ActionRequest, ActionType}; use crate::model::{ Error, Result, auth::User, @@ -191,14 +191,12 @@ impl DataManager { let mut data = data.clone(); data.role = CommunityPermission::DEFAULT | CommunityPermission::REQUESTED; - // send notification to the owner - self.create_notification(Notification::new( - "You've received a community join request!".to_string(), - format!( - "[Somebody](/api/v1/auth/user/find/{}) is asking to join your [community](/community/{}).\n\n[Click here to review their request](/community/{}/manage?uid={}#/members).", - data.owner, data.community, data.community, data.owner - ), + // create join request + self.create_request(ActionRequest::with_id( + data.owner, community.owner, + ActionType::CommunityJoin, + community.id, )) .await?; diff --git a/crates/core/src/database/mod.rs b/crates/core/src/database/mod.rs index 9222af6..e602b5a 100644 --- a/crates/core/src/database/mod.rs +++ b/crates/core/src/database/mod.rs @@ -7,8 +7,10 @@ mod ipbans; mod memberships; mod notifications; mod posts; +mod questions; mod reactions; mod reports; +mod requests; mod user_warnings; mod userblocks; mod userfollows; diff --git a/crates/core/src/database/notifications.rs b/crates/core/src/database/notifications.rs index 95f64d4..d38a90f 100644 --- a/crates/core/src/database/notifications.rs +++ b/crates/core/src/database/notifications.rs @@ -51,7 +51,7 @@ impl DataManager { /// Create a new notification in the database. /// /// # Arguments - /// * `data` - a mock [`Reaction`] object to insert + /// * `data` - a mock [`Notification`] object to insert pub async fn create_notification(&self, data: Notification) -> Result<()> { let conn = match self.connect().await { Ok(c) => c, @@ -85,7 +85,9 @@ impl DataManager { pub async fn delete_notification(&self, id: usize, user: &User) -> Result<()> { let notification = self.get_notification_by_id(id).await?; - if user.id != notification.owner && !user.permissions.check(FinePermission::MANAGE_NOTIFICATIONS) { + if user.id != notification.owner + && !user.permissions.check(FinePermission::MANAGE_NOTIFICATIONS) + { return Err(Error::NotAllowed); } @@ -121,7 +123,9 @@ impl DataManager { let notifications = self.get_notifications_by_owner(user.id).await?; for notification in notifications { - if user.id != notification.owner && !user.permissions.check(FinePermission::MANAGE_NOTIFICATIONS) { + if user.id != notification.owner + && !user.permissions.check(FinePermission::MANAGE_NOTIFICATIONS) + { return Err(Error::NotAllowed); } diff --git a/crates/core/src/database/posts.rs b/crates/core/src/database/posts.rs index f65892b..b535d2e 100644 --- a/crates/core/src/database/posts.rs +++ b/crates/core/src/database/posts.rs @@ -3,6 +3,7 @@ use std::collections::HashMap; use super::*; use crate::cache::Cache; use crate::model::auth::Notification; +use crate::model::communities::Question; use crate::model::communities_permissions::CommunityPermission; use crate::model::moderation::AuditLogEntry; use crate::model::{ @@ -100,12 +101,23 @@ impl DataManager { } } + /// Get the question of a given post. + pub async fn get_post_question(&self, post: &Post) -> Result> { + if post.context.answering != 0 { + let question = self.get_question_by_id(post.context.answering).await?; + let user = self.get_user_by_id_with_void(question.owner).await?; + Ok(Some((question, user))) + } else { + Ok(None) + } + } + /// Complete a vector of just posts with their owner as well. pub async fn fill_posts( &self, posts: Vec, - ) -> Result)>> { - let mut out: Vec<(Post, User, Option<(User, Post)>)> = Vec::new(); + ) -> Result, Option<(Question, User)>)>> { + let mut out: Vec<(Post, User, Option<(User, Post)>, Option<(Question, User)>)> = Vec::new(); let mut users: HashMap = HashMap::new(); for post in posts { @@ -116,11 +128,17 @@ impl DataManager { post.clone(), user.clone(), self.get_post_reposting(&post).await, + self.get_post_question(&post).await?, )); } else { let user = self.get_user_by_id(owner).await?; users.insert(owner, user.clone()); - out.push((post.clone(), user, self.get_post_reposting(&post).await)); + out.push(( + post.clone(), + user, + self.get_post_reposting(&post).await, + self.get_post_question(&post).await?, + )); } } @@ -132,8 +150,22 @@ impl DataManager { &self, posts: Vec, user_id: usize, - ) -> Result)>> { - let mut out: Vec<(Post, User, Community, Option<(User, Post)>)> = Vec::new(); + ) -> Result< + Vec<( + Post, + User, + Community, + Option<(User, Post)>, + Option<(Question, User)>, + )>, + > { + let mut out: Vec<( + Post, + User, + Community, + Option<(User, Post)>, + Option<(Question, User)>, + )> = Vec::new(); let mut seen_before: HashMap<(usize, usize), (User, Community)> = HashMap::new(); let mut seen_user_follow_statuses: HashMap<(usize, usize), bool> = HashMap::new(); @@ -148,6 +180,7 @@ impl DataManager { user.clone(), community.to_owned(), self.get_post_reposting(&post).await, + self.get_post_question(&post).await?, )); } else { let user = self.get_user_by_id(owner).await?; @@ -186,6 +219,7 @@ impl DataManager { user, community, self.get_post_reposting(&post).await, + self.get_post_question(&post).await?, )); } } @@ -303,6 +337,66 @@ impl DataManager { Ok(res.unwrap()) } + /// Get all posts answering the given question (from most recent). + /// + /// # Arguments + /// * `id` - the ID of the question the requested posts belong to + /// * `batch` - the limit of posts in each page + /// * `page` - the page number + pub async fn get_posts_by_question( + &self, + id: usize, + batch: usize, + page: usize, + ) -> Result> { + let conn = match self.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = query_rows!( + &conn, + "SELECT * FROM posts WHERE context LIKE $1 ORDER BY created DESC LIMIT $2 OFFSET $3", + params![ + &format!("%\"answering\":{id}%"), + &(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 a post given its owner and question ID. + /// + /// # Arguments + /// * `owner` - the ID of the post owner + /// * `question` - the ID of the post question + pub async fn get_post_by_owner_question(&self, owner: usize, question: usize) -> Result { + let conn = match self.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = query_row!( + &conn, + "SELECT * FROM posts WHERE context LIKE $1 AND owner = $2 LIMIT 1", + params![&format!("%\"answering\":{question}%"), &(owner as i64),], + |x| { Ok(Self::get_post_from_row(x)) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("post".to_string())); + } + + Ok(res.unwrap()) + } + /// Get posts from all communities, sorted by likes. /// /// # Arguments @@ -508,6 +602,42 @@ impl DataManager { // mirror nsfw state data.context.is_nsfw = community.context.is_nsfw; + // remove request if we were answering a question + let owner = self.get_user_by_id(data.owner).await?; + if data.context.answering != 0 { + let question = self.get_question_by_id(data.context.answering).await?; + + // check if we've already answered this + if self + .get_post_by_owner_question(owner.id, question.id) + .await + .is_ok() + { + return Err(Error::MiscError( + "You've already answered this question".to_string(), + )); + } + + if !question.is_global { + self.delete_request(question.owner, question.id, &owner) + .await?; + } else { + self.incr_question_answer_count(data.context.answering) + .await?; + } + + // create notification for question owner + self.create_notification(Notification::new( + "Your question has received a new answer!".to_string(), + format!( + "[@{}](/api/v1/auth/user/find/{}) has answered your [question](/question/{}).", + owner.username, owner.id, question.id + ), + question.owner, + )) + .await?; + } + // check if we're reposting a post let reposting = if let Some(ref repost) = data.context.repost { if let Some(id) = repost.reposting { @@ -650,6 +780,9 @@ impl DataManager { } } + // increase user post count + self.incr_user_post_count(data.owner).await?; + // return Ok(data.id) } @@ -695,6 +828,22 @@ impl DataManager { self.decr_post_comments(replying_to).await.unwrap(); } + // decr user post count + let owner = self.get_user_by_id(y.owner).await?; + + if owner.post_count > 0 { + self.decr_user_post_count(y.owner).await?; + } + + // decr question answer count + if y.context.answering != 0 { + let question = self.get_question_by_id(y.context.answering).await?; + + if question.is_global { + self.incr_question_answer_count(y.context.answering).await?; + } + } + // return Ok(()) } @@ -707,6 +856,7 @@ impl DataManager { ) -> Result<()> { let y = self.get_post_by_id(id).await?; x.repost = y.context.repost; // cannot change repost settings at all + x.answering = y.context.answering; // cannot change answering settings at all let user_membership = self .get_membership_by_owner_community(user.id, y.community) diff --git a/crates/core/src/database/questions.rs b/crates/core/src/database/questions.rs new file mode 100644 index 0000000..2bb1fd0 --- /dev/null +++ b/crates/core/src/database/questions.rs @@ -0,0 +1,261 @@ +use std::collections::HashMap; + +use super::*; +use crate::cache::Cache; +use crate::model::{ + Error, Result, + communities::Question, + requests::{ActionRequest, ActionType}, + auth::User, + permissions::FinePermission, +}; +use crate::{auto_method, execute, get, query_row, query_rows, params}; + +#[cfg(feature = "sqlite")] +use rusqlite::Row; + +#[cfg(feature = "postgres")] +use tokio_postgres::Row; + +impl DataManager { + /// Get a [`Question`] from an SQL row. + pub(crate) fn get_question_from_row( + #[cfg(feature = "sqlite")] x: &Row<'_>, + #[cfg(feature = "postgres")] x: &Row, + ) -> Question { + Question { + id: get!(x->0(i64)) as usize, + created: get!(x->1(i64)) as usize, + owner: get!(x->2(i64)) as usize, + receiver: get!(x->3(i64)) as usize, + content: get!(x->4(String)), + is_global: get!(x->5(i32)) as i8 == 1, + answer_count: get!(x->6(i32)) as usize, + community: get!(x->7(i64)) as usize, + } + } + + auto_method!(get_question_by_id()@get_question_from_row -> "SELECT * FROM questions WHERE id = $1" --name="question" --returns=Question --cache-key-tmpl="atto.question:{}"); + + /// Fill the given vector of questions with their owner as well. + pub async fn fill_questions(&self, questions: Vec) -> Result> { + let mut out: Vec<(Question, User)> = Vec::new(); + + let mut seen_users: HashMap = HashMap::new(); + for question in questions { + if let Some(ua) = seen_users.get(&question.owner) { + out.push((question, ua.to_owned())); + } else { + let user = self.get_user_by_id_with_void(question.owner).await?; + seen_users.insert(question.owner, user.clone()); + out.push((question, user)); + } + } + + Ok(out) + } + + /// Get all questions by `owner`. + pub async fn get_questions_by_owner(&self, owner: 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 questions WHERE owner = $1 ORDER BY created DESC", + &[&(owner as i64)], + |x| { Self::get_question_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("question".to_string())); + } + + Ok(res.unwrap()) + } + + /// Get all questions by `receiver`. + pub async fn get_questions_by_receiver(&self, receiver: 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 questions WHERE receiver = $1 ORDER BY created DESC", + &[&(receiver as i64)], + |x| { Self::get_question_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("question".to_string())); + } + + Ok(res.unwrap()) + } + + /// Get all global questions by `community`. + pub async fn get_questions_by_community( + &self, + community: usize, + batch: usize, + page: usize, + ) -> Result> { + let conn = match self.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = query_rows!( + &conn, + "SELECT * FROM questions WHERE community = $1 AND is_global = 1 ORDER BY created DESC LIMIT $2 OFFSET $3", + &[ + &(community as i64), + &(batch as i64), + &((page * batch) as i64) + ], + |x| { Self::get_question_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("question".to_string())); + } + + Ok(res.unwrap()) + } + + /// Create a new question in the database. + /// + /// # Arguments + /// * `data` - a mock [`Question`] object to insert + pub async fn create_question(&self, mut data: Question) -> Result { + // check if we can post this + if data.is_global { + if data.community > 0 { + // posting to community + data.receiver = 0; + let community = self.get_community_by_id(data.community).await?; + + if !community.context.enable_questions + | !self.check_can_post(&community, data.owner).await + { + return Err(Error::QuestionsDisabled); + } + } else { + let receiver = self.get_user_by_id(data.receiver).await?; + + if !receiver.settings.enable_questions { + return Err(Error::QuestionsDisabled); + } + } + } else { + let receiver = self.get_user_by_id(data.receiver).await?; + + if !receiver.settings.enable_questions { + return Err(Error::QuestionsDisabled); + } + } + + // ... + let conn = match self.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!( + &conn, + "INSERT INTO questions VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", + params![ + &(data.id as i64), + &(data.created as i64), + &(data.owner as i64), + &(data.receiver as i64), + &data.content, + &{ if data.is_global { 1 } else { 0 } }, + &0_i32, + &(data.community as i64) + ] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // create request + if !data.is_global { + self.create_request(ActionRequest::with_id( + data.owner, + data.receiver, + ActionType::Answer, + data.id, + )) + .await?; + } + + // return + Ok(data.id) + } + + pub async fn delete_question(&self, id: usize, user: &User) -> Result<()> { + let y = self.get_question_by_id(id).await?; + + if user.id != y.owner + && user.id != y.receiver + && !user.permissions.check(FinePermission::MANAGE_QUESTIONS) + { + 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 questions WHERE id = $1", + &[&(id as i64)] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + self.2.remove(format!("atto.question:{}", id)).await; + + // delete request (if it exists and question isn't global) + if !y.is_global + && self + .get_request_by_id_linked_asset(y.owner, y.id) + .await + .is_ok() + { + // requests are also deleted when a post is created answering the given question + // (unless the question is global) + self.delete_request(y.owner, y.id, &user).await?; + } + + // return + Ok(()) + } + + pub async fn delete_all_questions(&self, user: &User) -> Result<()> { + let y = self.get_questions_by_receiver(user.id).await?; + + for x in y { + if user.id != x.receiver && !user.permissions.check(FinePermission::MANAGE_QUESTIONS) { + return Err(Error::NotAllowed); + } + + self.delete_question(x.id, user).await? + } + + Ok(()) + } + + auto_method!(incr_question_answer_count() -> "UPDATE questions SET answer_count = answer_count + 1 WHERE id = $1" --cache-key-tmpl="atto.question:{}" --incr); + auto_method!(decr_question_answer_count() -> "UPDATE questions SET answer_count = answer_count - 1 WHERE id = $1" --cache-key-tmpl="atto.question:{}" --decr); +} diff --git a/crates/core/src/database/requests.rs b/crates/core/src/database/requests.rs new file mode 100644 index 0000000..2b17abb --- /dev/null +++ b/crates/core/src/database/requests.rs @@ -0,0 +1,169 @@ +use super::*; +use crate::cache::Cache; +use crate::model::requests::ActionType; +use crate::model::{Error, Result, requests::ActionRequest, auth::User, permissions::FinePermission}; +use crate::{execute, get, query_row, query_rows, params}; + +#[cfg(feature = "sqlite")] +use rusqlite::Row; + +#[cfg(feature = "postgres")] +use tokio_postgres::Row; + +impl DataManager { + /// Get an [`ActionRequest`] from an SQL row. + pub(crate) fn get_request_from_row( + #[cfg(feature = "sqlite")] x: &Row<'_>, + #[cfg(feature = "postgres")] x: &Row, + ) -> ActionRequest { + ActionRequest { + id: get!(x->0(i64)) as usize, + created: get!(x->1(i64)) as usize, + owner: get!(x->2(i64)) as usize, + action_type: serde_json::from_str(&get!(x->3(String))).unwrap(), + linked_asset: get!(x->4(i64)) as usize, + } + } + + pub async fn get_request_by_id_linked_asset( + &self, + id: usize, + linked_asset: usize, + ) -> Result { + if let Some(cached) = self + .2 + .get(format!("atto.request:{}:{}", id, linked_asset)) + .await + { + return Ok(serde_json::from_str(&cached).unwrap()); + } + + let conn = match self.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = query_row!( + &conn, + "SELECT * FROM requests WHERE id = $1 AND linked_asset = $2", + &[&(id as i64), &(linked_asset as i64)], + |x| { Ok(Self::get_request_from_row(x)) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("request".to_string())); + } + + let x = res.unwrap(); + self.2 + .set( + format!("atto.request:{}:{}", id, linked_asset), + serde_json::to_string(&x).unwrap(), + ) + .await; + + Ok(x) + } + + /// Get all action requests by `owner`. + pub async fn get_requests_by_owner(&self, owner: 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 requests WHERE owner = $1 ORDER BY created DESC", + &[&(owner as i64)], + |x| { Self::get_request_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("request".to_string())); + } + + Ok(res.unwrap()) + } + + /// Create a new request in the database. + /// + /// # Arguments + /// * `data` - a mock [`ActionRequest`] object to insert + pub async fn create_request(&self, data: ActionRequest) -> Result<()> { + let conn = match self.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!( + &conn, + "INSERT INTO requests VALUES ($1, $2, $3, $4, $5)", + params![ + &(data.id as i64), + &(data.created as i64), + &(data.owner as i64), + &serde_json::to_string(&data.action_type).unwrap().as_str(), + &(data.linked_asset as i64), + ] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // incr request count + self.incr_user_request_count(data.owner).await.unwrap(); + + // return + Ok(()) + } + + pub async fn delete_request(&self, id: usize, linked_asset: usize, user: &User) -> Result<()> { + let y = self + .get_request_by_id_linked_asset(id, linked_asset) + .await?; + + if user.id != y.owner && !user.permissions.check(FinePermission::MANAGE_REQUESTS) { + 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 requests WHERE id = $1", &[&(id as i64)]); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + self.2.remove(format!("atto.request:{}", id)).await; + + // decr request count + self.decr_user_request_count(y.owner).await.unwrap(); + + // return + Ok(()) + } + + pub async fn delete_all_requests(&self, user: &User) -> Result<()> { + let y = self.get_requests_by_owner(user.id).await?; + + for x in y { + if user.id != x.owner && !user.permissions.check(FinePermission::MANAGE_REQUESTS) { + return Err(Error::NotAllowed); + } + + self.delete_request(x.id, x.linked_asset, user).await?; + + // delete question + if x.action_type == ActionType::Answer { + self.delete_question(x.linked_asset, user).await?; + } + } + + Ok(()) + } +} diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index f2060d1..9b48834 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -31,6 +31,10 @@ pub struct User { /// The TOTP recovery codes for this profile. #[serde(default)] pub recovery_codes: Vec, + #[serde(default)] + pub post_count: usize, + #[serde(default)] + pub request_count: usize, } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -127,6 +131,8 @@ pub struct UserSettings { pub disable_other_themes: bool, #[serde(default)] pub disable_other_theme_css: bool, + #[serde(default)] + pub enable_questions: bool, } impl Default for User { @@ -160,6 +166,8 @@ impl User { last_seen: unix_epoch_timestamp() as usize, totp: String::new(), recovery_codes: Vec::new(), + post_count: 0, + request_count: 0, } } diff --git a/crates/core/src/model/communities.rs b/crates/core/src/model/communities.rs index b059bc0..51316d9 100644 --- a/crates/core/src/model/communities.rs +++ b/crates/core/src/model/communities.rs @@ -1,6 +1,5 @@ use serde::{Deserialize, Serialize}; use tetratto_shared::{snow::AlmostSnowflake, unix_epoch_timestamp}; - use super::communities_permissions::CommunityPermission; #[derive(Clone, Serialize, Deserialize)] @@ -78,6 +77,8 @@ pub struct CommunityContext { pub description: String, #[serde(default)] pub is_nsfw: bool, + #[serde(default)] + pub enable_questions: bool, } /// Who can read a [`Community`]. @@ -172,6 +173,9 @@ pub struct PostContext { pub repost: Option, #[serde(default = "default_reposts_enabled")] pub reposts_enabled: bool, + /// The ID of the question this post is answering. + #[serde(default)] + pub answering: usize, } fn default_comments_enabled() -> bool { @@ -192,6 +196,7 @@ impl Default for PostContext { edited: 0, is_nsfw: false, repost: None, + answering: 0, } } } @@ -271,3 +276,42 @@ impl Post { }); } } + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Question { + pub id: usize, + pub created: usize, + pub owner: usize, + pub receiver: usize, + pub content: String, + /// The `is_global` flag allows any (authenticated) user to respond + /// to the question. Normally, ownly the `receiver` can do so. + /// + /// If `is_global` is true, `receiver` should be 0 (and vice versa). + pub is_global: bool, + /// The number of answers the question has. Should never really be changed + /// unless the question has `is_global` set to true. + pub answer_count: usize, + /// The ID of the community this question is asked to. This should only be > 0 + /// if `is_global` is set to true. + pub community: usize, +} + +impl Question { + /// Create a new [`Question`]. + pub fn new(owner: usize, receiver: usize, content: String, is_global: bool) -> Self { + Self { + id: AlmostSnowflake::new(1234567890) + .to_string() + .parse::() + .unwrap(), + created: unix_epoch_timestamp() as usize, + owner, + receiver, + content, + is_global, + answer_count: 0, + community: 0, + } + } +} diff --git a/crates/core/src/model/mod.rs b/crates/core/src/model/mod.rs index 87bf1c3..0e86e0e 100644 --- a/crates/core/src/model/mod.rs +++ b/crates/core/src/model/mod.rs @@ -4,6 +4,7 @@ pub mod communities_permissions; pub mod moderation; pub mod permissions; pub mod reactions; +pub mod requests; use serde::{Deserialize, Serialize}; @@ -32,6 +33,7 @@ pub enum Error { DataTooShort(String), UsernameInUse, TitleInUse, + QuestionsDisabled, Unknown, } @@ -51,6 +53,7 @@ impl ToString for Error { Self::DataTooShort(name) => format!("Given {name} is too short!"), Self::UsernameInUse => "Username in use".to_string(), Self::TitleInUse => "Title in use".to_string(), + Self::QuestionsDisabled => "You are not allowed to ask questions there".to_string(), _ => format!("An unknown error as occurred: ({:?})", self), } } diff --git a/crates/core/src/model/permissions.rs b/crates/core/src/model/permissions.rs index fba4b88..c051ee0 100644 --- a/crates/core/src/model/permissions.rs +++ b/crates/core/src/model/permissions.rs @@ -28,6 +28,8 @@ bitflags! { const BANNED = 1 << 17; const INFINITE_COMMUNITIES = 1 << 18; const SUPPORTER = 1 << 19; + const MANAGE_REQUESTS = 1 << 20; + const MANAGE_QUESTIONS = 1 << 21; const _ = !0; } diff --git a/crates/core/src/model/requests.rs b/crates/core/src/model/requests.rs new file mode 100644 index 0000000..b6ce927 --- /dev/null +++ b/crates/core/src/model/requests.rs @@ -0,0 +1,52 @@ +use serde::{Serialize, Deserialize}; +use tetratto_shared::{snow::AlmostSnowflake, unix_epoch_timestamp}; + +#[derive(Serialize, Deserialize, PartialEq, Eq)] +pub enum ActionType { + /// A request to join a community. + /// + /// `users` table. + CommunityJoin, + /// A request to answer a question with a post. + /// + /// `questions` table. + Answer, +} + +#[derive(Serialize, Deserialize)] +pub struct ActionRequest { + pub id: usize, + pub created: usize, + pub owner: usize, + pub action_type: ActionType, + /// The ID of the asset this request links to. Should exist in the correct + /// table for the given [`ActionType`]. + pub linked_asset: usize, +} + +impl ActionRequest { + /// Create a new [`ActionRequest`]. + pub fn new(owner: usize, action_type: ActionType, linked_asset: usize) -> Self { + Self { + id: AlmostSnowflake::new(1234567890) + .to_string() + .parse::() + .unwrap(), + created: unix_epoch_timestamp() as usize, + owner, + action_type, + linked_asset, + } + } + + /// Create a new [`ActionRequest`] with the given `id`. + pub fn with_id(id: usize, owner: usize, action_type: ActionType, linked_asset: usize) -> Self { + Self { + id, + created: unix_epoch_timestamp() as usize, + owner, + action_type, + linked_asset, + } + } +} diff --git a/crates/l10n/Cargo.toml b/crates/l10n/Cargo.toml index 4926341..3728475 100644 --- a/crates/l10n/Cargo.toml +++ b/crates/l10n/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto-l10n" -version = "1.0.2" +version = "1.0.3" edition = "2024" authors.workspace = true repository.workspace = true diff --git a/crates/shared/Cargo.toml b/crates/shared/Cargo.toml index f90bcca..71bde60 100644 --- a/crates/shared/Cargo.toml +++ b/crates/shared/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto-shared" -version = "1.0.2" +version = "1.0.3" edition = "2024" authors.workspace = true repository.workspace = true diff --git a/sql_changes/users_post_count.sql b/sql_changes/users_post_count.sql new file mode 100644 index 0000000..b8619e2 --- /dev/null +++ b/sql_changes/users_post_count.sql @@ -0,0 +1,2 @@ +ALTER TABLE users +ADD COLUMN post_count INT NOT NULL DEFAULT 0; diff --git a/sql_changes/users_request_count.sql b/sql_changes/users_request_count.sql new file mode 100644 index 0000000..91a13a6 --- /dev/null +++ b/sql_changes/users_request_count.sql @@ -0,0 +1,2 @@ +ALTER TABLE users +ADD COLUMN request_count INT NOT NULL DEFAULT 0; diff --git a/sql_upgrades/totp.sql b/sql_changes/users_totp.sql similarity index 100% rename from sql_upgrades/totp.sql rename to sql_changes/users_totp.sql