add: temporary bans

This commit is contained in:
trisua 2025-08-05 13:39:01 -04:00
parent 9650c0177e
commit 155fe34c6e
11 changed files with 132 additions and 19 deletions

View file

@ -214,6 +214,7 @@ version = "1.0.0"
"mod_panel:label.invited_by" = "Invited by" "mod_panel:label.invited_by" = "Invited by"
"mod_panel:label.send_debug_payload" = "Send debug payload" "mod_panel:label.send_debug_payload" = "Send debug payload"
"mod_panel:label.ban_reason" = "Ban reason" "mod_panel:label.ban_reason" = "Ban reason"
"mod_panel:label.ban_expiration" = "Ban expiration"
"requests:label.requests" = "Requests" "requests:label.requests" = "Requests"
"requests:label.community_join_request" = "Community join request" "requests:label.community_join_request" = "Community join request"

View file

@ -87,10 +87,31 @@ macro_rules! get_user_from_token {
{ {
Ok(ua) => { Ok(ua) => {
if ua.permissions.check_banned() { if ua.permissions.check_banned() {
let mut banned_user = tetratto_core::model::auth::User::banned(); // check expiration
banned_user.ban_reason = ua.ban_reason; let now = tetratto_shared::unix_epoch_timestamp();
let expired = ua.ban_expire <= now;
Some(banned_user) if expired && ua.ban_expire != 0 {
$db.update_user_role(
ua.id,
ua.permissions
- tetratto_core::model::permissions::FinePermission::BANNED,
&ua,
true,
)
.await
.expect("failed to auto unban user");
Some(ua)
} else {
// banned
let mut banned_user = tetratto_core::model::auth::User::banned();
banned_user.ban_reason = ua.ban_reason;
banned_user.ban_expire = ua.ban_expire;
Some(banned_user)
}
} else { } else {
Some(ua) Some(ua)
} }

View file

@ -298,7 +298,7 @@
(div (div
("class" "flex flex_col gap_1") ("class" "flex flex_col gap_1")
(label (label
("for" "title") ("for" "reason")
(str (text "mod_panel:label.ban_reason"))) (str (text "mod_panel:label.ban_reason")))
(textarea (textarea
("type" "text") ("type" "text")
@ -309,6 +309,37 @@
(text "{{ profile.ban_reason|remove_script_tags|safe }}"))) (text "{{ profile.ban_reason|remove_script_tags|safe }}")))
(button (button
(str (text "general:action.save"))))) (str (text "general:action.save")))))
(div
("class" "card_nest w_full")
(div
("class" "card small flex items_center justify_between gap_2")
(div
("class" "flex items_center gap_2")
(icon (text "scale"))
(span
(str (text "mod_panel:label.ban_expiration")))))
(form
("class" "card flex flex_col gap_2")
("onsubmit" "event.preventDefault(); profile_request(false, 'ban_expire', { expire: new Date(event.target.expire.value).getTime() || 0 })")
(div
("class" "flex flex_col gap_1")
(label
("for" "expire")
(str (text "mod_panel:label.ban_expiration")))
(input
("type" "datetime-local")
("name" "expire")
("id" "expire")
("value" "{{ profile.ban_expire }}")))
(div
("class" "flex gap_2")
(button
(str (text "general:action.save")))
(button
("type" "button")
("class" "lowered red")
("onclick" "profile_request(false, 'ban_expire', { expire: 0 })")
(str (text "notifs:action.clear"))))))
(div (div
("class" "card_nest w_full") ("class" "card_nest w_full")
(div (div

View file

@ -76,7 +76,17 @@
(span ("class" "fade") (text "The following reason was provided by a moderator:")) (span ("class" "fade") (text "The following reason was provided by a moderator:"))
(div (div
("class" "card lowered w_full") ("class" "card lowered w_full")
(text "{{ user.ban_reason|markdown|safe }}")))))) (text "{{ user.ban_reason|markdown|safe }}"))
(text "{% if user.ban_expire != 0 -%}")
(hr)
(span
(text "Your ban will expire on: ")
(span ("id" "ban_expire")))
(script
(text "document.getElementById(\"ban_expire\").innerText = new Date({{ user.ban_expire }}).toLocaleString();"))
(text "{% else %}")
(span (text "This ban is marked as permanent."))
(text "{%- endif %}")))))
; if we aren't banned, just show the page body ; if we aren't banned, just show the page body
(text "{% elif user and user.awaiting_purchase %}") (text "{% elif user and user.awaiting_purchase %}")
; account waiting for payment message ; account waiting for payment message

View file

@ -179,7 +179,7 @@ pub async fn stripe_webhook(
let new_user_permissions = user.permissions | FinePermission::SUPPORTER; let new_user_permissions = user.permissions | FinePermission::SUPPORTER;
if let Err(e) = data if let Err(e) = data
.update_user_role(user.id, new_user_permissions, user.clone(), true) .update_user_role(user.id, new_user_permissions, &user, true)
.await .await
{ {
return Json(e.into()); return Json(e.into());
@ -225,7 +225,7 @@ pub async fn stripe_webhook(
user.secondary_permissions | SecondaryPermission::DEVELOPER_PASS; user.secondary_permissions | SecondaryPermission::DEVELOPER_PASS;
if let Err(e) = data if let Err(e) = data
.update_user_secondary_role(user.id, new_user_permissions, user.clone(), true) .update_user_secondary_role(user.id, new_user_permissions, &user, true)
.await .await
{ {
return Json(e.into()); return Json(e.into());
@ -284,7 +284,7 @@ pub async fn stripe_webhook(
let new_user_permissions = user.permissions - FinePermission::SUPPORTER; let new_user_permissions = user.permissions - FinePermission::SUPPORTER;
if let Err(e) = data if let Err(e) = data
.update_user_role(user.id, new_user_permissions, user.clone(), true) .update_user_role(user.id, new_user_permissions, &user, true)
.await .await
{ {
return Json(e.into()); return Json(e.into());
@ -310,7 +310,7 @@ pub async fn stripe_webhook(
user.secondary_permissions - SecondaryPermission::DEVELOPER_PASS; user.secondary_permissions - SecondaryPermission::DEVELOPER_PASS;
if let Err(e) = data if let Err(e) = data
.update_user_secondary_role(user.id, new_user_permissions, user.clone(), true) .update_user_secondary_role(user.id, new_user_permissions, &user, true)
.await .await
{ {
return Json(e.into()); return Json(e.into());
@ -396,7 +396,7 @@ pub async fn stripe_webhook(
let new_user_permissions = user.permissions - FinePermission::SUPPORTER; let new_user_permissions = user.permissions - FinePermission::SUPPORTER;
if let Err(e) = data if let Err(e) = data
.update_user_role(user.id, new_user_permissions, user.clone(), true) .update_user_role(user.id, new_user_permissions, &user, true)
.await .await
{ {
return Json(e.into()); return Json(e.into());
@ -437,7 +437,7 @@ pub async fn stripe_webhook(
user.secondary_permissions - SecondaryPermission::DEVELOPER_PASS; user.secondary_permissions - SecondaryPermission::DEVELOPER_PASS;
if let Err(e) = data if let Err(e) = data
.update_user_secondary_role(user.id, new_user_permissions, user.clone(), true) .update_user_secondary_role(user.id, new_user_permissions, &user, true)
.await .await
{ {
return Json(e.into()); return Json(e.into());

View file

@ -4,9 +4,9 @@ use crate::{
model::{ApiReturn, Error}, model::{ApiReturn, Error},
routes::api::v1::{ routes::api::v1::{
AppendAssociations, AwardAchievement, DeleteUser, DisableTotp, RefreshGrantToken, AppendAssociations, AwardAchievement, DeleteUser, DisableTotp, RefreshGrantToken,
UpdateSecondaryUserRole, UpdateUserAwaitingPurchase, UpdateUserBanReason, UpdateSecondaryUserRole, UpdateUserAwaitingPurchase, UpdateUserBanExpire,
UpdateUserInviteCode, UpdateUserIsDeactivated, UpdateUserIsVerified, UpdateUserPassword, UpdateUserBanReason, UpdateUserInviteCode, UpdateUserIsDeactivated, UpdateUserIsVerified,
UpdateUserRole, UpdateUserUsername, UpdateUserPassword, UpdateUserRole, UpdateUserUsername,
}, },
State, State,
}; };
@ -423,7 +423,7 @@ pub async fn update_user_role_request(
None => return Json(Error::NotAllowed.into()), None => return Json(Error::NotAllowed.into()),
}; };
match data.update_user_role(id, req.role, user, false).await { match data.update_user_role(id, req.role, &user, false).await {
Ok(_) => Json(ApiReturn { Ok(_) => Json(ApiReturn {
ok: true, ok: true,
message: "User updated".to_string(), message: "User updated".to_string(),
@ -449,7 +449,7 @@ pub async fn update_user_secondary_role_request(
}; };
match data match data
.update_user_secondary_role(id, req.role, user, false) .update_user_secondary_role(id, req.role, &user, false)
.await .await
{ {
Ok(_) => Json(ApiReturn { Ok(_) => Json(ApiReturn {
@ -490,6 +490,35 @@ pub async fn update_user_ban_reason_request(
} }
} }
/// Update the ban expiration date of the given user.
///
/// Does not support third-party grants.
pub async fn update_user_ban_expire_request(
jar: CookieJar,
Path(id): Path<usize>,
Extension(data): Extension<State>,
Json(req): Json<UpdateUserBanExpire>,
) -> 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 !user.permissions.check(FinePermission::MANAGE_USERS) {
return Json(Error::NotAllowed.into());
}
match data.update_user_ban_expire(id, req.expire as i64).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "User updated".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
/// Update the current user's last seen value. /// Update the current user's last seen value.
pub async fn seen_request(jar: CookieJar, Extension(data): Extension<State>) -> impl IntoResponse { pub async fn seen_request(jar: CookieJar, Extension(data): Extension<State>) -> impl IntoResponse {
let data = &(data.read().await).0; let data = &(data.read().await).0;

View file

@ -340,6 +340,10 @@ pub fn routes() -> Router {
"/auth/user/{id}/ban_reason", "/auth/user/{id}/ban_reason",
post(auth::profile::update_user_ban_reason_request), post(auth::profile::update_user_ban_reason_request),
) )
.route(
"/auth/user/{id}/ban_expire",
post(auth::profile::update_user_ban_expire_request),
)
.route( .route(
"/auth/user/{id}", "/auth/user/{id}",
delete(auth::profile::delete_user_request), delete(auth::profile::delete_user_request),
@ -916,6 +920,11 @@ pub struct UpdateUserBanReason {
pub reason: String, pub reason: String,
} }
#[derive(Deserialize)]
pub struct UpdateUserBanExpire {
pub expire: usize,
}
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct UpdateUserInviteCode { pub struct UpdateUserInviteCode {
pub invite_code: String, pub invite_code: String,

View file

@ -27,7 +27,7 @@ macro_rules! update_role_fn {
&self, &self,
id: usize, id: usize,
role: $role_ty, role: $role_ty,
user: User, user: &User,
force: bool, force: bool,
) -> Result<()> { ) -> Result<()> {
let other_user = self.get_user_by_id(id).await?; let other_user = self.get_user_by_id(id).await?;
@ -129,6 +129,7 @@ impl DataManager {
ban_reason: get!(x->28(String)), ban_reason: get!(x->28(String)),
channel_mutes: serde_json::from_str(&get!(x->29(String)).to_string()).unwrap(), channel_mutes: serde_json::from_str(&get!(x->29(String)).to_string()).unwrap(),
is_deactivated: get!(x->30(i32)) as i8 == 1, is_deactivated: get!(x->30(i32)) as i8 == 1,
ban_expire: get!(x->31(i64)) as usize,
} }
} }
@ -285,7 +286,7 @@ impl DataManager {
let res = execute!( let res = execute!(
&conn, &conn,
"INSERT INTO users VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31)", "INSERT INTO users VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32)",
params![ params![
&(data.id as i64), &(data.id as i64),
&(data.created as i64), &(data.created as i64),
@ -318,6 +319,7 @@ impl DataManager {
&data.ban_reason, &data.ban_reason,
&serde_json::to_string(&data.channel_mutes).unwrap(), &serde_json::to_string(&data.channel_mutes).unwrap(),
&if data.is_deactivated { 1_i32 } else { 0_i32 }, &if data.is_deactivated { 1_i32 } else { 0_i32 },
&(data.ban_expire as i64),
] ]
); );
@ -1059,6 +1061,7 @@ impl DataManager {
auto_method!(update_user_seller_data(StripeSellerData)@get_user_by_id -> "UPDATE users SET seller_data = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user); auto_method!(update_user_seller_data(StripeSellerData)@get_user_by_id -> "UPDATE users SET seller_data = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user);
auto_method!(update_user_ban_reason(&str)@get_user_by_id -> "UPDATE users SET ban_reason = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user); auto_method!(update_user_ban_reason(&str)@get_user_by_id -> "UPDATE users SET ban_reason = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user);
auto_method!(update_user_channel_mutes(Vec<usize>)@get_user_by_id -> "UPDATE users SET channel_mutes = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user); auto_method!(update_user_channel_mutes(Vec<usize>)@get_user_by_id -> "UPDATE users SET channel_mutes = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user);
auto_method!(update_user_ban_expire(i64)@get_user_by_id -> "UPDATE users SET ban_expire = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user);
auto_method!(get_user_by_stripe_id(&str)@get_user_from_row -> "SELECT * FROM users WHERE stripe_id = $1" --name="user" --returns=User); auto_method!(get_user_by_stripe_id(&str)@get_user_from_row -> "SELECT * FROM users WHERE stripe_id = $1" --name="user" --returns=User);
auto_method!(update_user_stripe_id(&str)@get_user_by_id -> "UPDATE users SET stripe_id = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user); auto_method!(update_user_stripe_id(&str)@get_user_by_id -> "UPDATE users SET stripe_id = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user);

View file

@ -29,5 +29,6 @@ CREATE TABLE IF NOT EXISTS users (
seller_data TEXT NOT NULL, seller_data TEXT NOT NULL,
ban_reason TEXT NOT NULL, ban_reason TEXT NOT NULL,
channel_mutes TEXT NOT NULL, channel_mutes TEXT NOT NULL,
is_deactivated INT NOT NULL is_deactivated INT NOT NULL,
ban_expire BIGINT NOT NULL
) )

View file

@ -25,3 +25,7 @@ ADD COLUMN IF NOT EXISTS topics TEXT DEFAULT '{}';
-- posts topic -- posts topic
ALTER TABLE posts ALTER TABLE posts
ADD COLUMN IF NOT EXISTS topic BIGINT DEFAULT 0; ADD COLUMN IF NOT EXISTS topic BIGINT DEFAULT 0;
-- users ban_expire
ALTER TABLE users
ADD COLUMN IF NOT EXISTS ban_expire BIGINT DEFAULT 0;

View file

@ -92,6 +92,9 @@ pub struct User {
/// users, but their data is not wiped. /// users, but their data is not wiped.
#[serde(default)] #[serde(default)]
pub is_deactivated: bool, pub is_deactivated: bool,
/// The time at which the user's ban will automatically expire.
#[serde(default)]
pub ban_expire: usize,
} }
pub type UserConnections = pub type UserConnections =
@ -408,6 +411,7 @@ impl User {
ban_reason: String::new(), ban_reason: String::new(),
channel_mutes: Vec::new(), channel_mutes: Vec::new(),
is_deactivated: false, is_deactivated: false,
ban_expire: 0,
} }
} }