diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs index 225c842..6036787 100644 --- a/crates/app/src/assets.rs +++ b/crates/app/src/assets.rs @@ -57,6 +57,7 @@ pub const TIMELINES_POPULAR: &str = include_str!("./public/html/timelines/popula pub const MOD_AUDIT_LOG: &str = include_str!("./public/html/mod/audit_log.html"); pub const MOD_REPORTS: &str = include_str!("./public/html/mod/reports.html"); pub const MOD_FILE_REPORT: &str = include_str!("./public/html/mod/file_report.html"); +pub const MOD_IP_BANS: &str = include_str!("./public/html/mod/ip_bans.html"); // langs pub const LANG_EN_US: &str = include_str!("./langs/en-US.toml"); @@ -180,6 +181,7 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD { write_template!(html_path->"mod/audit_log.html"(crate::assets::MOD_AUDIT_LOG) -d "mod" --config=config); write_template!(html_path->"mod/reports.html"(crate::assets::MOD_REPORTS) --config=config); write_template!(html_path->"mod/file_report.html"(crate::assets::MOD_FILE_REPORT) --config=config); + write_template!(html_path->"mod/ip_bans.html"(crate::assets::MOD_IP_BANS) --config=config); html_path } diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index 6a6c1cc..c6cf324 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -11,6 +11,7 @@ version = "1.0.0" "general:link.reference" = "Reference" "general:link.audit_log" = "Audit log" "general:link.reports" = "Reports" +"general:link.ip_bans" = "IP bans" "general:action.save" = "Save" "general:action.delete" = "Delete" "general:action.back" = "Back" diff --git a/crates/app/src/public/html/macros.html b/crates/app/src/public/html/macros.html index 4969c27..a90103f 100644 --- a/crates/app/src/public/html/macros.html +++ b/crates/app/src/public/html/macros.html @@ -87,6 +87,11 @@ show_lhs=true) -%} {{ icon "flag" }} {{ text "general:link.reports" }} + + + {{ icon "ban" }} + {{ text "general:link.ip_bans" }} + {% endif %} {{ config.name }} diff --git a/crates/app/src/public/html/mod/ip_bans.html b/crates/app/src/public/html/mod/ip_bans.html new file mode 100644 index 0000000..c1f32bf --- /dev/null +++ b/crates/app/src/public/html/mod/ip_bans.html @@ -0,0 +1,70 @@ +{% import "macros.html" as macros %} {% extends "root.html" %} {% block head %} +IP Bans - {{ config.name }} +{% endblock %} {% block body %} {{ macros::nav(selected="notifications") }} +
+
+
+ {{ icon "ban" }} + {{ text "general:link.ip_bans" }} +
+ +
+ + {% for item in items %} +
+ + + {{ components::avatar(username=item.moderator, selector_type="id") }} + {{ item.moderator }} + {{ item.created }} + + +
+ {{ item.ip }} + {{ item.reason|markdown|safe }} + +
+ +
+
+
+ {% endfor %} + + + {{ components::pagination(page=page, items=items|length) }} +
+
+
+ + +{% endblock %} diff --git a/crates/app/src/public/html/profile/settings.html b/crates/app/src/public/html/profile/settings.html index 5b4d1fe..9fc92d0 100644 --- a/crates/app/src/public/html/profile/settings.html +++ b/crates/app/src/public/html/profile/settings.html @@ -241,7 +241,19 @@ " >{{ token[1] }} - {{ token[0] }} + {% if is_helper %} + + {{ token[0] }} + + {% else %} + {{ token[0] }} + {% endif %} + {{ token[2] }} diff --git a/crates/app/src/public/html/root.html b/crates/app/src/public/html/root.html index df44a98..ff0fe51 100644 --- a/crates/app/src/public/html/root.html +++ b/crates/app/src/public/html/root.html @@ -104,7 +104,7 @@ atto["hooks::long_text.init"](); atto["hooks::alt"](); atto["hooks::online_indicator"](); - // atto["hooks::ips"](); + atto["hooks::ips"](); atto["hooks::check_reactions"](); atto["hooks::tabs"](); atto["hooks::partial_embeds"](); diff --git a/crates/app/src/public/js/atto.js b/crates/app/src/public/js/atto.js index ccf7757..f519e2c 100644 --- a/crates/app/src/public/js/atto.js +++ b/crates/app/src/public/js/atto.js @@ -408,6 +408,62 @@ media_theme_pref(); } }); + self.define("hooks::ips", ({ $ }) => { + for (const anchor of Array.from(document.querySelectorAll("a"))) { + try { + const href = new URL(anchor.href); + + if ( + href.pathname.startsWith("/api/v1/auth/profile/find_by_ip/") + ) { + const ban_button = document.createElement("button"); + ban_button.innerText = "Ban IP"; + ban_button.className = "quaternary red small"; + anchor.parentElement.parentElement.appendChild(ban_button); + + ban_button.addEventListener("click", async (e) => { + e.preventDefault(); + + $.ban_ip( + href.pathname.replace( + "/api/v1/auth/profile/find_by_ip/", + "", + ), + ); + }); + } + } catch {} + } + }); + + self.define("ban_ip", async ({ $ }, ip) => { + const reason = await $.prompt( + "Please explain your reason for banning this IP below:", + ); + + if (!reason) { + return; + } + + fetch(`/api/v1/bans/${ip}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + ip, + reason, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger("app::toast", [ + res.success ? "success" : "error", + res.message, + ]); + }); + }); + self.define( "hooks::attach_to_partial", ({ $ }, partial, full, attach, wrapper, page, run_on_load) => { diff --git a/crates/app/src/routes/api/v1/auth/profile.rs b/crates/app/src/routes/api/v1/auth/profile.rs index c2d5006..11ded2b 100644 --- a/crates/app/src/routes/api/v1/auth/profile.rs +++ b/crates/app/src/routes/api/v1/auth/profile.rs @@ -33,6 +33,27 @@ pub async fn redirect_from_id( } } +pub async fn redirect_from_ip( + jar: CookieJar, + Extension(data): Extension, + Path(ip): Path, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data) { + Some(ua) => ua, + None => return Redirect::to("/"), + }; + + if !user.permissions.check(FinePermission::MANAGE_BANS) { + return Redirect::to("/"); + } + + match data.get_user_by_token(&ip).await { + Ok(u) => Redirect::to(&format!("/@{}", u.username)), + Err(_) => Redirect::to("/"), + } +} + /// Update the settings of the given user. pub async fn update_user_settings_request( jar: CookieJar, diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 1abffcb..ecfe4f9 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -152,6 +152,10 @@ pub fn routes() -> Router { "/auth/profile/find/{id}", get(auth::profile::redirect_from_id), ) + .route( + "/auth/profile/find_by_ip/{ip}", + get(auth::profile::redirect_from_ip), + ) // notifications .route( "/notifications/my", diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs index 1d74965..5d0e977 100644 --- a/crates/app/src/routes/pages/mod.rs +++ b/crates/app/src/routes/pages/mod.rs @@ -28,6 +28,7 @@ pub fn routes() -> Router { "/mod_panel/file_report", get(mod_panel::file_report_request), ) + .route("/mod_panel/ip_bans", get(mod_panel::ip_bans_request)) // auth .route("/auth/register", get(auth::register_request)) .route("/auth/login", get(auth::login_request)) diff --git a/crates/app/src/routes/pages/mod_panel.rs b/crates/app/src/routes/pages/mod_panel.rs index b9ce543..7f37da4 100644 --- a/crates/app/src/routes/pages/mod_panel.rs +++ b/crates/app/src/routes/pages/mod_panel.rs @@ -113,3 +113,39 @@ pub async fn file_report_request( data.1.render("mod/file_report.html", &context).unwrap(), )) } + +/// `/mod_panel/ip_bans` +pub async fn ip_bans_request( + jar: CookieJar, + Extension(data): Extension, + Query(req): Query, +) -> impl IntoResponse { + let data = data.read().await; + let user = match get_user_from_token!(jar, data.0) { + Some(ua) => ua, + None => { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &None).await, + )); + } + }; + + if !user.permissions.check(FinePermission::MANAGE_BANS) { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &None).await, + )); + } + + let items = match data.0.get_ipbans(12, req.page).await { + Ok(p) => p, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }; + + let lang = get_lang!(jar, data.0); + let mut context = initial_context(&data.0.0, lang, &Some(user)).await; + context.insert("items", &items); + context.insert("page", &req.page); + + // return + Ok(Html(data.1.render("mod/ip_bans.html", &context).unwrap())) +} diff --git a/crates/core/src/database/ipbans.rs b/crates/core/src/database/ipbans.rs index 88f7425..35c4249 100644 --- a/crates/core/src/database/ipbans.rs +++ b/crates/core/src/database/ipbans.rs @@ -2,7 +2,7 @@ use super::*; use crate::cache::Cache; use crate::model::moderation::AuditLogEntry; use crate::model::{Error, Result, auth::IpBan, auth::User, permissions::FinePermission}; -use crate::{auto_method, execute, get, query_row}; +use crate::{auto_method, execute, get, query_row, query_rows}; #[cfg(feature = "sqlite")] use rusqlite::Row; @@ -26,7 +26,32 @@ impl DataManager { auto_method!(get_ipban_by_ip(&str)@get_ipban_from_row -> "SELECT * FROM ipbans WHERE ip = $1" --name="ip ban" --returns=IpBan --cache-key-tmpl="atto.ipban:{}"); - /// Create a new user block in the database. + /// Get all IP bans (paginated). + /// + /// # Arguments + /// * `batch` - the limit of items in each page + /// * `page` - the page number + pub async fn get_ipbans(&self, batch: usize, page: usize) -> Result> { + let conn = match self.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = query_rows!( + &conn, + "SELECT * FROM ipbans ORDER BY created DESC LIMIT $1 OFFSET $2", + &[&(batch as isize), &((page * batch) as isize)], + |x| { Self::get_ipban_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("ip ban".to_string())); + } + + Ok(res.unwrap()) + } + + /// Create a new IP ban in the database. /// /// # Arguments /// * `data` - a mock [`IpBan`] object to insert diff --git a/crates/core/src/database/posts.rs b/crates/core/src/database/posts.rs index aea2db2..d86ad47 100644 --- a/crates/core/src/database/posts.rs +++ b/crates/core/src/database/posts.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use super::*; use crate::cache::Cache; use crate::model::auth::Notification; @@ -314,17 +316,27 @@ impl DataManager { } // send mention notifications + let mut already_notified: HashMap = HashMap::new(); for username in User::parse_mentions(&data.content) { - let user = self.get_user_by_username(&username).await?; - self.create_notification(Notification::new( - "You've been mentioned in a post!".to_string(), - format!( - "[Somebody](/api/v1/auth/profile/find/{}) mentioned you in their [post](/post/{}).", - data.owner, data.id - ), - user.id, - )) - .await?; + let user = { + if let Some(ua) = already_notified.get(&username) { + ua.to_owned() + } else { + let user = self.get_user_by_username(&username).await?; + self.create_notification(Notification::new( + "You've been mentioned in a post!".to_string(), + format!( + "[Somebody](/api/v1/auth/profile/find/{}) mentioned you in their [post](/post/{}).", + data.owner, data.id + ), + user.id, + )) + .await?; + already_notified.insert(username.to_owned(), user.clone()); + user + } + }; + data.content = data.content.replace( &format!("@{username}"), &format!("[@{username}](/api/v1/auth/profile/find/{})", user.id), diff --git a/crates/core/src/model/permissions.rs b/crates/core/src/model/permissions.rs index 4a1425c..cfa315f 100644 --- a/crates/core/src/model/permissions.rs +++ b/crates/core/src/model/permissions.rs @@ -14,7 +14,7 @@ bitflags! { const MANAGE_POSTS = 1 << 3; const MANAGE_POST_REPLIES = 1 << 4; const MANAGE_USERS = 1 << 5; - const MANAGE_BANS = 1 << 6; // includes managing IP bans + const MANAGE_BANS = 1 << 6; const MANAGE_WARNINGS = 1 << 7; const MANAGE_NOTIFICATIONS = 1 << 8; const VIEW_REPORTS = 1 << 9;