add: anonymous questions

This commit is contained in:
trisua 2025-04-19 18:59:55 -04:00
parent 2266afde01
commit 3db7f2699c
34 changed files with 473 additions and 98 deletions

View file

@ -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 }

View file

@ -257,6 +257,7 @@ fn default_banned_usernames() -> Vec<String> {
"notification".to_string(),
"post".to_string(),
"void".to_string(),
"anonymous".to_string(),
]
}

View file

@ -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!(

View file

@ -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)),
))

View file

@ -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");

View file

@ -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
)

View file

@ -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
)

View file

@ -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<IpBlock> {
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<IpBlock> {
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(())
}
}

View file

@ -4,6 +4,7 @@ mod common;
mod communities;
mod drivers;
mod ipbans;
mod ipblocks;
mod memberships;
mod notifications;
mod posts;

View file

@ -105,7 +105,12 @@ impl DataManager {
pub async fn get_post_question(&self, post: &Post) -> Result<Option<(Question, User)>> {
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);
}

View file

@ -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
]
);

View file

@ -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 {

View file

@ -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::<usize>()
.unwrap(),
created: unix_epoch_timestamp() as usize,
initiator,
receiver,
}
}
}
#[derive(Serialize, Deserialize)]
pub struct IpBan {
pub ip: String,

View file

@ -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 }
}
}

View file

@ -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),
}
})
}
}