diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index 1949204..c9e0b93 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -214,6 +214,7 @@ version = "1.0.0" "mod_panel:label.invited_by" = "Invited by" "mod_panel:label.send_debug_payload" = "Send debug payload" "mod_panel:label.ban_reason" = "Ban reason" +"mod_panel:label.ban_expiration" = "Ban expiration" "requests:label.requests" = "Requests" "requests:label.community_join_request" = "Community join request" diff --git a/crates/app/src/macros.rs b/crates/app/src/macros.rs index 9fbda85..c50c68d 100644 --- a/crates/app/src/macros.rs +++ b/crates/app/src/macros.rs @@ -87,10 +87,31 @@ macro_rules! get_user_from_token { { Ok(ua) => { if ua.permissions.check_banned() { - let mut banned_user = tetratto_core::model::auth::User::banned(); - banned_user.ban_reason = ua.ban_reason; + // check expiration + 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 { Some(ua) } diff --git a/crates/app/src/public/html/mod/profile.lisp b/crates/app/src/public/html/mod/profile.lisp index 3201178..8dcf616 100644 --- a/crates/app/src/public/html/mod/profile.lisp +++ b/crates/app/src/public/html/mod/profile.lisp @@ -298,7 +298,7 @@ (div ("class" "flex flex_col gap_1") (label - ("for" "title") + ("for" "reason") (str (text "mod_panel:label.ban_reason"))) (textarea ("type" "text") @@ -309,6 +309,37 @@ (text "{{ profile.ban_reason|remove_script_tags|safe }}"))) (button (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 ("class" "card_nest w_full") (div diff --git a/crates/app/src/public/html/root.lisp b/crates/app/src/public/html/root.lisp index 76b38d9..9a253fe 100644 --- a/crates/app/src/public/html/root.lisp +++ b/crates/app/src/public/html/root.lisp @@ -76,7 +76,17 @@ (span ("class" "fade") (text "The following reason was provided by a moderator:")) (div ("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 (text "{% elif user and user.awaiting_purchase %}") ; account waiting for payment message diff --git a/crates/app/src/routes/api/v1/auth/connections/stripe.rs b/crates/app/src/routes/api/v1/auth/connections/stripe.rs index 2110924..837d2d3 100644 --- a/crates/app/src/routes/api/v1/auth/connections/stripe.rs +++ b/crates/app/src/routes/api/v1/auth/connections/stripe.rs @@ -179,7 +179,7 @@ pub async fn stripe_webhook( let new_user_permissions = user.permissions | FinePermission::SUPPORTER; 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 { return Json(e.into()); @@ -225,7 +225,7 @@ pub async fn stripe_webhook( user.secondary_permissions | SecondaryPermission::DEVELOPER_PASS; 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 { return Json(e.into()); @@ -284,7 +284,7 @@ pub async fn stripe_webhook( let new_user_permissions = user.permissions - FinePermission::SUPPORTER; 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 { return Json(e.into()); @@ -310,7 +310,7 @@ pub async fn stripe_webhook( user.secondary_permissions - SecondaryPermission::DEVELOPER_PASS; 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 { return Json(e.into()); @@ -396,7 +396,7 @@ pub async fn stripe_webhook( let new_user_permissions = user.permissions - FinePermission::SUPPORTER; 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 { return Json(e.into()); @@ -437,7 +437,7 @@ pub async fn stripe_webhook( user.secondary_permissions - SecondaryPermission::DEVELOPER_PASS; 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 { return Json(e.into()); diff --git a/crates/app/src/routes/api/v1/auth/profile.rs b/crates/app/src/routes/api/v1/auth/profile.rs index c5bbcc1..a4ec514 100644 --- a/crates/app/src/routes/api/v1/auth/profile.rs +++ b/crates/app/src/routes/api/v1/auth/profile.rs @@ -4,9 +4,9 @@ use crate::{ model::{ApiReturn, Error}, routes::api::v1::{ AppendAssociations, AwardAchievement, DeleteUser, DisableTotp, RefreshGrantToken, - UpdateSecondaryUserRole, UpdateUserAwaitingPurchase, UpdateUserBanReason, - UpdateUserInviteCode, UpdateUserIsDeactivated, UpdateUserIsVerified, UpdateUserPassword, - UpdateUserRole, UpdateUserUsername, + UpdateSecondaryUserRole, UpdateUserAwaitingPurchase, UpdateUserBanExpire, + UpdateUserBanReason, UpdateUserInviteCode, UpdateUserIsDeactivated, UpdateUserIsVerified, + UpdateUserPassword, UpdateUserRole, UpdateUserUsername, }, State, }; @@ -423,7 +423,7 @@ pub async fn update_user_role_request( 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: true, message: "User updated".to_string(), @@ -449,7 +449,7 @@ pub async fn update_user_secondary_role_request( }; match data - .update_user_secondary_role(id, req.role, user, false) + .update_user_secondary_role(id, req.role, &user, false) .await { 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, + 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()), + }; + + 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. pub async fn seen_request(jar: CookieJar, Extension(data): Extension) -> impl IntoResponse { let data = &(data.read().await).0; diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 2b148df..fa6b913 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -340,6 +340,10 @@ pub fn routes() -> Router { "/auth/user/{id}/ban_reason", post(auth::profile::update_user_ban_reason_request), ) + .route( + "/auth/user/{id}/ban_expire", + post(auth::profile::update_user_ban_expire_request), + ) .route( "/auth/user/{id}", delete(auth::profile::delete_user_request), @@ -916,6 +920,11 @@ pub struct UpdateUserBanReason { pub reason: String, } +#[derive(Deserialize)] +pub struct UpdateUserBanExpire { + pub expire: usize, +} + #[derive(Deserialize)] pub struct UpdateUserInviteCode { pub invite_code: String, diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs index 4ced643..e0aef74 100644 --- a/crates/core/src/database/auth.rs +++ b/crates/core/src/database/auth.rs @@ -27,7 +27,7 @@ macro_rules! update_role_fn { &self, id: usize, role: $role_ty, - user: User, + user: &User, force: bool, ) -> Result<()> { let other_user = self.get_user_by_id(id).await?; @@ -129,6 +129,7 @@ impl DataManager { ban_reason: get!(x->28(String)), channel_mutes: serde_json::from_str(&get!(x->29(String)).to_string()).unwrap(), 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!( &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![ &(data.id as i64), &(data.created as i64), @@ -318,6 +319,7 @@ impl DataManager { &data.ban_reason, &serde_json::to_string(&data.channel_mutes).unwrap(), &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_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)@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!(update_user_stripe_id(&str)@get_user_by_id -> "UPDATE users SET stripe_id = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user); diff --git a/crates/core/src/database/drivers/sql/create_users.sql b/crates/core/src/database/drivers/sql/create_users.sql index 6a939e5..06e86ef 100644 --- a/crates/core/src/database/drivers/sql/create_users.sql +++ b/crates/core/src/database/drivers/sql/create_users.sql @@ -29,5 +29,6 @@ CREATE TABLE IF NOT EXISTS users ( seller_data TEXT NOT NULL, ban_reason TEXT NOT NULL, channel_mutes TEXT NOT NULL, - is_deactivated INT NOT NULL + is_deactivated INT NOT NULL, + ban_expire BIGINT NOT NULL ) diff --git a/crates/core/src/database/drivers/sql/version_migrations.sql b/crates/core/src/database/drivers/sql/version_migrations.sql index cd48530..5c6c51e 100644 --- a/crates/core/src/database/drivers/sql/version_migrations.sql +++ b/crates/core/src/database/drivers/sql/version_migrations.sql @@ -25,3 +25,7 @@ ADD COLUMN IF NOT EXISTS topics TEXT DEFAULT '{}'; -- posts topic ALTER TABLE posts 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; diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index de205cb..7622f1a 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -92,6 +92,9 @@ pub struct User { /// users, but their data is not wiped. #[serde(default)] pub is_deactivated: bool, + /// The time at which the user's ban will automatically expire. + #[serde(default)] + pub ban_expire: usize, } pub type UserConnections = @@ -408,6 +411,7 @@ impl User { ban_reason: String::new(), channel_mutes: Vec::new(), is_deactivated: false, + ban_expire: 0, } }