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 %}
+
{{ 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