From 3db7f2699c98ab93af4b3806f3bfdb188045cb79 Mon Sep 17 00:00:00 2001 From: trisua Date: Sat, 19 Apr 2025 18:59:55 -0400 Subject: [PATCH] add: anonymous questions --- Cargo.lock | 49 ++++--- crates/app/Cargo.toml | 2 +- crates/app/src/langs/en-US.toml | 13 +- crates/app/src/public/html/misc/requests.html | 3 +- crates/app/src/public/html/profile/base.html | 8 +- crates/app/src/public/html/profile/posts.html | 3 +- .../app/src/public/html/profile/private.html | 6 +- .../app/src/public/html/profile/settings.html | 36 +++-- crates/app/src/public/js/me.js | 21 +++ crates/app/src/routes/api/v1/auth/social.rs | 37 ++++- .../routes/api/v1/communities/questions.rs | 76 +++++++++- crates/app/src/routes/api/v1/mod.rs | 5 + crates/app/src/routes/mod.rs | 4 + crates/core/Cargo.toml | 8 +- crates/core/src/config.rs | 1 + crates/core/src/database/auth.rs | 11 +- crates/core/src/database/common.rs | 5 +- crates/core/src/database/drivers/common.rs | 1 + .../database/drivers/sql/create_ipblocks.sql | 6 + .../database/drivers/sql/create_questions.sql | 5 +- crates/core/src/database/ipblocks.rs | 133 ++++++++++++++++++ crates/core/src/database/mod.rs | 1 + crates/core/src/database/posts.rs | 17 ++- crates/core/src/database/questions.rs | 35 ++++- crates/core/src/database/requests.rs | 6 +- crates/core/src/model/auth.rs | 35 +++++ crates/core/src/model/communities.rs | 19 ++- crates/core/src/model/mod.rs | 10 +- crates/l10n/Cargo.toml | 2 +- crates/shared/Cargo.toml | 6 +- example/.gitignore | 2 +- example/public/robots.txt | 2 + example/tetratto.toml | 1 + sql_changes/questions_ip.sql | 2 + 34 files changed, 473 insertions(+), 98 deletions(-) create mode 100644 crates/core/src/database/drivers/sql/create_ipblocks.sql create mode 100644 crates/core/src/database/ipblocks.rs create mode 100644 example/public/robots.txt create mode 100644 sql_changes/questions_ip.sql diff --git a/Cargo.lock b/Cargo.lock index 755b84a..694711c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -310,9 +310,9 @@ dependencies = [ [[package]] name = "base32" -version = "0.4.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23ce669cd6c8588f79e15cf450314f9638f967fc5770ff1c7c1deb0925ea7cfa" +checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076" [[package]] name = "base64" @@ -623,9 +623,9 @@ dependencies = [ [[package]] name = "comrak" -version = "0.37.0" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a4f05e73ca9a30af27bebc13600f91fd1651b2ec7d139ca82a89df7ca583af1" +checksum = "f690706b5db081dccea6206d7f6d594bb9895599abea9d1a0539f13888781ae8" dependencies = [ "bon", "caseless", @@ -642,9 +642,9 @@ dependencies = [ [[package]] name = "constant_time_eq" -version = "0.2.6" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21a53c0a4d288377e7415b53dcfc3c04da5cdc2cc95c8d5ac178b58f0b861ad6" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" [[package]] name = "cookie" @@ -1649,9 +1649,9 @@ checksum = "8355be11b20d696c8f18f6cc018c4e372165b1fa8126cef092399c9951984ffa" [[package]] name = "libsqlite3-sys" -version = "0.32.0" +version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbb8270bb4060bd76c6e96f20c52d80620f1d82a3470885694e41e0f81ef6fe7" +checksum = "947e6816f7825b2b45027c2c32e7085da9934defa535de4a6a46b10a4d5257fa" dependencies = [ "pkg-config", "vcpkg", @@ -2212,7 +2212,7 @@ dependencies = [ "hmac", "md-5", "memchr", - "rand 0.9.0", + "rand 0.9.1", "sha2", "stringprep", ] @@ -2356,13 +2356,12 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3779b94aeb87e8bd4e834cee3650289ee9e0d5677f976ecdb6d219e5f4f6cd94" +checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.3", - "zerocopy", ] [[package]] @@ -2475,9 +2474,9 @@ dependencies = [ [[package]] name = "redis" -version = "0.29.2" +version = "0.29.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b110459d6e323b7cda23980c46c77157601199c9da6241552b284cd565a7a133" +checksum = "1bc42f3a12fd4408ce64d8efef67048a924e543bd35c6591c0447fda9054695f" dependencies = [ "arc-swap", "combine", @@ -2611,9 +2610,9 @@ dependencies = [ [[package]] name = "rusqlite" -version = "0.34.0" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37e34486da88d8e051c7c0e23c3f15fd806ea8546260aa2fec247e97242ec143" +checksum = "a22715a5d6deef63c637207afbe68d0c72c3f8d0022d7cf9714c442d6157606b" dependencies = [ "bitflags 2.9.0", "fallible-iterator 0.3.0", @@ -3155,7 +3154,7 @@ dependencies = [ [[package]] name = "tetratto" -version = "1.0.5" +version = "1.0.6" dependencies = [ "ammonia", "axum", @@ -3180,7 +3179,7 @@ dependencies = [ [[package]] name = "tetratto-core" -version = "1.0.5" +version = "1.0.6" dependencies = [ "async-recursion", "bb8-postgres", @@ -3199,7 +3198,7 @@ dependencies = [ [[package]] name = "tetratto-l10n" -version = "1.0.5" +version = "1.0.6" dependencies = [ "pathbufd", "serde", @@ -3208,14 +3207,14 @@ dependencies = [ [[package]] name = "tetratto-shared" -version = "1.0.5" +version = "1.0.6" dependencies = [ "ammonia", "chrono", "comrak", "hex_fmt", "num-bigint", - "rand 0.9.0", + "rand 0.9.1", "serde", "sha2", "uuid", @@ -3395,7 +3394,7 @@ dependencies = [ "pin-project-lite", "postgres-protocol", "postgres-types", - "rand 0.9.0", + "rand 0.9.1", "socket2", "tokio", "tokio-util", @@ -3461,15 +3460,15 @@ dependencies = [ [[package]] name = "totp-rs" -version = "5.6.0" +version = "5.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17b2f27dad992486c26b4e7455f38aa487e838d6d61b57e72906ee2b8c287a90" +checksum = "f124352108f58ef88299e909f6e9470f1cdc8d2a1397963901b4a6366206bf72" dependencies = [ "base32", "constant_time_eq", "hmac", "qrcodegen-image", - "rand 0.8.5", + "rand 0.9.1", "sha1", "sha2", "url", diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 7da2012..e46c7c8 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto" -version = "1.0.5" +version = "1.0.6" edition = "2024" [features] diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index 2376c76..cd6fb41 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -41,10 +41,11 @@ version = "1.0.0" "auth:action.login" = "Login" "auth:action.register" = "Register" "auth:action.logout" = "Logout" -"auto:action.follow" = "Follow" -"auto:action.unfollow" = "Unfollow" -"auto:action.block" = "Block" -"auto:action.unblock" = "Unblock" +"auth:action.follow" = "Follow" +"auth:action.unfollow" = "Unfollow" +"auth:action.block" = "Block" +"auth:action.ip_block" = "IP block" +"auth:action.unblock" = "Unblock" "auth:link.my_profile" = "My profile" "auth:link.settings" = "Settings" "auth:label.followers" = "Followers" @@ -55,8 +56,8 @@ version = "1.0.0" "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" +"auth:action.request_to_follow" = "Request to follow" +"auth:action.cancel_follow_request" = "Cancel follow request" "communities:action.create" = "Create" "communities:action.select" = "Select" diff --git a/crates/app/src/public/html/misc/requests.html b/crates/app/src/public/html/misc/requests.html index 0715c85..8e96287 100644 --- a/crates/app/src/public/html/misc/requests.html +++ b/crates/app/src/public/html/misc/requests.html @@ -110,7 +110,8 @@
- + +
diff --git a/crates/app/src/public/html/profile/base.html b/crates/app/src/public/html/profile/base.html index f70ad7b..b971af6 100644 --- a/crates/app/src/public/html/profile/base.html +++ b/crates/app/src/public/html/profile/base.html @@ -156,7 +156,7 @@ atto_tag="user.follow" > {{ icon "user-plus" }} - {{ text "auto:action.follow" }} + {{ text "auth:action.follow" }} {% else %} {% endif %} {% if is_helper %} {{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header) }} diff --git a/crates/app/src/public/html/profile/private.html b/crates/app/src/public/html/profile/private.html index f74f8ad..d31e924 100644 --- a/crates/app/src/public/html/profile/private.html +++ b/crates/app/src/public/html/profile/private.html @@ -25,7 +25,7 @@ atto_tag="user.follow_request" > {{ icon "user-plus" }} - {{ text "auto:action.request_to_follow" }} + {{ text "auth:action.request_to_follow" }} {% else %} {% endif %} diff --git a/crates/app/src/public/html/profile/settings.html b/crates/app/src/public/html/profile/settings.html index 5e8e338..632ee0f 100644 --- a/crates/app/src/public/html/profile/settings.html +++ b/crates/app/src/public/html/profile/settings.html @@ -748,20 +748,6 @@ settings.warning, "textarea", ], - [[], "Questions", "title"], - [ - [ - "enable_questions", - "Allow users to ask you questions", - ], - "{{ profile.settings.enable_questions }}", - "checkbox", - ], - [ - ["motivational_header", "Motivational header"], - settings.motivational_header, - "input", - ], ], settings, ); @@ -791,6 +777,28 @@ "{{ profile.settings.private_last_seen }}", "checkbox", ], + [[], "Questions", "title"], + [ + [ + "enable_questions", + "Allow users to ask you questions", + ], + "{{ profile.settings.enable_questions }}", + "checkbox", + ], + [ + [ + "allow_anonymous_questions", + "Allow anonymous questions", + ], + "{{ profile.settings.allow_anonymous_questions }}", + "checkbox", + ], + [ + ["motivational_header", "Motivational header"], + settings.motivational_header, + "input", + ], ], settings, ); diff --git a/crates/app/src/public/js/me.js b/crates/app/src/public/js/me.js index 19105bf..0dd48fe 100644 --- a/crates/app/src/public/js/me.js +++ b/crates/app/src/public/js/me.js @@ -214,6 +214,27 @@ }); }); + self.define("ip_block_question", async (_, id) => { + if ( + !(await trigger("atto::confirm", [ + "Are you sure you want to do this?", + ])) + ) { + return; + } + + fetch(`/api/v1/questions/${id}/block_ip`, { + method: "POST", + }) + .then((res) => res.json()) + .then((res) => { + trigger("atto::toast", [ + res.ok ? "success" : "error", + res.message, + ]); + }); + }); + // token switcher self.define( "set_login_account_tokens", diff --git a/crates/app/src/routes/api/v1/auth/social.rs b/crates/app/src/routes/api/v1/auth/social.rs index b9c91b5..e6d3bbc 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::{FollowResult, Notification, UserBlock, UserFollow}; +use tetratto_core::model::auth::{FollowResult, IpBlock, Notification, UserBlock, UserFollow}; /// Toggle following on the given user. pub async fn follow_request( @@ -197,3 +197,38 @@ pub async fn block_request( } } } + +/// Toggle IP blocking on the given IP. +pub async fn ip_block_request( + jar: CookieJar, + Path(ip): Path, + 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()), + }; + + if let Ok(ipblock) = data.get_ipblock_by_initiator_receiver(user.id, &ip).await { + // delete + match data.delete_ipblock(ipblock.id, user).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "IP unblocked".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } + } else { + // create + match data.create_ipblock(IpBlock::new(user.id, ip)).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "IP blocked".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } + } +} diff --git a/crates/app/src/routes/api/v1/communities/questions.rs b/crates/app/src/routes/api/v1/communities/questions.rs index 2e26fa5..b4b27cc 100644 --- a/crates/app/src/routes/api/v1/communities/questions.rs +++ b/crates/app/src/routes/api/v1/communities/questions.rs @@ -1,27 +1,49 @@ -use axum::{Extension, Json, extract::Path, response::IntoResponse}; +use axum::{ + extract::Path, + http::{HeaderMap, HeaderValue}, + response::IntoResponse, + Extension, Json, +}; use axum_extra::extract::CookieJar; -use tetratto_core::model::{communities::Question, ApiReturn, Error}; +use tetratto_core::model::{auth::IpBlock, communities::Question, ApiReturn, Error}; use crate::{get_user_from_token, routes::api::v1::CreateQuestion, State}; pub async fn create_request( jar: CookieJar, + headers: HeaderMap, 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 user = get_user_from_token!(jar, data); + if req.is_global && user.is_none() { + return Json(Error::NotAllowed.into()); + } + + // get real ip + let real_ip = headers + .get(data.0.security.real_ip_header.to_owned()) + .unwrap_or(&HeaderValue::from_static("")) + .to_str() + .unwrap_or("") + .to_string(); + + // check for ip ban + if data.get_ipban_by_ip(&real_ip).await.is_ok() { + return Json(Error::NotAllowed.into()); + } + + // ... let mut props = Question::new( - user.id, + if let Some(ref ua) = user { ua.id } else { 0 }, match req.receiver.parse::() { Ok(x) => x, Err(e) => return Json(Error::MiscError(e.to_string()).into()), }, req.content, req.is_global, + real_ip, ); if !req.community.is_empty() { @@ -63,3 +85,43 @@ pub async fn delete_request( Err(e) => Json(e.into()), } } + +pub async fn ip_block_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()), + }; + + // get question + let question = match data.get_question_by_id(id).await { + Ok(q) => q, + Err(e) => return Json(e.into()), + }; + + // check for an existing ip block + if data + .get_ipblock_by_initiator_receiver(user.id, &question.ip) + .await + .is_ok() + { + return Json(Error::NotAllowed.into()); + } + + // create ip block + match data + .create_ipblock(IpBlock::new(user.id, question.ip)) + .await + { + Ok(_) => Json(ApiReturn { + ok: true, + message: "IP blocked".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 52c20df..557d940 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -100,6 +100,10 @@ pub fn routes() -> Router { "/questions/{id}", delete(communities::questions::delete_request), ) + .route( + "/questions/{id}/block_ip", + post(communities::questions::ip_block_request), + ) // auth // global .route("/auth/register", post(auth::register_request)) @@ -177,6 +181,7 @@ pub fn routes() -> Router { "/auth/user/find_by_ip/{ip}", get(auth::profile::redirect_from_ip), ) + .route("/auth/ip/{ip}/block", post(auth::social::ip_block_request)) // warnings .route("/warnings/{id}", post(auth::user_warnings::create_request)) .route( diff --git a/crates/app/src/routes/mod.rs b/crates/app/src/routes/mod.rs index bf73c9d..120eeb5 100644 --- a/crates/app/src/routes/mod.rs +++ b/crates/app/src/routes/mod.rs @@ -20,6 +20,10 @@ pub fn routes(config: &Config) -> Router { get_service(tower_http::services::ServeDir::new(&config.dirs.assets)), ) .route("/public/favicon.svg", get(assets::favicon_request)) + .route_service( + "/robots.txt", + tower_http::services::ServeFile::new(format!("{}/robots.txt", config.dirs.assets)), + ) // api .nest("/api/v1", api::v1::routes()) // pages diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 976a5f0..aa1766d 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto-core" -version = "1.0.5" +version = "1.0.6" edition = "2024" [features] @@ -16,11 +16,11 @@ toml = "0.8.20" tetratto-shared = { path = "../shared" } tetratto-l10n = { path = "../l10n" } serde_json = "1.0.140" -totp-rs = { version = "5.6.0", features = ["qr", "gen_secret"] } +totp-rs = { version = "5.7.0", features = ["qr", "gen_secret"] } -redis = { version = "0.29.2", optional = true } +redis = { version = "0.29.5", optional = true } -rusqlite = { version = "0.34.0", optional = true } +rusqlite = { version = "0.35.0", optional = true } tokio-postgres = { version = "0.7.13", optional = true } bb8-postgres = { version = "0.9.0", optional = true } diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 6eff384..1fe4ea6 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -257,6 +257,7 @@ fn default_banned_usernames() -> Vec { "notification".to_string(), "post".to_string(), "void".to_string(), + "anonymous".to_string(), ] } diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs index af5c98a..188974a 100644 --- a/crates/core/src/database/auth.rs +++ b/crates/core/src/database/auth.rs @@ -9,7 +9,6 @@ use crate::model::{ use crate::{auto_method, execute, get, query_row, params}; use pathbufd::PathBufD; use std::fs::{exists, remove_file}; -use std::usize; use tetratto_shared::hash::{hash_salted, salt}; use tetratto_shared::unix_epoch_timestamp; @@ -259,6 +258,16 @@ impl DataManager { return Err(Error::DatabaseError(e.to_string())); } + let res = execute!( + &conn, + "DELETE FROM ipblocks WHERE initiator = $1", + &[&(id as i64)] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + // delete reactions // reactions counts will remain the same :) let res = execute!( diff --git a/crates/core/src/database/common.rs b/crates/core/src/database/common.rs index 15bbaad..6926f2b 100644 --- a/crates/core/src/database/common.rs +++ b/crates/core/src/database/common.rs @@ -27,6 +27,7 @@ impl DataManager { execute!(&conn, common::CREATE_TABLE_USER_WARNINGS).unwrap(); execute!(&conn, common::CREATE_TABLE_REQUESTS).unwrap(); execute!(&conn, common::CREATE_TABLE_QUESTIONS).unwrap(); + execute!(&conn, common::CREATE_TABLE_IPBLOCKS).unwrap(); Ok(()) } @@ -338,7 +339,7 @@ macro_rules! auto_method { if !user.permissions.check(FinePermission::$permission) { return Err(Error::NotAllowed); } else { - self.create_audit_log_entry(crate::model::moderation::AuditLogEntry::new( + self.create_audit_log_entry($crate::model::moderation::AuditLogEntry::new( user.id, format!("invoked `{}` with x value `{x:?}`", stringify!($name)), )) @@ -493,7 +494,7 @@ macro_rules! auto_method { if !user.permissions.check(FinePermission::$permission) { return Err(Error::NotAllowed); } else { - self.create_audit_log_entry(crate::model::moderation::AuditLogEntry::new( + self.create_audit_log_entry($crate::model::moderation::AuditLogEntry::new( user.id, format!("invoked `{}` with x value `{id}`", stringify!($name)), )) diff --git a/crates/core/src/database/drivers/common.rs b/crates/core/src/database/drivers/common.rs index 1dc424a..cb16682 100644 --- a/crates/core/src/database/drivers/common.rs +++ b/crates/core/src/database/drivers/common.rs @@ -12,3 +12,4 @@ 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"); +pub const CREATE_TABLE_IPBLOCKS: &str = include_str!("./sql/create_ipblocks.sql"); diff --git a/crates/core/src/database/drivers/sql/create_ipblocks.sql b/crates/core/src/database/drivers/sql/create_ipblocks.sql new file mode 100644 index 0000000..8381d7d --- /dev/null +++ b/crates/core/src/database/drivers/sql/create_ipblocks.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS ipblocks ( + id BIGINT NOT NULL PRIMARY KEY, + created BIGINT NOT NULL, + initiator BIGINT NOT NULL, + receiver TEXT NOT NULL +) diff --git a/crates/core/src/database/drivers/sql/create_questions.sql b/crates/core/src/database/drivers/sql/create_questions.sql index d7dba2c..ab23661 100644 --- a/crates/core/src/database/drivers/sql/create_questions.sql +++ b/crates/core/src/database/drivers/sql/create_questions.sql @@ -9,5 +9,8 @@ CREATE TABLE IF NOT EXISTS questions ( community BIGINT NOT NULL, -- likes likes INT NOT NULL, - dislikes INT NOT NULL + dislikes INT NOT NULL, + -- ... + context TEXT NOT NULL, + ip TEXT NOT NULL ) diff --git a/crates/core/src/database/ipblocks.rs b/crates/core/src/database/ipblocks.rs new file mode 100644 index 0000000..ffb14a0 --- /dev/null +++ b/crates/core/src/database/ipblocks.rs @@ -0,0 +1,133 @@ +use super::*; +use crate::cache::Cache; +use crate::model::{Error, Result, auth::User, auth::IpBlock, permissions::FinePermission}; +use crate::{auto_method, execute, get, query_row, params}; + +#[cfg(feature = "sqlite")] +use rusqlite::Row; + +#[cfg(feature = "postgres")] +use tokio_postgres::Row; + +impl DataManager { + /// Get a [`UserBlock`] from an SQL row. + pub(crate) fn get_ipblock_from_row( + #[cfg(feature = "sqlite")] x: &Row<'_>, + #[cfg(feature = "postgres")] x: &Row, + ) -> IpBlock { + IpBlock { + id: get!(x->0(i64)) as usize, + created: get!(x->1(i64)) as usize, + initiator: get!(x->2(i64)) as usize, + receiver: get!(x->3(String)), + } + } + + auto_method!(get_ipblock_by_id()@get_ipblock_from_row -> "SELECT * FROM ipblocks WHERE id = $1" --name="ip block" --returns=IpBlock --cache-key-tmpl="atto.ipblock:{}"); + + /// Get a user block by `initiator` and `receiver` (in that order). + pub async fn get_ipblock_by_initiator_receiver( + &self, + initiator: usize, + receiver: &str, + ) -> 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 ipblocks WHERE initiator = $1 AND receiver = $2", + params![&(initiator as i64), &receiver], + |x| { Ok(Self::get_ipblock_from_row(x)) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("user block".to_string())); + } + + Ok(res.unwrap()) + } + + /// Get a user block by `receiver` and `initiator` (in that order). + pub async fn get_ipblock_by_receiver_initiator( + &self, + receiver: &str, + initiator: 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 ipblocks WHERE receiver = $1 AND initiator = $2", + params![&receiver, &(initiator as i64)], + |x| { Ok(Self::get_ipblock_from_row(x)) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("user block".to_string())); + } + + Ok(res.unwrap()) + } + + /// Create a new user block in the database. + /// + /// # Arguments + /// * `data` - a mock [`UserBlock`] object to insert + pub async fn create_ipblock(&self, data: IpBlock) -> 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 ipblocks VALUES ($1, $2, $3, $4)", + params![ + &(data.id as i64), + &(data.created as i64), + &(data.initiator as i64), + &data.receiver + ] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // return + Ok(()) + } + + pub async fn delete_ipblock(&self, id: usize, user: User) -> Result<()> { + let block = self.get_ipblock_by_id(id).await?; + + if user.id != block.initiator { + // only the initiator (or moderators) can delete user blocks! + if !user.permissions.check(FinePermission::MANAGE_FOLLOWS) { + 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 ipblocks WHERE id = $1", &[&(id as i64)]); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + self.2.remove(format!("atto.ipblock:{}", id)).await; + + // return + Ok(()) + } +} diff --git a/crates/core/src/database/mod.rs b/crates/core/src/database/mod.rs index e602b5a..b43d53d 100644 --- a/crates/core/src/database/mod.rs +++ b/crates/core/src/database/mod.rs @@ -4,6 +4,7 @@ mod common; mod communities; mod drivers; mod ipbans; +mod ipblocks; mod memberships; mod notifications; mod posts; diff --git a/crates/core/src/database/posts.rs b/crates/core/src/database/posts.rs index 3fc5e6a..856a12b 100644 --- a/crates/core/src/database/posts.rs +++ b/crates/core/src/database/posts.rs @@ -105,7 +105,12 @@ impl DataManager { 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?; + let user = if question.owner == 0 { + User::anonymous() + } else { + self.get_user_by_id_with_void(question.owner).await? + }; + Ok(Some((question, user))) } else { Ok(None) @@ -563,7 +568,7 @@ impl DataManager { .get_membership_by_owner_community(uid, community.id) .await { - Ok(m) => !(!m.role.check_member()), + Ok(m) => m.role.check_member(), Err(_) => false, } } @@ -630,7 +635,7 @@ impl DataManager { // create notification for question owner // (if the current user isn't the owner) - if question.owner != data.owner { + if (question.owner != data.owner) && (question.owner != 0) { self.create_notification(Notification::new( "Your question has received a new answer!".to_string(), format!( @@ -682,9 +687,10 @@ impl DataManager { } // check blocked status - if let Ok(_) = self + if self .get_userblock_by_initiator_receiver(rt.owner, data.owner) .await + .is_ok() { return Err(Error::NotAllowed); } @@ -703,9 +709,10 @@ impl DataManager { } // check blocked status - if let Ok(_) = self + if self .get_userblock_by_initiator_receiver(rt.owner, data.owner) .await + .is_ok() { return Err(Error::NotAllowed); } diff --git a/crates/core/src/database/questions.rs b/crates/core/src/database/questions.rs index eddd5b3..85f18b7 100644 --- a/crates/core/src/database/questions.rs +++ b/crates/core/src/database/questions.rs @@ -39,6 +39,7 @@ impl DataManager { dislikes: get!(x->9(i32)) as isize, // ... context: serde_json::from_str(&get!(x->10(String))).unwrap(), + ip: get!(x->11(String)), } } @@ -53,7 +54,12 @@ impl DataManager { 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?; + let user = if question.owner == 0 { + User::anonymous() + } else { + self.get_user_by_id_with_void(question.owner).await? + }; + seen_users.insert(question.owner, user.clone()); out.push((question, user)); } @@ -311,6 +317,15 @@ impl DataManager { if !receiver.settings.enable_questions { return Err(Error::QuestionsDisabled); } + + // check for ip block + if self + .get_ipblock_by_initiator_receiver(receiver.id, &data.ip) + .await + .is_ok() + { + return Err(Error::NotAllowed); + } } } else { let receiver = self.get_user_by_id(data.receiver).await?; @@ -318,6 +333,19 @@ impl DataManager { if !receiver.settings.enable_questions { return Err(Error::QuestionsDisabled); } + + if !receiver.settings.allow_anonymous_questions && data.owner == 0 { + return Err(Error::NotAllowed); + } + + // check for ip block + if self + .get_ipblock_by_initiator_receiver(receiver.id, &data.ip) + .await + .is_ok() + { + return Err(Error::NotAllowed); + } } // ... @@ -328,7 +356,7 @@ impl DataManager { let res = execute!( &conn, - "INSERT INTO questions VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)", + "INSERT INTO questions VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)", params![ &(data.id as i64), &(data.created as i64), @@ -340,7 +368,8 @@ impl DataManager { &(data.community as i64), &0_i32, &0_i32, - &serde_json::to_string(&data.context).unwrap() + &serde_json::to_string(&data.context).unwrap(), + &data.ip ] ); diff --git a/crates/core/src/database/requests.rs b/crates/core/src/database/requests.rs index 173dcd3..87bde05 100644 --- a/crates/core/src/database/requests.rs +++ b/crates/core/src/database/requests.rs @@ -130,10 +130,8 @@ impl DataManager { .get_request_by_id_linked_asset(id, linked_asset) .await?; - if !force { - if user.id != y.owner && !user.permissions.check(FinePermission::MANAGE_REQUESTS) { - return Err(Error::NotAllowed); - } + if !force && user.id != y.owner && !user.permissions.check(FinePermission::MANAGE_REQUESTS) { + return Err(Error::NotAllowed); } let conn = match self.connect().await { diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index 2ad81a4..56428a1 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -136,6 +136,9 @@ pub struct UserSettings { /// A header shown in the place of "Ask question" if `enable_questions` is true. #[serde(default)] pub motivational_header: String, + /// If questions from anonymous users are allowed. Requires `enable_questions`. + #[serde(default)] + pub allow_anonymous_questions: bool, } impl Default for User { @@ -192,6 +195,15 @@ impl User { } } + /// Anonymous user profile. + pub fn anonymous() -> Self { + Self { + username: "anonymous".to_string(), + id: 0, + ..Default::default() + } + } + /// Create a new token /// /// # Returns @@ -356,6 +368,29 @@ impl UserBlock { } } +#[derive(Serialize, Deserialize)] +pub struct IpBlock { + pub id: usize, + pub created: usize, + pub initiator: usize, + pub receiver: String, +} + +impl IpBlock { + /// Create a new [`IpBlock`]. + pub fn new(initiator: usize, receiver: String) -> Self { + Self { + id: AlmostSnowflake::new(1234567890) + .to_string() + .parse::() + .unwrap(), + created: unix_epoch_timestamp() as usize, + initiator, + receiver, + } + } +} + #[derive(Serialize, Deserialize)] pub struct IpBan { pub ip: String, diff --git a/crates/core/src/model/communities.rs b/crates/core/src/model/communities.rs index 1fa332f..827ebc8 100644 --- a/crates/core/src/model/communities.rs +++ b/crates/core/src/model/communities.rs @@ -307,13 +307,23 @@ pub struct Question { pub likes: isize, #[serde(default)] pub dislikes: isize, + // ... #[serde(default)] pub context: QuestionContext, + /// The IP of the question creator for IP blocking and identifying anonymous users. + #[serde(default)] + pub ip: String, } impl Question { /// Create a new [`Question`]. - pub fn new(owner: usize, receiver: usize, content: String, is_global: bool) -> Self { + pub fn new( + owner: usize, + receiver: usize, + content: String, + is_global: bool, + ip: String, + ) -> Self { Self { id: AlmostSnowflake::new(1234567890) .to_string() @@ -329,18 +339,15 @@ impl Question { likes: 0, dislikes: 0, context: QuestionContext::default(), + ip, } } } #[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Default)] 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/mod.rs b/crates/core/src/model/mod.rs index 0e86e0e..0d43fba 100644 --- a/crates/core/src/model/mod.rs +++ b/crates/core/src/model/mod.rs @@ -6,6 +6,8 @@ pub mod permissions; pub mod reactions; pub mod requests; +use std::fmt::Display; + use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize)] @@ -37,9 +39,9 @@ pub enum Error { Unknown, } -impl ToString for Error { - fn to_string(&self) -> String { - match self { +impl Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&match self { Self::MiscError(msg) => msg.to_owned(), Self::DatabaseConnection(msg) => msg.to_owned(), Self::DatabaseError(msg) => format!("Database error: {msg}"), @@ -55,7 +57,7 @@ impl ToString for Error { 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/l10n/Cargo.toml b/crates/l10n/Cargo.toml index c39f68f..dc4cad4 100644 --- a/crates/l10n/Cargo.toml +++ b/crates/l10n/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto-l10n" -version = "1.0.5" +version = "1.0.6" edition = "2024" authors.workspace = true repository.workspace = true diff --git a/crates/shared/Cargo.toml b/crates/shared/Cargo.toml index 63dbeb3..7415d12 100644 --- a/crates/shared/Cargo.toml +++ b/crates/shared/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto-shared" -version = "1.0.5" +version = "1.0.6" edition = "2024" authors.workspace = true repository.workspace = true @@ -9,10 +9,10 @@ license.workspace = true [dependencies] ammonia = "4.0.0" chrono = "0.4.40" -comrak = "0.37.0" +comrak = "0.38.0" hex_fmt = "0.3.0" num-bigint = "0.4.6" -rand = "0.9.0" +rand = "0.9.1" serde = "1.0.219" sha2 = "0.10.8" uuid = { version = "1.16.0", features = ["v4"] } diff --git a/example/.gitignore b/example/.gitignore index 0d1efcf..ba0c3d0 100644 --- a/example/.gitignore +++ b/example/.gitignore @@ -1,7 +1,7 @@ atto.db* html/* -public/* +# public/* media/* icons/* langs/* diff --git a/example/public/robots.txt b/example/public/robots.txt new file mode 100644 index 0000000..1e6da55 --- /dev/null +++ b/example/public/robots.txt @@ -0,0 +1,2 @@ +User-Agent: * +Disallow: /api diff --git a/example/tetratto.toml b/example/tetratto.toml index c145448..27c651f 100644 --- a/example/tetratto.toml +++ b/example/tetratto.toml @@ -15,6 +15,7 @@ banned_usernames = [ "notification", "post", "void", + "anonymous" ] town_square = 166340372315581657 diff --git a/sql_changes/questions_ip.sql b/sql_changes/questions_ip.sql new file mode 100644 index 0000000..c29c114 --- /dev/null +++ b/sql_changes/questions_ip.sql @@ -0,0 +1,2 @@ +ALTER TABLE questions +ADD COLUMN ip TEXT NOT NULL DEFAULT '';