From 01633913807a3ac7422c884226b2d84d69d80cc9 Mon Sep 17 00:00:00 2001 From: trisua Date: Sat, 28 Jun 2025 13:15:37 -0400 Subject: [PATCH] add: ability to ip block users from their profile --- crates/app/src/langs/en-US.toml | 1 + crates/app/src/public/css/root.css | 4 ++ crates/app/src/public/html/components.lisp | 9 +-- crates/app/src/public/html/profile/base.lisp | 48 +++++++++++++-- .../app/src/public/html/profile/settings.lisp | 53 ++++++++++++++++ crates/app/src/public/js/atto.js | 6 +- crates/app/src/public/js/me.js | 21 +++++++ crates/app/src/routes/api/v1/auth/social.rs | 61 +++++++++++++++++++ crates/app/src/routes/api/v1/mod.rs | 8 +++ crates/app/src/routes/pages/profile.rs | 6 ++ crates/core/src/database/ipblocks.rs | 35 ++++++++--- crates/core/src/model/auth.rs | 9 +++ 12 files changed, 241 insertions(+), 20 deletions(-) diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index 13b6b64..fe07c8b 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -169,6 +169,7 @@ version = "1.0.0" "settings:label.export" = "Export" "settings:label.manage_blocks" = "Manage blocks" "settings:label.users" = "Users" +"settings:label.ips" = "IPs" "settings:label.generate_invites" = "Generate invites" "settings:label.add_to_stack" = "Add to stack" "settings:tab.security" = "Security" diff --git a/crates/app/src/public/css/root.css b/crates/app/src/public/css/root.css index 3de8708..34281e6 100644 --- a/crates/app/src/public/css/root.css +++ b/crates/app/src/public/css/root.css @@ -38,6 +38,10 @@ --pad-2: 0.5rem; --pad-3: 0.75rem; --pad-4: 1rem; + + --online: var(--color-green); + --idle: var(--color-yellow); + --offline: hsl(0, 0%, 50%); } .dark, diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index 2f4f004..ba7f4e3 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -528,7 +528,7 @@ ("width" "24") ("height" "24") ("viewBox" "0 0 24 24") - ("style" "fill: var(--color-green)") + ("style" "fill: var(--online)") (circle ("cx" "12") ("cy" "12") @@ -541,7 +541,7 @@ ("width" "24") ("height" "24") ("viewBox" "0 0 24 24") - ("style" "fill: var(--color-yellow)") + ("style" "fill: var(--idle)") (circle ("cx" "12") ("cy" "12") @@ -554,7 +554,7 @@ ("width" "24") ("height" "24") ("viewBox" "0 0 24 24") - ("style" "fill: hsl(0, 0%, 50%)") + ("style" "fill: var(--offline)") (circle ("cx" "12") ("cy" "12") @@ -611,7 +611,8 @@ (text "{%- endif %}") (div ("style" "display: none;") - (text "{{ self::theme_color(color=user.settings.theme_color_surface, css=\"color-surface\") }} {{ self::theme_color(color=user.settings.theme_color_text, css=\"color-text\") }} {{ self::theme_color(color=user.settings.theme_color_text_link, css=\"color-link\") }} {{ self::theme_color(color=user.settings.theme_color_lowered, css=\"color-lowered\") }} {{ self::theme_color(color=user.settings.theme_color_text_lowered, css=\"color-text-lowered\") }} {{ self::theme_color(color=user.settings.theme_color_super_lowered, css=\"color-super-lowered\") }} {{ self::theme_color(color=user.settings.theme_color_raised, css=\"color-raised\") }} {{ self::theme_color(color=user.settings.theme_color_text_raised, css=\"color-text-raised\") }} {{ self::theme_color(color=user.settings.theme_color_super_raised, css=\"color-super-raised\") }} {{ self::theme_color(color=user.settings.theme_color_primary, css=\"color-primary\") }} {{ self::theme_color(color=user.settings.theme_color_text_primary, css=\"color-text-primary\") }} {{ self::theme_color(color=user.settings.theme_color_primary_lowered, css=\"color-primary-lowered\") }} {{ self::theme_color(color=user.settings.theme_color_secondary, css=\"color-secondary\") }} {{ self::theme_color(color=user.settings.theme_color_text_secondary, css=\"color-text-secondary\") }} {{ self::theme_color(color=user.settings.theme_color_secondary_lowered, css=\"color-secondary-lowered\") }} {% if user.permissions|has_supporter -%}") + (text "{{ self::theme_color(color=user.settings.theme_color_surface, css=\"color-surface\") }} {{ self::theme_color(color=user.settings.theme_color_text, css=\"color-text\") }} {{ self::theme_color(color=user.settings.theme_color_text_link, css=\"color-link\") }} {{ self::theme_color(color=user.settings.theme_color_lowered, css=\"color-lowered\") }} {{ self::theme_color(color=user.settings.theme_color_text_lowered, css=\"color-text-lowered\") }} {{ self::theme_color(color=user.settings.theme_color_super_lowered, css=\"color-super-lowered\") }} {{ self::theme_color(color=user.settings.theme_color_raised, css=\"color-raised\") }} {{ self::theme_color(color=user.settings.theme_color_text_raised, css=\"color-text-raised\") }} {{ self::theme_color(color=user.settings.theme_color_super_raised, css=\"color-super-raised\") }} {{ self::theme_color(color=user.settings.theme_color_primary, css=\"color-primary\") }} {{ self::theme_color(color=user.settings.theme_color_text_primary, css=\"color-text-primary\") }} {{ self::theme_color(color=user.settings.theme_color_primary_lowered, css=\"color-primary-lowered\") }} {{ self::theme_color(color=user.settings.theme_color_secondary, css=\"color-secondary\") }} {{ self::theme_color(color=user.settings.theme_color_text_secondary, css=\"color-text-secondary\") }} {{ self::theme_color(color=user.settings.theme_color_secondary_lowered, css=\"color-secondary-lowered\") }} + {{ self::theme_color(color=user.settings.theme_color_online, css=\"online\") }} {{ self::theme_color(color=user.settings.theme_color_idle, css=\"idle\") }} {{ self::theme_color(color=user.settings.theme_color_offline, css=\"offline\") }} {% if user.permissions|has_supporter -%}") (style (text "{{ user.settings.theme_custom_css|remove_script_tags|safe }}")) (text "{%- endif %}")) diff --git a/crates/app/src/public/html/profile/base.lisp b/crates/app/src/public/html/profile/base.lisp index 7718eea..481c007 100644 --- a/crates/app/src/public/html/profile/base.lisp +++ b/crates/app/src/public/html/profile/base.lisp @@ -219,12 +219,24 @@ (text "{{ icon \"user-minus\" }}") (span (text "{{ text \"auth:action.unfollow\" }}"))) - (button - ("onclick" "toggle_block_user()") - ("class" "lowered red") - (text "{{ icon \"shield\" }}") - (span - (text "{{ text \"auth:action.block\" }}"))) + (div + ("class" "dropdown") + (button + ("onclick" "trigger('atto::hooks::dropdown', [event])") + ("exclude" "dropdown") + ("class" "lowered red") + (icon_class (text "chevron-down") (text "dropdown-arrow")) + (str (text "auth:action.block"))) + (div + ("class" "inner") + (button + ("onclick" "toggle_block_user()") + (icon (text "shield")) + (str (text "auth:action.block"))) + (button + ("onclick" "ip_block_user()") + (icon (text "wifi")) + (str (text "auth:action.ip_block"))))) (text "{% else %}") (button ("onclick" "toggle_block_user()") @@ -342,6 +354,30 @@ res.message, ]); }); + }; + + globalThis.ip_block_user = async () => { + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this?\", + ])) + ) { + return; + } + + fetch( + \"/api/v1/auth/user/{{ profile.id }}/block_ip\", + { + method: \"POST\", + }, + ) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); };")))) (text "{%- endif %} {% if not profile.settings.private_communities or is_self or is_helper %}") (div diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index 4ac8b4c..1d699a9 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -446,6 +446,30 @@ ("class" "button lowered small") (icon (text "external-link")) (span (str (text "requests:action.view_profile")))))) + (text "{% endfor %}"))) + + ; ip blocks + (div + ("class" "card-nest") + (div + ("class" "card flex items-center gap-2 small") + (text "{{ icon \"wifi\" }}") + (span + (text "{{ text \"settings:label.ips\" }}"))) + (div + ("class" "card flex flex-col gap-2") + (text "{% for ip in ipblocks %}") + (div + ("class" "card secondary flex flex-wrap gap-2 items-center justify-between") + (span + (text "Block from: ") (span ("class" "date") (text "{{ ip.created }}"))) + (div + ("class" "flex gap-2") + (button + ("onclick" "trigger('me::remove_ip_block', ['{{ ip.id }}'])") + ("class" "lowered small red") + (icon (text "x")) + (span (str (text "auth:action.unblock")))))) (text "{% endfor %}"))))) (div ("class" "w-full flex flex-col gap-2 hidden") @@ -1734,6 +1758,35 @@ description: \"Hover state for secondary buttons.\", }, ], + // online indicator + [[], \"\", \"divider\"], + [ + [\"theme_color_online\", \"Online indicator (online)\"], + \"{{ profile.settings.theme_color_online }}\", + \"color\", + { + description: + \"The green dot next to the name of online users.\", + }, + ], + [ + [\"theme_color_idle\", \"Online indicator (idle)\"], + \"{{ profile.settings.theme_color_idle }}\", + \"color\", + { + description: + \"The yellow dot next to the name of online users.\", + }, + ], + [ + [\"theme_color_offline\", \"Online indicator (offline)\"], + \"{{ profile.settings.theme_color_offline }}\", + \"color\", + { + description: + \"The grey next to the name of online users.\", + }, + ], ]; if (can_use_custom_css) { diff --git a/crates/app/src/public/js/atto.js b/crates/app/src/public/js/atto.js index 32c5b5f..85c9c2c 100644 --- a/crates/app/src/public/js/atto.js +++ b/crates/app/src/public/js/atto.js @@ -505,7 +505,7 @@ media_theme_pref(); return now - last_seen <= maximum_time_to_be_considered_idle; }); - self.define("hooks::online_indicator", ({ $ }) => { + self.define("hooks::online_indicator", async ({ $ }) => { for (const element of Array.from( document.querySelectorAll("[hook=online_indicator]") || [], )) { @@ -513,8 +513,8 @@ media_theme_pref(); element.getAttribute("hook-arg:last_seen"), ); - const is_online = $.last_seen_just_now(last_seen); - const is_idle = $.last_seen_recently(last_seen); + const is_online = await $.last_seen_just_now(last_seen); + const is_idle = await $.last_seen_recently(last_seen); const offline = element.querySelector("[hook_ui_ident=offline]"); const online = element.querySelector("[hook_ui_ident=online]"); diff --git a/crates/app/src/public/js/me.js b/crates/app/src/public/js/me.js index 15bf7a2..36ff6b1 100644 --- a/crates/app/src/public/js/me.js +++ b/crates/app/src/public/js/me.js @@ -402,6 +402,27 @@ }); }); + self.define("remove_ip_block", async (_, id) => { + if ( + !(await trigger("atto::confirm", [ + "Are you sure you want to do this?", + ])) + ) { + return; + } + + fetch(`/api/v1/auth/ip/${id}/unblock_ip`, { + method: "POST", + }) + .then((res) => res.json()) + .then((res) => { + trigger("atto::toast", [ + res.ok ? "success" : "error", + res.message, + ]); + }); + }); + self.define("notifications_stream", ({ _, streams }) => { const element = document.getElementById("notifications_span"); diff --git a/crates/app/src/routes/api/v1/auth/social.rs b/crates/app/src/routes/api/v1/auth/social.rs index 53aff80..badbbc2 100644 --- a/crates/app/src/routes/api/v1/auth/social.rs +++ b/crates/app/src/routes/api/v1/auth/social.rs @@ -314,3 +314,64 @@ pub async fn following_request( Err(e) => Json(e.into()), } } + +pub async fn ip_block_profile_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, oauth::AppScope::UserCreateIpBlock) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + // get other user + let other_user = match data.get_user_by_id(id).await { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + for (ip, _, _) in other_user.tokens { + // check for an existing ip block + if data + .get_ipblock_by_initiator_receiver(user.id, &ip) + .await + .is_ok() + { + continue; + } + + // create ip block + if let Err(e) = data.create_ipblock(IpBlock::new(user.id, ip)).await { + return Json(e.into()); + } + } + + Json(ApiReturn { + ok: true, + message: "IP(s) blocked".to_string(), + payload: (), + }) +} + +pub async fn remove_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, oauth::AppScope::UserManageBlocks) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.delete_ipblock(id, user).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "IP unblocked".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 9f850af..b87dbdc 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -288,6 +288,14 @@ pub fn routes() -> Router { post(auth::social::accept_follow_request), ) .route("/auth/user/{id}/block", post(auth::social::block_request)) + .route( + "/auth/user/{id}/block_ip", + post(auth::social::ip_block_profile_request), + ) + .route( + "/auth/ip/{id}/unblock_ip", + post(auth::social::remove_ip_block_request), + ) .route( "/auth/user/{id}/settings", post(auth::profile::update_user_settings_request), diff --git a/crates/app/src/routes/pages/profile.rs b/crates/app/src/routes/pages/profile.rs index 17d0d1f..ed7adcd 100644 --- a/crates/app/src/routes/pages/profile.rs +++ b/crates/app/src/routes/pages/profile.rs @@ -94,6 +94,11 @@ pub async fn settings_request( out }; + let ipblocks = match data.0.get_ipblocks_by_initiator(profile.id).await { + Ok(l) => l, + Err(e) => return Err(Html(render_error(e, &jar, &data, &None).await)), + }; + let uploads = match data.0.get_uploads_by_owner(profile.id, 12, req.page).await { Ok(ua) => ua, Err(e) => { @@ -129,6 +134,7 @@ pub async fn settings_request( context.insert("following", &following); context.insert("blocks", &blocks); context.insert("stackblocks", &stackblocks); + context.insert("ipblocks", &ipblocks); context.insert("invites", &invites); context.insert( "user_tokens_serde", diff --git a/crates/core/src/database/ipblocks.rs b/crates/core/src/database/ipblocks.rs index f94ed51..9eaf892 100644 --- a/crates/core/src/database/ipblocks.rs +++ b/crates/core/src/database/ipblocks.rs @@ -2,7 +2,7 @@ use oiseau::cache::Cache; use crate::model::{Error, Result, auth::User, auth::IpBlock, permissions::FinePermission}; use crate::{auto_method, DataManager}; -use oiseau::PostgresRow; +use oiseau::{query_rows, PostgresRow}; use oiseau::{execute, get, query_row, params}; @@ -19,7 +19,7 @@ impl DataManager { 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). + /// Get a ip block by `initiator` and `receiver` (in that order). pub async fn get_ipblock_by_initiator_receiver( &self, initiator: usize, @@ -38,13 +38,13 @@ impl DataManager { ); if res.is_err() { - return Err(Error::GeneralNotFound("user block".to_string())); + return Err(Error::GeneralNotFound("ip block".to_string())); } Ok(res.unwrap()) } - /// Get a user block by `receiver` and `initiator` (in that order). + /// Get a ip block by `receiver` and `initiator` (in that order). pub async fn get_ipblock_by_receiver_initiator( &self, receiver: &str, @@ -63,13 +63,34 @@ impl DataManager { ); if res.is_err() { - return Err(Error::GeneralNotFound("user block".to_string())); + return Err(Error::GeneralNotFound("ip block".to_string())); } Ok(res.unwrap()) } - /// Create a new user block in the database. + /// Get all ip blocks by `initiator`. + pub async fn get_ipblocks_by_initiator(&self, initiator: usize) -> Result> { + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = query_rows!( + &conn, + "SELECT * FROM ipblocks WHERE initiator = $1", + params![&(initiator as i64)], + |x| { Self::get_ipblock_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("ip block".to_string())); + } + + Ok(res.unwrap()) + } + + /// Create a new ip block in the database. /// /// # Arguments /// * `data` - a mock [`IpBlock`] object to insert @@ -102,7 +123,7 @@ impl DataManager { let block = self.get_ipblock_by_id(id).await?; if user.id != block.initiator { - // only the initiator (or moderators) can delete user blocks! + // only the initiator (or moderators) can delete ip blocks! if !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 054d449..c91db34 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -192,6 +192,15 @@ pub struct UserSettings { /// Custom CSS input. #[serde(default)] pub theme_custom_css: String, + /// The color of an online online indicator. + #[serde(default)] + pub theme_color_online: String, + /// The color of an idle online indicator. + #[serde(default)] + pub theme_color_idle: String, + /// The color of an offline online indicator. + #[serde(default)] + pub theme_color_offline: String, #[serde(default)] pub disable_other_themes: bool, #[serde(default)]