add: ability to ip block users from their profile

This commit is contained in:
trisua 2025-06-28 13:15:37 -04:00
parent a799c777ea
commit 0163391380
12 changed files with 241 additions and 20 deletions

View file

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

View file

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

View file

@ -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 %}"))

View file

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

View file

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

View file

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

View file

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

View file

@ -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<State>,
Path(id): Path<usize>,
) -> 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<State>,
Path(id): Path<usize>,
) -> 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()),
}
}

View file

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

View file

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

View file

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

View file

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