diff --git a/Cargo.lock b/Cargo.lock index 6c58c80..755b84a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3155,7 +3155,7 @@ dependencies = [ [[package]] name = "tetratto" -version = "1.0.4" +version = "1.0.5" dependencies = [ "ammonia", "axum", @@ -3180,7 +3180,7 @@ dependencies = [ [[package]] name = "tetratto-core" -version = "1.0.4" +version = "1.0.5" dependencies = [ "async-recursion", "bb8-postgres", @@ -3199,7 +3199,7 @@ dependencies = [ [[package]] name = "tetratto-l10n" -version = "1.0.4" +version = "1.0.5" dependencies = [ "pathbufd", "serde", @@ -3208,7 +3208,7 @@ dependencies = [ [[package]] name = "tetratto-shared" -version = "1.0.4" +version = "1.0.5" dependencies = [ "ammonia", "chrono", diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 5593d98..7da2012 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto" -version = "1.0.4" +version = "1.0.5" edition = "2024" [features] diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs index 7707608..8a2237b 100644 --- a/crates/app/src/assets.rs +++ b/crates/app/src/assets.rs @@ -47,6 +47,7 @@ pub const PROFILE_SETTINGS: &str = include_str!("./public/html/profile/settings. pub const PROFILE_FOLLOWING: &str = include_str!("./public/html/profile/following.html"); pub const PROFILE_FOLLOWERS: &str = include_str!("./public/html/profile/followers.html"); pub const PROFILE_WARNING: &str = include_str!("./public/html/profile/warning.html"); +pub const PROFILE_PRIVATE: &str = include_str!("./public/html/profile/private.html"); pub const COMMUNITIES_LIST: &str = include_str!("./public/html/communities/list.html"); pub const COMMUNITIES_BASE: &str = include_str!("./public/html/communities/base.html"); @@ -192,6 +193,7 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD { write_template!(html_path->"profile/following.html"(crate::assets::PROFILE_FOLLOWING) --config=config); write_template!(html_path->"profile/followers.html"(crate::assets::PROFILE_FOLLOWERS) --config=config); write_template!(html_path->"profile/warning.html"(crate::assets::PROFILE_WARNING) --config=config); + write_template!(html_path->"profile/private.html"(crate::assets::PROFILE_PRIVATE) --config=config); write_template!(html_path->"communities/list.html"(crate::assets::COMMUNITIES_LIST) -d "communities" --config=config); write_template!(html_path->"communities/base.html"(crate::assets::COMMUNITIES_BASE) --config=config); diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index de3a48f..2376c76 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -16,6 +16,7 @@ version = "1.0.0" "general:link.ip_bans" = "IP bans" "general:action.save" = "Save" "general:action.delete" = "Delete" +"general:action.accept" = "Accept" "general:action.back" = "Back" "general:action.report" = "Report" "general:action.manage" = "Manage" @@ -52,6 +53,10 @@ version = "1.0.0" "auth:label.joined_communities" = "Joined communities" "auth:label.recent_posts" = "Recent posts" "auth:label.before_you_view" = "Before you view" +"auth:label.private_profile" = "Private profile" +"auth:label.private_profile_message" = "This profile is private, meaning you can only view it if they follow you." +"auto:action.request_to_follow" = "Request to follow" +"auto:action.cancel_follow_request" = "Cancel follow request" "communities:action.create" = "Create" "communities:action.select" = "Select" @@ -128,3 +133,6 @@ version = "1.0.0" "requests:label.review" = "Review" "requests:label.ask_question" = "Ask question" "requests:label.answer" = "Answer" +"requests:label.user_follow_request" = "User follow request" +"requests:action.view_profile" = "View profile" +"requests:label.user_follow_request_message" = "Accepting this request will not allow them to see your profile. For that, you must follow them back." diff --git a/crates/app/src/public/html/communities/question.html b/crates/app/src/public/html/communities/question.html index a548070..752fc31 100644 --- a/crates/app/src/public/html/communities/question.html +++ b/crates/app/src/public/html/communities/question.html @@ -58,6 +58,7 @@ {% endblock %} diff --git a/crates/app/src/public/html/profile/private.html b/crates/app/src/public/html/profile/private.html new file mode 100644 index 0000000..f74f8ad --- /dev/null +++ b/crates/app/src/public/html/profile/private.html @@ -0,0 +1,131 @@ +{% extends "root.html" %} {% block head %} +{{ profile.username }} (private profile) - {{ config.name }} +{% endblock %} {% block body %} {{ macros::nav() }} +
+
+
+
+ {{ components::avatar(username=profile.username, size="24px") }} + {{ profile.username }} +
+ + {{ text "auth:label.private_profile" }} +
+ +
+ {{ text "auth:label.private_profile_message" }} + +
+ {% if user %} {% if not is_following %} + + + + {% else %} + + {% endif %} + + + {% endif %} + + + {{ icon "x" }} + {{ text "general:action.back" }} + +
+
+
+
+{% endblock %} diff --git a/crates/app/src/public/html/timelines/popular_questions.html b/crates/app/src/public/html/timelines/popular_questions.html index 9c015d3..65e17e9 100644 --- a/crates/app/src/public/html/timelines/popular_questions.html +++ b/crates/app/src/public/html/timelines/popular_questions.html @@ -4,7 +4,7 @@
{{ macros::timelines_nav(selected="popular") }} {{ macros::timelines_secondary_nav(posts="/popular", - questions="/popular/questions", selected="popular") }} + questions="/popular/questions", selected="questions") }}
diff --git a/crates/app/src/routes/api/v1/auth/social.rs b/crates/app/src/routes/api/v1/auth/social.rs index 9d41225..a951e5e 100644 --- a/crates/app/src/routes/api/v1/auth/social.rs +++ b/crates/app/src/routes/api/v1/auth/social.rs @@ -4,7 +4,7 @@ use crate::{ }; use axum::{Extension, Json, extract::Path, response::IntoResponse}; use axum_extra::extract::CookieJar; -use tetratto_core::model::auth::{Notification, UserBlock, UserFollow}; +use tetratto_core::model::auth::{FollowResult, Notification, UserBlock, UserFollow}; /// Toggle following on the given user. pub async fn follow_request( @@ -30,33 +30,111 @@ pub async fn follow_request( } } else { // create - match data.create_userfollow(UserFollow::new(user.id, id)).await { - Ok(_) => { - if let Err(e) = data - .create_notification(Notification::new( - "Somebody has followed you!".to_string(), - format!( - "You have been followed by [@{}](/api/v1/auth/user/find/{}).", - user.username, user.id - ), - id, - )) - .await - { - return Json(e.into()); - }; + match data + .create_userfollow(UserFollow::new(user.id, id), false) + .await + { + Ok(r) => { + if r == FollowResult::Followed { + if let Err(e) = data + .create_notification(Notification::new( + "Somebody has followed you!".to_string(), + format!( + "You have been followed by [@{}](/api/v1/auth/user/find/{}).", + user.username, user.id + ), + id, + )) + .await + { + return Json(e.into()); + }; - Json(ApiReturn { - ok: true, - message: "User followed".to_string(), - payload: (), - }) + Json(ApiReturn { + ok: true, + message: "User followed".to_string(), + payload: (), + }) + } else { + Json(ApiReturn { + ok: true, + message: "Asked to follow user".to_string(), + payload: (), + }) + } } Err(e) => Json(e.into()), } } } +pub async fn cancel_follow_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_request(user.id, id, &user, true).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Follow request deleted".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn accept_follow_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()), + }; + + // delete the request + if let Err(e) = data.delete_request(id, user.id, &user, true).await { + return Json(e.into()); + } + + // create follow + match data + .create_userfollow(UserFollow::new(id, user.id), true) + .await + { + Ok(_) => { + if let Err(e) = data + .create_notification(Notification::new( + "Somebody has accepted your follow request!".to_string(), + format!( + "You are now following [@{}](/api/v1/auth/user/find/{}).", + user.username, user.id + ), + id, + )) + .await + { + return Json(e.into()); + }; + + Json(ApiReturn { + ok: true, + message: "User follow request accepted".to_string(), + payload: (), + }) + } + Err(e) => Json(e.into()), + } +} + /// Toggle blocking on the given user. pub async fn block_request( jar: CookieJar, diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index cdaee07..52c20df 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -118,6 +118,14 @@ pub fn routes() -> Router { .route("/auth/user/{id}/avatar", get(auth::images::avatar_request)) .route("/auth/user/{id}/banner", get(auth::images::banner_request)) .route("/auth/user/{id}/follow", post(auth::social::follow_request)) + .route( + "/auth/user/{id}/follow/cancel", + post(auth::social::cancel_follow_request), + ) + .route( + "/auth/user/{id}/follow/accept", + post(auth::social::accept_follow_request), + ) .route("/auth/user/{id}/block", post(auth::social::block_request)) .route( "/auth/user/{id}/settings", diff --git a/crates/app/src/routes/api/v1/requests.rs b/crates/app/src/routes/api/v1/requests.rs index 3e43c50..64da0ac 100644 --- a/crates/app/src/routes/api/v1/requests.rs +++ b/crates/app/src/routes/api/v1/requests.rs @@ -14,7 +14,7 @@ pub async fn delete_request( None => return Json(Error::NotAllowed.into()), }; - match data.delete_request(id, linked_asset, &user).await { + match data.delete_request(id, linked_asset, &user, false).await { Ok(_) => Json(ApiReturn { ok: true, message: "Request deleted".to_string(), diff --git a/crates/app/src/routes/pages/profile.rs b/crates/app/src/routes/pages/profile.rs index 74cd925..7df555c 100644 --- a/crates/app/src/routes/pages/profile.rs +++ b/crates/app/src/routes/pages/profile.rs @@ -137,13 +137,41 @@ pub async fn posts_request( .await .is_err() { - return Err(Html( - render_error(Error::NotAllowed, &jar, &data, &user).await, + let lang = get_lang!(jar, data.0); + let mut context = initial_context(&data.0.0, lang, &user).await; + + context.insert("profile", &other_user); + context.insert( + "follow_requested", + &data + .0 + .get_request_by_id_linked_asset(ua.id, other_user.id) + .await + .is_ok(), + ); + context.insert( + "is_following", + &data + .0 + .get_userfollow_by_initiator_receiver(ua.id, other_user.id) + .await + .is_ok(), + ); + + return Ok(Html( + data.1.render("profile/private.html", &context).unwrap(), )); } } else { - return Err(Html( - render_error(Error::NotAllowed, &jar, &data, &user).await, + let lang = get_lang!(jar, data.0); + let mut context = initial_context(&data.0.0, lang, &user).await; + + context.insert("profile", &other_user); + context.insert("follow_requested", &false); + context.insert("is_following", &false); + + return Ok(Html( + data.1.render("profile/private.html", &context).unwrap(), )); } } diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index fb60c3d..976a5f0 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto-core" -version = "1.0.4" +version = "1.0.5" edition = "2024" [features] diff --git a/crates/core/src/database/posts.rs b/crates/core/src/database/posts.rs index 75c64f5..3fc5e6a 100644 --- a/crates/core/src/database/posts.rs +++ b/crates/core/src/database/posts.rs @@ -621,7 +621,7 @@ impl DataManager { } if !question.is_global { - self.delete_request(question.owner, question.id, &owner) + self.delete_request(question.owner, question.id, &owner, false) .await?; } else { self.incr_question_answer_count(data.context.answering) @@ -629,15 +629,23 @@ impl DataManager { } // 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?; + // (if the current user isn't the owner) + if question.owner != data.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?; + } + + // inherit nsfw status if we didn't get it from the community + if question.context.is_nsfw { + data.context.is_nsfw = question.context.is_nsfw; + } } // check if we're reposting a post diff --git a/crates/core/src/database/questions.rs b/crates/core/src/database/questions.rs index dcabc13..30b0712 100644 --- a/crates/core/src/database/questions.rs +++ b/crates/core/src/database/questions.rs @@ -37,6 +37,8 @@ impl DataManager { // likes likes: get!(x->8(i32)) as isize, dislikes: get!(x->9(i32)) as isize, + // ... + context: serde_json::from_str(&get!(x->10(String))).unwrap(), } } @@ -300,6 +302,9 @@ impl DataManager { { return Err(Error::QuestionsDisabled); } + + // inherit nsfw status + data.context.is_nsfw = community.context.is_nsfw; } else { let receiver = self.get_user_by_id(data.receiver).await?; @@ -323,7 +328,7 @@ impl DataManager { let res = execute!( &conn, - "INSERT INTO questions VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", + "INSERT INTO questions VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)", params![ &(data.id as i64), &(data.created as i64), @@ -334,7 +339,8 @@ impl DataManager { &0_i32, &(data.community as i64), &0_i32, - &0_i32 + &0_i32, + &serde_json::to_string(&data.context).unwrap() ] ); @@ -404,7 +410,7 @@ impl DataManager { { // 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?; + self.delete_request(y.owner, y.id, user, false).await?; } // delete all posts answering question diff --git a/crates/core/src/database/requests.rs b/crates/core/src/database/requests.rs index 7c5e7ba..173dcd3 100644 --- a/crates/core/src/database/requests.rs +++ b/crates/core/src/database/requests.rs @@ -119,13 +119,21 @@ impl DataManager { Ok(()) } - pub async fn delete_request(&self, id: usize, linked_asset: usize, user: &User) -> Result<()> { + pub async fn delete_request( + &self, + id: usize, + linked_asset: usize, + user: &User, + force: bool, + ) -> 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); + if !force { + if user.id != y.owner && !user.permissions.check(FinePermission::MANAGE_REQUESTS) { + return Err(Error::NotAllowed); + } } let conn = match self.connect().await { @@ -133,13 +141,21 @@ impl DataManager { Err(e) => return Err(Error::DatabaseConnection(e.to_string())), }; - let res = execute!(&conn, "DELETE FROM requests WHERE id = $1", &[&(id as i64)]); + let res = execute!( + &conn, + "DELETE FROM requests WHERE id = $1", + &[&(y.id as i64)] + ); if let Err(e) = res { return Err(Error::DatabaseError(e.to_string())); } - self.2.remove(format!("atto.request:{}", id)).await; + self.2.remove(format!("atto.request:{}", y.id)).await; + + self.2 + .remove(format!("atto.request:{}:{}", id, linked_asset)) + .await; // decr request count let owner = self.get_user_by_id(y.owner).await?; @@ -159,7 +175,8 @@ impl DataManager { return Err(Error::NotAllowed); } - self.delete_request(x.id, x.linked_asset, user).await?; + self.delete_request(x.id, x.linked_asset, user, false) + .await?; // delete question if x.action_type == ActionType::Answer { diff --git a/crates/core/src/database/userfollows.rs b/crates/core/src/database/userfollows.rs index a2288d8..c80a2de 100644 --- a/crates/core/src/database/userfollows.rs +++ b/crates/core/src/database/userfollows.rs @@ -1,5 +1,7 @@ use super::*; use crate::cache::Cache; +use crate::model::auth::FollowResult; +use crate::model::requests::{ActionRequest, ActionType}; use crate::model::{Error, Result, auth::User, auth::UserFollow, permissions::FinePermission}; use crate::{auto_method, execute, get, query_row, query_rows, params}; @@ -219,7 +221,26 @@ impl DataManager { /// /// # Arguments /// * `data` - a mock [`UserFollow`] object to insert - pub async fn create_userfollow(&self, data: UserFollow) -> Result<()> { + /// * `force` - if we should skip the request stage + pub async fn create_userfollow(&self, data: UserFollow, force: bool) -> Result { + if !force { + let other_user = self.get_user_by_id(data.receiver).await?; + + if other_user.settings.private_profile { + // send follow request instead + self.create_request(ActionRequest::with_id( + data.initiator, + data.receiver, + ActionType::Follow, + data.receiver, + )) + .await?; + + return Ok(FollowResult::Requested); + } + } + + // ... let conn = match self.connect().await { Ok(c) => c, Err(e) => return Err(Error::DatabaseConnection(e.to_string())), @@ -248,13 +269,16 @@ impl DataManager { self.incr_user_follower_count(data.receiver).await.unwrap(); // return - Ok(()) + Ok(FollowResult::Followed) } pub async fn delete_userfollow(&self, id: usize, user: &User) -> Result<()> { let follow = self.get_userfollow_by_id(id).await?; - if (user.id != follow.initiator) && (user.id != follow.receiver) && !user.permissions.check(FinePermission::MANAGE_FOLLOWS) { + if (user.id != follow.initiator) + && (user.id != follow.receiver) + && !user.permissions.check(FinePermission::MANAGE_FOLLOWS) + { return Err(Error::NotAllowed); } diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index 9b48834..8d411b2 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -322,6 +322,14 @@ impl UserFollow { } } +#[derive(Serialize, Deserialize, PartialEq, Eq)] +pub enum FollowResult { + /// Request sent to follow other user. + Requested, + /// Successfully followed other user. + Followed, +} + #[derive(Serialize, Deserialize)] pub struct UserBlock { pub id: usize, diff --git a/crates/core/src/model/communities.rs b/crates/core/src/model/communities.rs index 57a4ae6..1fa332f 100644 --- a/crates/core/src/model/communities.rs +++ b/crates/core/src/model/communities.rs @@ -307,6 +307,8 @@ pub struct Question { pub likes: isize, #[serde(default)] pub dislikes: isize, + #[serde(default)] + pub context: QuestionContext, } impl Question { @@ -326,6 +328,19 @@ impl Question { community: 0, likes: 0, dislikes: 0, + context: QuestionContext::default(), } } } + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QuestionContext { + #[serde(default)] + pub is_nsfw: bool, +} + +impl Default for QuestionContext { + fn default() -> Self { + Self { is_nsfw: false } + } +} diff --git a/crates/core/src/model/requests.rs b/crates/core/src/model/requests.rs index b6ce927..867c723 100644 --- a/crates/core/src/model/requests.rs +++ b/crates/core/src/model/requests.rs @@ -11,6 +11,10 @@ pub enum ActionType { /// /// `questions` table. Answer, + /// A request follow a private account. + /// + /// `users` table. + Follow, } #[derive(Serialize, Deserialize)] diff --git a/crates/l10n/Cargo.toml b/crates/l10n/Cargo.toml index 4a991d5..c39f68f 100644 --- a/crates/l10n/Cargo.toml +++ b/crates/l10n/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto-l10n" -version = "1.0.4" +version = "1.0.5" edition = "2024" authors.workspace = true repository.workspace = true diff --git a/crates/shared/Cargo.toml b/crates/shared/Cargo.toml index 7472401..63dbeb3 100644 --- a/crates/shared/Cargo.toml +++ b/crates/shared/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto-shared" -version = "1.0.4" +version = "1.0.5" edition = "2024" authors.workspace = true repository.workspace = true diff --git a/sql_changes/questions_context.sql b/sql_changes/questions_context.sql new file mode 100644 index 0000000..f692dfd --- /dev/null +++ b/sql_changes/questions_context.sql @@ -0,0 +1,2 @@ +ALTER TABLE questions +ADD COLUMN context TEXT NOT NULL DEFAULT '{}';