diff --git a/fluxer_admin/build.rs b/fluxer_admin/build.rs index c2b2ebca9..2a72153b3 100644 --- a/fluxer_admin/build.rs +++ b/fluxer_admin/build.rs @@ -41,6 +41,11 @@ fn generate_admin_api(manifest_dir: &Path, out_dir: &Path) { let mut settings = progenitor::GenerationSettings::new(); settings.with_interface(progenitor::InterfaceStyle::Positional); + settings.with_inner_type( + "reqwest::header::HeaderMap" + .parse() + .expect("valid generated client header type"), + ); let mut generator = progenitor::Generator::new(&settings); let tokens = generator diff --git a/fluxer_admin/src/api/audit.rs b/fluxer_admin/src/api/audit.rs index 47013ac0b..b10983402 100644 --- a/fluxer_admin/src/api/audit.rs +++ b/fluxer_admin/src/api/audit.rs @@ -13,7 +13,7 @@ pub struct SearchAuditLogsParams { pub sort_by: Option, pub sort_order: Option, pub limit: u32, - pub offset: u32, + pub offset: u64, } impl AdminApiClient { diff --git a/fluxer_admin/src/api/bans.rs b/fluxer_admin/src/api/bans.rs index 80c411eeb..cdd43fa20 100644 --- a/fluxer_admin/src/api/bans.rs +++ b/fluxer_admin/src/api/bans.rs @@ -6,7 +6,7 @@ use super::client::{AdminApiClient, ApiError, ApiResult}; use super::types::{BanAvatarResult, BanCheckResult, BulkBanResult}; impl AdminApiClient { - pub async fn ban_email(&self, email: &str) -> ApiResult<()> { + pub async fn ban_email(&self, email: &str, audit_log_reason: Option<&str>) -> ApiResult<()> { self.create_blocklist_entry( "email", generated_types::AdminBlocklistEntryCreateRequest { @@ -15,50 +15,63 @@ impl AdminApiClient { }), ..Default::default() }, + audit_log_reason, ) .await } - pub async fn unban_email(&self, email: &str) -> ApiResult<()> { - self.delete_blocklist_entry("email", email, None).await + pub async fn unban_email(&self, email: &str, audit_log_reason: Option<&str>) -> ApiResult<()> { + self.delete_blocklist_entry("email", email, None, audit_log_reason) + .await } pub async fn check_email_ban(&self, email: &str) -> ApiResult { self.check_blocklist_entry("email", email, None).await } - pub async fn ban_ip(&self, ip: &str) -> ApiResult<()> { + pub async fn ban_ip(&self, ip: &str, audit_log_reason: Option<&str>) -> ApiResult<()> { self.create_blocklist_entry( "ip", generated_types::AdminBlocklistEntryCreateRequest { subtype_0: Some(generated_types::BanIpRequest { ip: ip.to_owned() }), ..Default::default() }, + audit_log_reason, ) .await } - pub async fn unban_ip(&self, ip: &str) -> ApiResult<()> { - self.delete_blocklist_entry("ip", ip, None).await + pub async fn unban_ip(&self, ip: &str, audit_log_reason: Option<&str>) -> ApiResult<()> { + self.delete_blocklist_entry("ip", ip, None, audit_log_reason) + .await } pub async fn check_ip_ban(&self, ip: &str) -> ApiResult { self.check_blocklist_entry("ip", ip, None).await } - pub async fn add_suspicious_email_domain(&self, domain: &str) -> ApiResult<()> { + pub async fn add_suspicious_email_domain( + &self, + domain: &str, + audit_log_reason: Option<&str>, + ) -> ApiResult<()> { self.create_blocklist_entry( SUSPICIOUS_EMAIL_DOMAIN_LIST, generated_types::AdminBlocklistEntryCreateRequest { subtype_2: Some(suspicious_email_domain_request(domain)?), ..Default::default() }, + audit_log_reason, ) .await } - pub async fn remove_suspicious_email_domain(&self, domain: &str) -> ApiResult<()> { - self.delete_blocklist_entry(SUSPICIOUS_EMAIL_DOMAIN_LIST, domain, None) + pub async fn remove_suspicious_email_domain( + &self, + domain: &str, + audit_log_reason: Option<&str>, + ) -> ApiResult<()> { + self.delete_blocklist_entry(SUSPICIOUS_EMAIL_DOMAIN_LIST, domain, None, audit_log_reason) .await } @@ -67,7 +80,7 @@ impl AdminApiClient { .await } - pub async fn ban_phrase(&self, phrase: &str) -> ApiResult<()> { + pub async fn ban_phrase(&self, phrase: &str, audit_log_reason: Option<&str>) -> ApiResult<()> { self.create_blocklist_entry( "phrase", generated_types::AdminBlocklistEntryCreateRequest { @@ -76,19 +89,25 @@ impl AdminApiClient { }), ..Default::default() }, + audit_log_reason, ) .await } - pub async fn unban_phrase(&self, phrase: &str) -> ApiResult<()> { - self.delete_blocklist_entry("phrase", phrase, None).await + pub async fn unban_phrase( + &self, + phrase: &str, + audit_log_reason: Option<&str>, + ) -> ApiResult<()> { + self.delete_blocklist_entry("phrase", phrase, None, audit_log_reason) + .await } pub async fn check_phrase_ban(&self, phrase: &str) -> ApiResult { self.check_blocklist_entry("phrase", phrase, None).await } - pub async fn ban_url(&self, url: &str) -> ApiResult<()> { + pub async fn ban_url(&self, url: &str, audit_log_reason: Option<&str>) -> ApiResult<()> { self.create_blocklist_entry( "url", generated_types::AdminBlocklistEntryCreateRequest { @@ -101,19 +120,26 @@ impl AdminApiClient { }), ..Default::default() }, + audit_log_reason, ) .await } - pub async fn unban_url(&self, url: &str) -> ApiResult<()> { - self.delete_blocklist_entry("url", url, None).await + pub async fn unban_url(&self, url: &str, audit_log_reason: Option<&str>) -> ApiResult<()> { + self.delete_blocklist_entry("url", url, None, audit_log_reason) + .await } pub async fn check_url_ban(&self, url: &str) -> ApiResult { self.check_blocklist_entry("url", url, None).await } - pub async fn ban_url_domain(&self, domain: &str, match_subdomains: bool) -> ApiResult<()> { + pub async fn ban_url_domain( + &self, + domain: &str, + match_subdomains: bool, + audit_log_reason: Option<&str>, + ) -> ApiResult<()> { self.create_blocklist_entry( "url-domain", generated_types::AdminBlocklistEntryCreateRequest { @@ -127,12 +153,17 @@ impl AdminApiClient { }), ..Default::default() }, + audit_log_reason, ) .await } - pub async fn unban_url_domain(&self, domain: &str) -> ApiResult<()> { - self.delete_blocklist_entry("url-domain", domain, None) + pub async fn unban_url_domain( + &self, + domain: &str, + audit_log_reason: Option<&str>, + ) -> ApiResult<()> { + self.delete_blocklist_entry("url-domain", domain, None, audit_log_reason) .await } @@ -145,20 +176,19 @@ impl AdminApiClient { sha256_hex: &str, audit_log_reason: Option<&str>, ) -> ApiResult<()> { - let body = generated_types::AdminBlocklistEntryCreateRequest { - subtype_6: Some(generated_types::BanFileShaRequest { - category: None, - content_type: None, - notes: None, - severity: None, - sha256_hex: sha256_hex.to_owned(), - source_url: None, - }), - ..Default::default() - }; - self.post_void_with_reason( - "/admin/blocklists/file-sha/entries", - Some(&serde_json::to_value(&body).map_err(|e| ApiError::Parse(e.to_string()))?), + self.create_blocklist_entry( + "file-sha", + generated_types::AdminBlocklistEntryCreateRequest { + subtype_6: Some(generated_types::BanFileShaRequest { + category: None, + content_type: None, + notes: None, + severity: None, + sha256_hex: sha256_hex.to_owned(), + source_url: None, + }), + ..Default::default() + }, audit_log_reason, ) .await @@ -169,12 +199,8 @@ impl AdminApiClient { sha256_hex: &str, audit_log_reason: Option<&str>, ) -> ApiResult<()> { - self.delete_void_with_reason( - &blocklist_entry_path("file-sha", sha256_hex), - None, - audit_log_reason, - ) - .await + self.delete_blocklist_entry("file-sha", sha256_hex, None, audit_log_reason) + .await } pub async fn check_file_sha_ban(&self, sha256_hex: &str) -> ApiResult { @@ -198,7 +224,11 @@ impl AdminApiClient { .await } - pub async fn ban_avatar_hash(&self, hash_short: &str) -> ApiResult<()> { + pub async fn ban_avatar_hash( + &self, + hash_short: &str, + audit_log_reason: Option<&str>, + ) -> ApiResult<()> { self.create_blocklist_entry( "avatar-hash", generated_types::AdminBlocklistEntryCreateRequest { @@ -212,12 +242,17 @@ impl AdminApiClient { }), ..Default::default() }, + audit_log_reason, ) .await } - pub async fn unban_avatar_hash(&self, hash_short: &str) -> ApiResult<()> { - self.delete_blocklist_entry("avatar-hash", hash_short, None) + pub async fn unban_avatar_hash( + &self, + hash_short: &str, + audit_log_reason: Option<&str>, + ) -> ApiResult<()> { + self.delete_blocklist_entry("avatar-hash", hash_short, None, audit_log_reason) .await } @@ -236,20 +271,36 @@ impl AdminApiClient { self.generated_value(response.into_inner()) } - pub async fn ban_profile_substring(&self, scope: &str, substring: &str) -> ApiResult<()> { + pub async fn ban_profile_substring( + &self, + scope: &str, + substring: &str, + audit_log_reason: Option<&str>, + ) -> ApiResult<()> { self.create_blocklist_entry( PROFILE_SUBSTRING_LIST, generated_types::AdminBlocklistEntryCreateRequest { subtype_8: Some(profile_substring_request(scope, substring)?), ..Default::default() }, + audit_log_reason, ) .await } - pub async fn unban_profile_substring(&self, scope: &str, substring: &str) -> ApiResult<()> { - self.delete_blocklist_entry(PROFILE_SUBSTRING_LIST, substring, Some(scope)) - .await + pub async fn unban_profile_substring( + &self, + scope: &str, + substring: &str, + audit_log_reason: Option<&str>, + ) -> ApiResult<()> { + self.delete_blocklist_entry( + PROFILE_SUBSTRING_LIST, + substring, + Some(scope), + audit_log_reason, + ) + .await } pub async fn check_profile_substring_ban( @@ -265,13 +316,14 @@ impl AdminApiClient { &self, list_type: &str, body: generated_types::AdminBlocklistEntryCreateRequest, + audit_log_reason: Option<&str>, ) -> ApiResult<()> { let list_type = blocklist_list_type(list_type)?; - self.generated() + self.generated_with_reason(audit_log_reason)? .create_admin_blocklist_entry(list_type, &body) .await - .map_err(|e| self.generated_error(e))?; - Ok(()) + .map(drop) + .map_err(|error| self.generated_error(error)) } async fn delete_blocklist_entry( @@ -279,14 +331,15 @@ impl AdminApiClient { list_type: &str, entry_value: &str, scope: Option<&str>, + audit_log_reason: Option<&str>, ) -> ApiResult<()> { let list_type = blocklist_list_type(list_type)?; let scope = scope.map(blocklist_delete_scope).transpose()?; - self.generated() + self.generated_with_reason(audit_log_reason)? .delete_admin_blocklist_entry(list_type, entry_value, scope) .await - .map_err(|e| self.generated_error(e))?; - Ok(()) + .map(drop) + .map_err(|error| self.generated_error(error)) } async fn check_blocklist_entry( @@ -315,14 +368,6 @@ fn blocklist_list_type(list_type: &str) -> ApiResult String { - format!( - "/admin/blocklists/{}/entries/{}", - urlencoding::encode(list_type), - urlencoding::encode(entry_value) - ) -} - fn blocklist_get_scope(scope: &str) -> ApiResult { generated_types::GetAdminBlocklistEntryScope::try_from(scope) .map_err(|e| ApiError::Parse(e.to_string())) diff --git a/fluxer_admin/src/api/client.rs b/fluxer_admin/src/api/client.rs index 8b66b3ab6..c4357b36b 100644 --- a/fluxer_admin/src/api/client.rs +++ b/fluxer_admin/src/api/client.rs @@ -1,6 +1,8 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +use crate::api::generated::GeneratedClient; use crate::{config::AdminConfig, session::Session}; +use progenitor_client::ClientInfo; use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderName, HeaderValue}; use reqwest::{Method, RequestBuilder}; use serde::Serialize; @@ -34,46 +36,39 @@ impl ApiResultExt for ApiResult { } pub struct AdminApiClient { - http_client: reqwest::Client, - generated: crate::api::generated::GeneratedClient, - base_url: String, - access_token: String, - proxy_client_ip_headers: HeaderMap, + generated: GeneratedClient, } impl AdminApiClient { pub fn new(http_client: &reqwest::Client, config: &AdminConfig, session: &Session) -> Self { - let generated_http_client = build_generated_http_client(config, session); - let generated = crate::api::generated::GeneratedClient::new_with_client( + let generated = GeneratedClient::new_with_client( &config.api_endpoint, - generated_http_client, + http_client.clone(), + build_session_headers(config, session), ); - Self { - http_client: http_client.clone(), - generated, - base_url: config.api_endpoint.clone(), - access_token: session.access_token.clone(), - proxy_client_ip_headers: build_proxy_client_ip_headers(config), - } + Self { generated } } fn build_url(&self, path: &str, query_params: Option<&[(&str, &str)]>) -> String { - let base = format!("{}{}", self.base_url, path); - match query_params { - None => base, - Some(params) => { - let filtered: Vec<_> = params.iter().filter(|(_, v)| !v.is_empty()).collect(); - if filtered.is_empty() { - return base; - } - let query = filtered - .iter() - .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v))) - .collect::>() - .join("&"); - format!("{base}?{query}") - } + let mut url = format!("{}{}", self.generated.baseurl(), path); + let query = query_params + .unwrap_or_default() + .iter() + .filter(|(_, value)| !value.is_empty()) + .map(|(key, value)| { + format!( + "{}={}", + urlencoding::encode(key), + urlencoding::encode(value) + ) + }) + .collect::>() + .join("&"); + if !query.is_empty() { + url.push('?'); + url.push_str(&query); } + url } fn request( @@ -81,30 +76,26 @@ impl AdminApiClient { method: Method, path: &str, query_params: Option<&[(&str, &str)]>, - ) -> RequestBuilder { - let url = self.build_url(path, query_params); - self.http_client - .request(method, &url) - .header("Authorization", format!("Bearer {}", self.access_token)) - .header("Content-Type", "application/json") - .headers(self.proxy_client_ip_headers.clone()) - } - - fn with_audit_log_reason( - builder: RequestBuilder, audit_log_reason: Option<&str>, - ) -> RequestBuilder { - match audit_log_reason { - Some(reason) => builder.header("X-Audit-Log-Reason", reason), - None => builder, - } + ) -> ApiResult { + let url = self.build_url(path, query_params); + Ok(self + .generated + .client() + .request(method, &url) + .header("Content-Type", "application/json") + .headers(self.headers_with_reason(audit_log_reason)?)) } - fn with_json_body(builder: RequestBuilder, body: Option<&serde_json::Value>) -> RequestBuilder { - match body { - Some(body) => builder.json(body), - None => builder, + fn headers_with_reason(&self, audit_log_reason: Option<&str>) -> ApiResult { + let mut headers = self.generated.inner().clone(); + if let Some(reason) = audit_log_reason { + let mut value = HeaderValue::from_str(reason) + .map_err(|_| ApiError::Parse("invalid audit log reason header".to_owned()))?; + value.set_sensitive(true); + headers.insert("x-audit-log-reason", value); } + Ok(headers) } async fn send_request(builder: RequestBuilder) -> ApiResult { @@ -114,12 +105,28 @@ impl AdminApiClient { .map_err(|e| ApiError::Network(e.to_string())) } + async fn send_json( + &self, + method: Method, + path: &str, + body: Option<&B>, + audit_log_reason: Option<&str>, + ) -> ApiResult { + let builder = self.request(method, path, None, audit_log_reason)?; + let builder = match body { + Some(body) => builder.json(body), + None => builder, + }; + Self::send_request(builder).await + } + pub async fn get( &self, path: &str, query_params: Option<&[(&str, &str)]>, ) -> ApiResult { - let response = Self::send_request(self.request(Method::GET, path, query_params)).await?; + let response = + Self::send_request(self.request(Method::GET, path, query_params, None)?).await?; Self::parse_response(response).await } @@ -149,9 +156,9 @@ impl AdminApiClient { T: DeserializeOwned, B: Serialize + ?Sized, { - let builder = - Self::with_audit_log_reason(self.request(Method::POST, path, None), audit_log_reason); - let response = Self::send_request(builder.json(body)).await?; + let response = self + .send_json(Method::POST, path, Some(body), audit_log_reason) + .await?; Self::parse_response(response).await } @@ -161,9 +168,9 @@ impl AdminApiClient { body: Option<&serde_json::Value>, audit_log_reason: Option<&str>, ) -> ApiResult { - let builder = - Self::with_audit_log_reason(self.request(Method::POST, path, None), audit_log_reason); - let response = Self::send_request(Self::with_json_body(builder, body)).await?; + let response = self + .send_json(Method::POST, path, body, audit_log_reason) + .await?; Self::parse_response(response).await } @@ -177,9 +184,9 @@ impl AdminApiClient { body: Option<&serde_json::Value>, audit_log_reason: Option<&str>, ) -> ApiResult<()> { - let builder = - Self::with_audit_log_reason(self.request(Method::POST, path, None), audit_log_reason); - let response = Self::send_request(Self::with_json_body(builder, body)).await?; + let response = self + .send_json(Method::POST, path, body, audit_log_reason) + .await?; Self::parse_void_response(response).await } @@ -197,9 +204,9 @@ impl AdminApiClient { body: Option<&serde_json::Value>, audit_log_reason: Option<&str>, ) -> ApiResult { - let builder = - Self::with_audit_log_reason(self.request(Method::PATCH, path, None), audit_log_reason); - let response = Self::send_request(Self::with_json_body(builder, body)).await?; + let response = self + .send_json(Method::PATCH, path, body, audit_log_reason) + .await?; Self::parse_response(response).await } @@ -213,9 +220,9 @@ impl AdminApiClient { T: DeserializeOwned, B: Serialize + ?Sized, { - let builder = - Self::with_audit_log_reason(self.request(Method::PATCH, path, None), audit_log_reason); - let response = Self::send_request(builder.json(body)).await?; + let response = self + .send_json(Method::PATCH, path, Some(body), audit_log_reason) + .await?; Self::parse_response(response).await } @@ -225,9 +232,9 @@ impl AdminApiClient { body: Option<&serde_json::Value>, audit_log_reason: Option<&str>, ) -> ApiResult { - let builder = - Self::with_audit_log_reason(self.request(Method::PUT, path, None), audit_log_reason); - let response = Self::send_request(Self::with_json_body(builder, body)).await?; + let response = self + .send_json(Method::PUT, path, body, audit_log_reason) + .await?; Self::parse_response(response).await } @@ -241,9 +248,9 @@ impl AdminApiClient { T: DeserializeOwned, B: Serialize + ?Sized, { - let builder = - Self::with_audit_log_reason(self.request(Method::PUT, path, None), audit_log_reason); - let response = Self::send_request(builder.json(body)).await?; + let response = self + .send_json(Method::PUT, path, Some(body), audit_log_reason) + .await?; Self::parse_response(response).await } @@ -253,9 +260,9 @@ impl AdminApiClient { body: Option<&serde_json::Value>, audit_log_reason: Option<&str>, ) -> ApiResult<()> { - let builder = - Self::with_audit_log_reason(self.request(Method::PUT, path, None), audit_log_reason); - let response = Self::send_request(Self::with_json_body(builder, body)).await?; + let response = self + .send_json(Method::PUT, path, body, audit_log_reason) + .await?; Self::parse_void_response(response).await } @@ -269,9 +276,9 @@ impl AdminApiClient { body: Option<&serde_json::Value>, audit_log_reason: Option<&str>, ) -> ApiResult<()> { - let builder = - Self::with_audit_log_reason(self.request(Method::DELETE, path, None), audit_log_reason); - let response = Self::send_request(Self::with_json_body(builder, body)).await?; + let response = self + .send_json(Method::DELETE, path, body, audit_log_reason) + .await?; Self::parse_void_response(response).await } @@ -281,9 +288,9 @@ impl AdminApiClient { body: Option<&serde_json::Value>, audit_log_reason: Option<&str>, ) -> ApiResult { - let builder = - Self::with_audit_log_reason(self.request(Method::DELETE, path, None), audit_log_reason); - let response = Self::send_request(Self::with_json_body(builder, body)).await?; + let response = self + .send_json(Method::DELETE, path, body, audit_log_reason) + .await?; Self::parse_response(response).await } @@ -302,10 +309,21 @@ impl AdminApiClient { Err(ApiError::Http { status, message }) } - pub(crate) fn generated(&self) -> &crate::api::generated::GeneratedClient { + pub(crate) fn generated(&self) -> &GeneratedClient { &self.generated } + pub(crate) fn generated_with_reason( + &self, + audit_log_reason: Option<&str>, + ) -> ApiResult { + Ok(GeneratedClient::new_with_client( + self.generated.baseurl(), + self.generated.client().clone(), + self.headers_with_reason(audit_log_reason)?, + )) + } + pub(crate) fn generated_value(&self, value: U) -> ApiResult where T: DeserializeOwned, @@ -346,17 +364,14 @@ impl AdminApiClient { } } -fn build_generated_http_client(config: &AdminConfig, session: &Session) -> reqwest::Client { +fn build_session_headers(config: &AdminConfig, session: &Session) -> HeaderMap { let mut headers = HeaderMap::new(); - let auth_value = HeaderValue::from_str(&format!("Bearer {}", session.access_token)) + let mut auth_value = HeaderValue::from_str(&format!("Bearer {}", session.access_token)) .expect("failed to build generated API Authorization header"); + auth_value.set_sensitive(true); headers.insert(AUTHORIZATION, auth_value); headers.extend(build_proxy_client_ip_headers(config)); - reqwest::Client::builder() - .user_agent(format!("FluxerAdmin/{} (Rust)", config.build_version)) - .default_headers(headers) - .build() - .expect("failed to create generated API HTTP client") + headers } pub(crate) fn with_proxy_client_ip_header( diff --git a/fluxer_admin/src/api/codes.rs b/fluxer_admin/src/api/codes.rs index 607560e91..80b211d4c 100644 --- a/fluxer_admin/src/api/codes.rs +++ b/fluxer_admin/src/api/codes.rs @@ -9,7 +9,7 @@ impl AdminApiClient { pub async fn generate_gift_codes( &self, count: u32, - duration_type: &str, + duration_type: generated_types::GiftCodeDurationTypeSchema, duration_quantity: u32, ) -> ApiResult { let body = generated_types::GenerateGiftCodesRequest { @@ -22,8 +22,7 @@ impl AdminApiClient { ) .map_err(ApiError::Parse)? .into(), - duration_type: generated_types::GiftCodeDurationTypeSchema::try_from(duration_type) - .map_err(|e| ApiError::Parse(e.to_string()))?, + duration_type, }; let response = self .generated() diff --git a/fluxer_admin/src/api/generated.rs b/fluxer_admin/src/api/generated.rs index 58817c5c8..46e97962a 100644 --- a/fluxer_admin/src/api/generated.rs +++ b/fluxer_admin/src/api/generated.rs @@ -1,5 +1,8 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +use progenitor_client::{ClientHooks, ClientInfo, Error, OperationInfo}; +use reqwest::header::HeaderMap; + #[allow( clippy::all, unused_imports, @@ -16,6 +19,20 @@ pub use inner::types; pub use inner::Client as GeneratedClient; +impl ClientHooks for GeneratedClient { + async fn pre( + &self, + request: &mut reqwest::Request, + _info: &OperationInfo, + ) -> Result<(), Error> { + let headers = request.headers_mut(); + for (name, value) in self.inner() { + headers.entry(name.clone()).or_insert_with(|| value.clone()); + } + Ok(()) + } +} + pub(crate) fn snowflake(value: &str) -> types::SnowflakeType { types::SnowflakeType::Variant0(value.to_owned()) } diff --git a/fluxer_admin/src/api/guilds.rs b/fluxer_admin/src/api/guilds.rs index e3d659dd5..f1731b7c3 100644 --- a/fluxer_admin/src/api/guilds.rs +++ b/fluxer_admin/src/api/guilds.rs @@ -16,7 +16,7 @@ impl AdminApiClient { &self, query: &str, limit: u32, - offset: u32, + offset: u64, ) -> ApiResult { let limit = limit.to_string(); let offset = offset.to_string(); @@ -97,7 +97,7 @@ impl AdminApiClient { &self, guild_id: &str, limit: u32, - offset: u32, + offset: u64, ) -> ApiResult { let limit = limit.to_string(); let offset = offset.to_string(); @@ -252,7 +252,7 @@ impl AdminApiClient { &self, guild_id: &str, limit: u32, - offset: u32, + offset: u64, ) -> ApiResult { self.search_reports(&SearchReportsParams { reported_guild_id: Some(guild_id), diff --git a/fluxer_admin/src/api/jobs.rs b/fluxer_admin/src/api/jobs.rs index 47014790b..04a801239 100644 --- a/fluxer_admin/src/api/jobs.rs +++ b/fluxer_admin/src/api/jobs.rs @@ -24,9 +24,9 @@ impl AdminApiClient { let max_lookback_days = params.max_lookback_days.to_string(); let query_params = [ ("limit", limit.as_str()), - ("cursor_bucket_day", cursor_bucket_day.as_str()), - ("cursor_created_at", cursor_created_at.as_str()), - ("cursor_job_id", cursor_job_id.as_str()), + ("cursor_bucket_day", cursor_bucket_day), + ("cursor_created_at", cursor_created_at), + ("cursor_job_id", cursor_job_id), ("max_lookback_days", max_lookback_days.as_str()), ("status", params.status.as_deref().unwrap_or_default()), ("task_type", params.task_type.as_deref().unwrap_or_default()), @@ -70,10 +70,9 @@ impl AdminApiClient { } } -fn cursor_field(cursor: Option<&serde_json::Value>, field: &str) -> String { +fn cursor_field<'a>(cursor: Option<&'a serde_json::Value>, field: &str) -> &'a str { cursor .and_then(|cursor| cursor.get(field)) .and_then(serde_json::Value::as_str) .unwrap_or_default() - .to_owned() } diff --git a/fluxer_admin/src/api/reports.rs b/fluxer_admin/src/api/reports.rs index c98afe57d..83940c5b5 100644 --- a/fluxer_admin/src/api/reports.rs +++ b/fluxer_admin/src/api/reports.rs @@ -22,7 +22,7 @@ pub struct SearchReportsParams<'a> { pub sort_by: Option<&'a str>, pub sort_order: Option<&'a str>, pub limit: u32, - pub offset: u32, + pub offset: u64, } impl AdminApiClient { @@ -129,7 +129,7 @@ impl AdminApiClient { &self, reporter_id: &str, limit: u32, - offset: u32, + offset: u64, ) -> ApiResult { self.search_reports(&SearchReportsParams { reporter_id: Some(reporter_id), @@ -144,7 +144,7 @@ impl AdminApiClient { &self, reported_user_id: &str, limit: u32, - offset: u32, + offset: u64, ) -> ApiResult { self.search_reports(&SearchReportsParams { reported_user_id: Some(reported_user_id), diff --git a/fluxer_admin/src/api/types/common.rs b/fluxer_admin/src/api/types/common.rs index c25dd4421..e54d404ad 100644 --- a/fluxer_admin/src/api/types/common.rs +++ b/fluxer_admin/src/api/types/common.rs @@ -6,8 +6,14 @@ pub fn deserialize_discriminator<'de, D: Deserializer<'de>>(d: D) -> Result Ok(format!("{:0>4}", s)), - serde_json::Value::Number(n) => Ok(format!("{:04}", n.as_u64().unwrap_or(0))), - _ => Ok("0000".to_owned()), + serde_json::Value::Number(n) => n + .as_u64() + .map(|value| format!("{value:04}")) + .ok_or_else(|| serde::de::Error::custom("expected an unsigned integer discriminator")), + serde_json::Value::Null => Ok("0000".to_owned()), + _ => Err(serde::de::Error::custom( + "expected string or unsigned integer discriminator", + )), } } @@ -15,7 +21,9 @@ pub fn deserialize_string_or_u64<'de, D: Deserializer<'de>>(d: D) -> Result s.parse::().map_err(serde::de::Error::custom), - serde_json::Value::Number(n) => Ok(n.as_u64().unwrap_or(0)), + serde_json::Value::Number(n) => n + .as_u64() + .ok_or_else(|| serde::de::Error::custom("expected an unsigned 64-bit integer")), serde_json::Value::Null => Ok(0), _ => Err(serde::de::Error::custom("expected string or number")), } diff --git a/fluxer_admin/src/api/users.rs b/fluxer_admin/src/api/users.rs index bf2e0f882..6e67665b6 100644 --- a/fluxer_admin/src/api/users.rs +++ b/fluxer_admin/src/api/users.rs @@ -15,7 +15,7 @@ impl AdminApiClient { email: Option<&str>, last_active_ip: Option<&str>, limit: u32, - offset: u32, + offset: u64, ) -> ApiResult { let limit = limit.to_string(); let offset = offset.to_string(); @@ -35,7 +35,8 @@ impl AdminApiClient { let response = response.into_inner(); Ok(SearchUsersResponse { users: self.generated_value(response.users)?, - total: response.total as u64, + total: crate::api::generated::number_to_u64(response.total, "total") + .map_err(ApiError::Parse)?, }) } @@ -426,6 +427,7 @@ impl AdminApiClient { reason_code: i32, public_reason: Option<&str>, days_until_deletion: u32, + audit_log_reason: Option<&str>, ) -> ApiResult { let body = generated_types::AdminUserDeletionScheduleRequest { days_until_deletion: crate::api::generated::nonzero_u32( @@ -439,10 +441,10 @@ impl AdminApiClient { .map_err(ApiError::Parse)?, }; let response = self - .generated() + .generated_with_reason(audit_log_reason)? .schedule_admin_user_deletion(&snowflake(user_id), &body) .await - .map_err(|e| self.generated_error(e))?; + .map_err(|error| self.generated_error(error))?; let resp: UserMutationResponse = self.generated_value(response.into_inner())?; Ok(resp.user) } diff --git a/fluxer_admin/src/routes/admin.rs b/fluxer_admin/src/routes/admin.rs index 34dce3162..a7dd4371a 100644 --- a/fluxer_admin/src/routes/admin.rs +++ b/fluxer_admin/src/routes/admin.rs @@ -81,7 +81,7 @@ async fn admin_api_keys_post( "create" => { let name = form.clean("name").unwrap_or_default(); let acls = form.list_values_any(&["acls[]", "acls"]); - return match client.create_api_key(&name, &acls).await { + match client.create_api_key(&name, &acls).await { Ok(created) => { let keys = client .list_api_keys() @@ -107,29 +107,38 @@ async fn admin_api_keys_post( is_htmx, ) } - }; - } - "revoke" => { - if let Some(key_id) = form.clean("key_id") { - let result = client.revoke_api_key(&key_id).await; - let flash = match result.log_error("revoke API key") { - Some(_) => FlashData::success("API key revoked."), - None => FlashData::error("Failed to revoke API key"), - }; - return flash::redirect_with_flash( - &format!("{base}/admin-api-keys"), - flash, - config.secure_cookies(), - ); } } - _ => {} + "revoke" => { + let Some(key_id) = form.clean("key_id") else { + return admin_api_key_flash_response( + config, + FlashData::error("API key ID is required"), + is_htmx, + ); + }; + let result = client.revoke_api_key(&key_id).await; + let flash = match result.log_error("revoke API key") { + Some(_) => FlashData::success("API key revoked."), + None => FlashData::error("Failed to revoke API key"), + }; + flash::redirect_with_flash( + &format!("{base}/admin-api-keys"), + flash, + config.secure_cookies(), + ) + } + "" => admin_api_key_flash_response( + config, + FlashData::error("API key action is required"), + is_htmx, + ), + _ => admin_api_key_flash_response( + config, + FlashData::error("Unknown API key action"), + is_htmx, + ), } - flash::redirect_with_flash( - &format!("{base}/admin-api-keys"), - FlashData::success(format!("API key action '{action}' completed.")), - config.secure_cookies(), - ) } fn admin_api_key_flash_response( diff --git a/fluxer_admin/src/routes/bans.rs b/fluxer_admin/src/routes/bans.rs index ec9a7c26c..f718d3f17 100644 --- a/fluxer_admin/src/routes/bans.rs +++ b/fluxer_admin/src/routes/bans.rs @@ -16,7 +16,7 @@ use axum::{ use super::ActionQuery; use super::bans_actions::{ - BanFormData, custom_flash, execute_ban, extract_value, flash_response, htmx_flash, + BanFormData, custom_flash, execute_ban, extract_value, flash_response, render_inline_flash, }; pub fn router() -> Router { @@ -105,7 +105,7 @@ async fn generic_ban_post( form.audit_log_reason.as_deref(), ) .await; - flash_response(config, auth, is_htmx, &level, &msg, ban_cfg, csrf_token) + flash_response(config, auth, is_htmx, level, &msg, ban_cfg, csrf_token) } macro_rules! ban_post { @@ -171,19 +171,22 @@ async fn url_domain_bans_post( Query::try_from_uri(request.uri()).unwrap_or(Query(ActionQuery { action: None })); let form: BanFormData = match Form::from_request(request, &state).await { Ok(Form(f)) => f, - Err(_) => return htmx_flash("error", "Invalid form data", &headers), + Err(_) => return render_inline_flash("error", "Invalid form data"), }; let is_htmx = htmx::is_htmx_request(&headers); let client = AdminApiClient::new(state.http_client(), config, &auth.0.session); let domain = form.domain.as_deref().unwrap_or("").trim().to_owned(); if domain.is_empty() { - return htmx_flash("error", "Domain is required", &headers); + return render_inline_flash("error", "Domain is required"); } let action = aq.action.as_deref().unwrap_or(""); let (level, msg) = match action { "ban" => { let m_sub = form.match_subdomains.as_deref() == Some("true"); - match client.ban_url_domain(&domain, m_sub).await { + match client + .ban_url_domain(&domain, m_sub, form.audit_log_reason.as_deref()) + .await + { Ok(()) => ("success", format!("Domain {domain} banned successfully")), Err(error) => { tracing::warn!(%error, domain, "admin API request failed: ban URL domain"); @@ -191,7 +194,10 @@ async fn url_domain_bans_post( } } } - "unban" => match client.unban_url_domain(&domain).await { + "unban" => match client + .unban_url_domain(&domain, form.audit_log_reason.as_deref()) + .await + { Ok(()) => ("success", format!("Domain {domain} unbanned")), Err(error) => { tracing::warn!(%error, domain, "admin API request failed: unban URL domain"); @@ -247,25 +253,31 @@ async fn profile_substring_bans_post( Query::try_from_uri(request.uri()).unwrap_or(Query(ActionQuery { action: None })); let form: BanFormData = match Form::from_request(request, &state).await { Ok(Form(f)) => f, - Err(_) => return htmx_flash("error", "Invalid form data", &headers), + Err(_) => return render_inline_flash("error", "Invalid form data"), }; let is_htmx = htmx::is_htmx_request(&headers); let client = AdminApiClient::new(state.http_client(), config, &auth.0.session); let scope = form.scope.as_deref().unwrap_or("").trim().to_owned(); let substring = form.substring.as_deref().unwrap_or("").trim().to_owned(); if scope.is_empty() || substring.is_empty() { - return htmx_flash("error", "Scope and substring required", &headers); + return render_inline_flash("error", "Scope and substring required"); } let action = aq.action.as_deref().unwrap_or(""); let (level, msg) = match action { - "ban" => match client.ban_profile_substring(&scope, &substring).await { + "ban" => match client + .ban_profile_substring(&scope, &substring, form.audit_log_reason.as_deref()) + .await + { Ok(()) => ("success", format!("\"{substring}\" banned for {scope}")), Err(error) => { tracing::warn!(%error, scope, substring, "admin API request failed: ban profile substring"); ("error", format!("Failed to ban substring for {scope}")) } }, - "unban" => match client.unban_profile_substring(&scope, &substring).await { + "unban" => match client + .unban_profile_substring(&scope, &substring, form.audit_log_reason.as_deref()) + .await + { Ok(()) => ("success", format!("\"{substring}\" unbanned for {scope}")), Err(error) => { tracing::warn!(%error, scope, substring, "admin API request failed: unban profile substring"); diff --git a/fluxer_admin/src/routes/bans_actions.rs b/fluxer_admin/src/routes/bans_actions.rs index e073e83b4..2524187e6 100644 --- a/fluxer_admin/src/routes/bans_actions.rs +++ b/fluxer_admin/src/routes/bans_actions.rs @@ -1,17 +1,13 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -use crate::api::client::AdminApiClient; +use crate::api::client::{AdminApiClient, ApiResult}; use crate::api::types::{FlashLevel, FlashMessage}; use crate::middleware::auth::AuthContext; use crate::templates; -use axum::{ - http::HeaderMap, - response::{Html, IntoResponse, Response}, -}; +use axum::response::{Html, IntoResponse, Response}; use serde::Deserialize; #[derive(Deserialize)] -#[allow(dead_code)] pub struct BanFormData { #[serde(default)] pub ip: Option, @@ -65,7 +61,7 @@ pub async fn execute_ban( bulk_hashes: Option<&str>, bulk_sha256_list: Option<&str>, audit_log_reason: Option<&str>, -) -> (String, String) { +) -> (&'static str, String) { if (action == "bulk-ban" || action == "bulk-ban-files") && ban_type == "file-sha-bans" { let raw_hashes = if action == "bulk-ban-files" { bulk_sha256_list @@ -75,13 +71,13 @@ pub async fn execute_ban( return execute_bulk_ban(client, raw_hashes, audit_log_reason).await; } if value.is_empty() { - return ("error".into(), "Value is required".into()); + return ("error", "Value is required".into()); } match action { "ban" => execute_single_ban(client, ban_type, value, audit_log_reason).await, "unban" => execute_single_unban(client, ban_type, value, audit_log_reason).await, "check" => execute_check(client, ban_type, value).await, - _ => ("error".into(), "Unknown action".into()), + _ => ("error", "Unknown action".into()), } } @@ -89,7 +85,7 @@ async fn execute_bulk_ban( client: &AdminApiClient, bulk_hashes: Option<&str>, audit_log_reason: Option<&str>, -) -> (String, String) { +) -> (&'static str, String) { let hashes: Vec = bulk_hashes .unwrap_or("") .split(|c: char| c.is_whitespace() || c == ',' || c == ';') @@ -98,18 +94,15 @@ async fn execute_bulk_ban( .collect(); if hashes.is_empty() { return ( - "error".into(), + "error", "No valid 64-character hex hashes found in input".into(), ); } match client.bulk_ban_file_shas(&hashes, audit_log_reason).await { - Ok(r) => ( - "success".into(), - format!("Bulk ban job created: {}", r.job_id), - ), + Ok(r) => ("success", format!("Bulk ban job created: {}", r.job_id)), Err(error) => { tracing::warn!(%error, "admin API request failed: bulk ban file SHAs"); - ("error".into(), "Failed to enqueue bulk ban job".into()) + ("error", "Failed to enqueue bulk ban job".into()) } } } @@ -119,29 +112,26 @@ async fn execute_single_ban( ban_type: &str, value: &str, audit_log_reason: Option<&str>, -) -> (String, String) { - let success = format!("{value} banned successfully"); - let failure = format!("Failed to ban {value}"); - match ban_type { - "ip-bans" => ban_action_result(client.ban_ip(value).await, success, failure), - "email-bans" => ban_action_result(client.ban_email(value).await, success, failure), - "suspicious-email-domains" => ban_action_result( - client.add_suspicious_email_domain(value).await, - success, - failure, - ), - "phrase-bans" => ban_action_result(client.ban_phrase(value).await, success, failure), - "url-bans" => ban_action_result(client.ban_url(value).await, success, failure), - "file-sha-bans" => ban_action_result( - client.ban_file_sha(value, audit_log_reason).await, - success, - failure, - ), - "avatar-hash-bans" => { - ban_action_result(client.ban_avatar_hash(value).await, success, failure) +) -> (&'static str, String) { + let result = match ban_type { + "ip-bans" => client.ban_ip(value, audit_log_reason).await, + "email-bans" => client.ban_email(value, audit_log_reason).await, + "suspicious-email-domains" => { + client + .add_suspicious_email_domain(value, audit_log_reason) + .await } - _ => ("error".into(), "Unknown ban type".into()), - } + "phrase-bans" => client.ban_phrase(value, audit_log_reason).await, + "url-bans" => client.ban_url(value, audit_log_reason).await, + "file-sha-bans" => client.ban_file_sha(value, audit_log_reason).await, + "avatar-hash-bans" => client.ban_avatar_hash(value, audit_log_reason).await, + _ => return ("error", "Unknown ban type".into()), + }; + ban_action_result( + result, + format!("{value} banned successfully"), + format!("Failed to ban {value}"), + ) } async fn execute_single_unban( @@ -149,32 +139,33 @@ async fn execute_single_unban( ban_type: &str, value: &str, audit_log_reason: Option<&str>, -) -> (String, String) { - let success = format!("{value} unbanned successfully"); - let failure = format!("Failed to unban {value}"); - match ban_type { - "ip-bans" => ban_action_result(client.unban_ip(value).await, success, failure), - "email-bans" => ban_action_result(client.unban_email(value).await, success, failure), - "suspicious-email-domains" => ban_action_result( - client.remove_suspicious_email_domain(value).await, - success, - failure, - ), - "phrase-bans" => ban_action_result(client.unban_phrase(value).await, success, failure), - "url-bans" => ban_action_result(client.unban_url(value).await, success, failure), - "file-sha-bans" => ban_action_result( - client.unban_file_sha(value, audit_log_reason).await, - success, - failure, - ), - "avatar-hash-bans" => { - ban_action_result(client.unban_avatar_hash(value).await, success, failure) +) -> (&'static str, String) { + let result = match ban_type { + "ip-bans" => client.unban_ip(value, audit_log_reason).await, + "email-bans" => client.unban_email(value, audit_log_reason).await, + "suspicious-email-domains" => { + client + .remove_suspicious_email_domain(value, audit_log_reason) + .await } - _ => ("error".into(), "Unknown ban type".into()), - } + "phrase-bans" => client.unban_phrase(value, audit_log_reason).await, + "url-bans" => client.unban_url(value, audit_log_reason).await, + "file-sha-bans" => client.unban_file_sha(value, audit_log_reason).await, + "avatar-hash-bans" => client.unban_avatar_hash(value, audit_log_reason).await, + _ => return ("error", "Unknown ban type".into()), + }; + ban_action_result( + result, + format!("{value} unbanned successfully"), + format!("Failed to unban {value}"), + ) } -async fn execute_check(client: &AdminApiClient, ban_type: &str, value: &str) -> (String, String) { +async fn execute_check( + client: &AdminApiClient, + ban_type: &str, + value: &str, +) -> (&'static str, String) { let result = match ban_type { "ip-bans" => client.check_ip_ban(value).await, "email-bans" => client.check_email_ban(value).await, @@ -183,28 +174,28 @@ async fn execute_check(client: &AdminApiClient, ban_type: &str, value: &str) -> "url-bans" => client.check_url_ban(value).await, "file-sha-bans" => client.check_file_sha_ban(value).await, "avatar-hash-bans" => client.check_avatar_hash_ban(value).await, - _ => return ("error".into(), "Unknown ban type".into()), + _ => return ("error", "Unknown ban type".into()), }; match result { - Ok(r) if r.banned => ("info".into(), format!("{value} is banned")), - Ok(_) => ("info".into(), format!("{value} is NOT banned")), + Ok(r) if r.banned => ("info", format!("{value} is banned")), + Ok(_) => ("info", format!("{value} is NOT banned")), Err(error) => { tracing::warn!(%error, ban_type, value, "admin API request failed: check ban status"); - ("error".into(), "Error checking ban status".into()) + ("error", "Error checking ban status".into()) } } } -fn ban_action_result( - result: Result, +fn ban_action_result( + result: ApiResult<()>, success_message: String, error_message: String, -) -> (String, String) { +) -> (&'static str, String) { match result { - Ok(_) => ("success".into(), success_message), + Ok(()) => ("success", success_message), Err(error) => { tracing::warn!(%error, "admin API request failed: ban action"); - ("error".into(), error_message) + ("error", error_message) } } } @@ -228,11 +219,6 @@ pub fn flash_response( } } -pub fn htmx_flash(level: &str, message: &str, headers: &HeaderMap) -> Response { - let _ = headers; - render_inline_flash(level, message) -} - pub fn render_inline_flash(level: &str, message: &str) -> Response { let (border, bg, text) = match level { "success" => ("border-green-300", "bg-green-50", "text-green-800"), diff --git a/fluxer_admin/src/routes/codes.rs b/fluxer_admin/src/routes/codes.rs index 7a7dc2de6..b3e90ac40 100644 --- a/fluxer_admin/src/routes/codes.rs +++ b/fluxer_admin/src/routes/codes.rs @@ -1,14 +1,14 @@ // SPDX-License-Identifier: AGPL-3.0-or-later use crate::{ - api::client::AdminApiClient, + api::{client::AdminApiClient, generated::types::GiftCodeDurationTypeSchema}, middleware::{ auth::AuthContext, csrf::CsrfToken, flash::{self, FlashData}, }, state::AppState, - templates, + templates::{self, pages::gift_codes::MAX_GIFT_CODES}, }; use axum::{ Form, Router, @@ -28,11 +28,11 @@ struct GiftCodesForm { #[serde(default)] _csrf: Option, #[serde(default)] - count: Option, + count: Option, #[serde(default)] - duration_type: Option, + duration_type: Option, #[serde(default)] - duration_quantity: Option, + duration_quantity: Option, } pub fn router() -> Router { @@ -86,21 +86,17 @@ async fn gift_codes_post( ); } }; - let count = form - .count - .as_deref() - .and_then(|s| s.parse::().ok()) - .unwrap_or(1) - .clamp(1, 100); - let dur_type = form.duration_type.as_deref().unwrap_or("month"); - let dur_qty = form - .duration_quantity - .as_deref() - .and_then(|s| s.parse::().ok()) - .unwrap_or(1); + let count = form.count.unwrap_or(1).clamp(1, MAX_GIFT_CODES); + let duration_type = form + .duration_type + .unwrap_or(GiftCodeDurationTypeSchema::Months); + let duration_quantity = form.duration_quantity.unwrap_or(1); let client = AdminApiClient::new(state.http_client(), config, &auth.0.session); let secure_cookies = config.secure_cookies(); - match client.generate_gift_codes(count, dur_type, dur_qty).await { + match client + .generate_gift_codes(count, duration_type, duration_quantity) + .await + { Ok(result) => { let codes = result.codes.join(","); flash::redirect_with_flash( diff --git a/fluxer_admin/src/routes/guild_tabs.rs b/fluxer_admin/src/routes/guild_tabs.rs index 86ecd5c77..619a4c4f0 100644 --- a/fluxer_admin/src/routes/guild_tabs.rs +++ b/fluxer_admin/src/routes/guild_tabs.rs @@ -43,7 +43,7 @@ pub async fn render( let page = query.members_page.unwrap_or(0); let limit: u32 = 50; let resp = client - .list_guild_members(guild_id, limit, page * limit) + .list_guild_members(guild_id, limit, u64::from(page) * u64::from(limit)) .await .log_error("load guild members")?; Some(tabs::members::members_tab( @@ -57,7 +57,7 @@ pub async fn render( let page = query.reports_page.unwrap_or(0); let limit: u32 = 25; let resp = client - .search_reports_by_guild(guild_id, limit, page * limit) + .search_reports_by_guild(guild_id, limit, u64::from(page) * u64::from(limit)) .await .log_error("load guild reports")?; Some(tabs::reports::reports_tab( diff --git a/fluxer_admin/src/routes/guilds.rs b/fluxer_admin/src/routes/guilds.rs index b1575ca41..a3f41c46c 100644 --- a/fluxer_admin/src/routes/guilds.rs +++ b/fluxer_admin/src/routes/guilds.rs @@ -82,23 +82,19 @@ async fn guilds_list( } } } - Some((guilds, Some(params.requested_ids.len() as u64), false)) + Some((guilds, params.requested_ids.len() as u64)) } else if params.has_search() { - let offset = params.page.saturating_mul(params.limit); + let offset = u64::from(params.page) * u64::from(params.limit); client .search_guilds(params.search_query(), params.limit, offset) .await .log_error("search guilds") - .map(|response| { - let has_more = u64::from(offset) + (response.guilds.len() as u64) < response.total; - (response.guilds, Some(response.total), has_more) - }) + .map(|response| (response.guilds, response.total)) } else { None }; let result_guilds = results.as_ref().map(|result| result.0.as_slice()); - let total = results.as_ref().and_then(|result| result.1); - let has_more = results.as_ref().is_some_and(|result| result.2); + let total = results.as_ref().map(|result| result.1); let markup = templates::pages::guilds_list::guilds_list_page( config, @@ -106,7 +102,6 @@ async fn guilds_list( ¶ms, result_guilds, total, - has_more, is_results_fragment, ); Html(markup.into_string()).into_response() @@ -324,8 +319,12 @@ async fn dispatch_guild_action( "default_message_notifications", "disabled_operations", ] { - if let Some(v) = form.parse_i64(key) { - settings.insert(key.to_string(), serde_json::json!(v)); + let value = match form.parse_value::(key) { + Ok(value) => value, + Err(_) => return FlashData::error(format!("Invalid {key} value")), + }; + if let Some(value) = value { + settings.insert(key.to_string(), serde_json::json!(value)); } } if form.contains_key("nsfw_submitted") { @@ -356,11 +355,12 @@ async fn dispatch_guild_action( ) } "update_disabled_operations" => { - let disabled_operations = form - .list_values_any(&["disabled_operations[]", "disabled_operations"]) - .iter() - .filter_map(|value| value.parse::().ok()) - .fold(0_i64, |acc, value| acc | value); + let Ok(operations) = + form.parse_list_values::(&["disabled_operations[]", "disabled_operations"]) + else { + return FlashData::error("Invalid disabled operation value"); + }; + let disabled_operations = operations.into_iter().fold(0_i64, |acc, value| acc | value); let settings = serde_json::json!({ "disabled_operations": disabled_operations, }); diff --git a/fluxer_admin/src/routes/jobs.rs b/fluxer_admin/src/routes/jobs.rs index d2f9a37fc..bb04af04c 100644 --- a/fluxer_admin/src/routes/jobs.rs +++ b/fluxer_admin/src/routes/jobs.rs @@ -21,7 +21,6 @@ use axum::{ use serde::Deserialize; #[derive(Deserialize)] -#[allow(dead_code)] struct JobsQuery { status: Option, task_type: Option, @@ -219,49 +218,36 @@ async fn job_detail_post( } fn jobs_url(config: &crate::config::AdminConfig, query: &JobsQuery) -> String { - let mut params = Vec::new(); - push_query(&mut params, "status", query.status.as_deref()); - push_query(&mut params, "task_type", query.task_type.as_deref()); - push_query( - &mut params, - "requested_by_user_id", - query.requested_by_user_id.as_deref(), - ); - push_query( - &mut params, - "max_lookback_days", - query.max_lookback_days.as_deref(), - ); - push_query( - &mut params, - "cursor_bucket_day", - query.cursor_bucket_day.as_deref(), - ); - push_query( - &mut params, - "cursor_created_at", - query.cursor_created_at.as_deref(), - ); - push_query(&mut params, "cursor_job_id", query.cursor_job_id.as_deref()); - if params.is_empty() { - return format!("{}/jobs", config.base_path); - } - let query = params - .iter() - .map(|(key, value)| { - format!( - "{}={}", - urlencoding::encode(key), - urlencoding::encode(value) - ) - }) - .collect::>() - .join("&"); - format!("{}/jobs?{query}", config.base_path) -} - -fn push_query(params: &mut Vec<(String, String)>, key: &str, value: Option<&str>) { - if let Some(value) = value.filter(|value| !value.is_empty()) { - params.push((key.to_owned(), value.to_owned())); + let query = [ + ("status", query.status.as_deref()), + ("task_type", query.task_type.as_deref()), + ( + "requested_by_user_id", + query.requested_by_user_id.as_deref(), + ), + ("max_lookback_days", query.max_lookback_days.as_deref()), + ("cursor_bucket_day", query.cursor_bucket_day.as_deref()), + ("cursor_created_at", query.cursor_created_at.as_deref()), + ("cursor_job_id", query.cursor_job_id.as_deref()), + ] + .into_iter() + .filter_map(|(key, value)| { + value + .filter(|value| !value.is_empty()) + .map(|value| (key, value)) + }) + .map(|(key, value)| { + format!( + "{}={}", + urlencoding::encode(key), + urlencoding::encode(value) + ) + }) + .collect::>() + .join("&"); + if query.is_empty() { + format!("{}/jobs", config.base_path) + } else { + format!("{}/jobs?{query}", config.base_path) } } diff --git a/fluxer_admin/src/routes/message_actions.rs b/fluxer_admin/src/routes/message_actions.rs index e28b9b916..5d73e462a 100644 --- a/fluxer_admin/src/routes/message_actions.rs +++ b/fluxer_admin/src/routes/message_actions.rs @@ -59,53 +59,51 @@ pub(crate) async fn messages_post( match action { "lookup" => { let context_limit = parse_context_limit(form.first("context_limit")); - return Redirect::to(&format!( + Redirect::to(&format!( "{base}/messages?channel_id={}&message_id={}&context_limit={context_limit}", encode_opt(&channel_id), encode_opt(&message_id) )) - .into_response(); + .into_response() } "lookup-by-attachment" => { let attachment_id = form.clean("attachment_id"); let filename = form.clean("filename"); let context_limit = parse_context_limit(form.first("context_limit")); - return Redirect::to(&format!( + Redirect::to(&format!( "{base}/messages?channel_id={}&attachment_id={}&filename={}&context_limit={context_limit}", encode_opt(&channel_id), encode_opt(&attachment_id), encode_opt(&filename) )) - .into_response(); - } - "browse" => { - return Redirect::to(&format!( - "{base}/messages?channel_id={}", - encode_opt(&channel_id) - )) - .into_response(); + .into_response() } + "browse" => Redirect::to(&format!( + "{base}/messages?channel_id={}", + encode_opt(&channel_id) + )) + .into_response(), "search" => { let search = form.clean("search"); - return Redirect::to(&format!( + Redirect::to(&format!( "{base}/messages?channel_id={}&search={}", encode_opt(&channel_id), encode_opt(&search) )) - .into_response(); + .into_response() } "delete" => { let (Some(cid), Some(mid)) = (&channel_id, &message_id) else { return json_error(StatusCode::BAD_REQUEST, "Missing channel_id or message_id"); }; let audit_log_reason = form.clean("audit_log_reason"); - return match client + match client .delete_message(cid, mid, audit_log_reason.as_deref()) .await { Ok(()) => Json(serde_json::json!({"success": true})).into_response(), Err(e) => json_error(StatusCode::BAD_REQUEST, &format!("{e}")), - }; + } } "report-to-ncmec" => { let attachment_id = form.clean("attachment_id"); @@ -131,7 +129,7 @@ pub(crate) async fn messages_post( "Missing required NCMEC report fields", ); } - return match client + match client .report_attachment_to_ncmec( cid, mid, @@ -144,11 +142,10 @@ pub(crate) async fn messages_post( { Ok(resp) => Json(resp.data).into_response(), Err(e) => json_error(StatusCode::BAD_REQUEST, &format!("{e}")), - }; + } } - _ => {} + _ => Redirect::to(&format!("{base}/messages")).into_response(), } - Redirect::to(&format!("{base}/messages")).into_response() } pub(crate) async fn system_dms_post( @@ -253,14 +250,22 @@ pub(crate) async fn bulk_actions_post( } "bulk-schedule-user-deletion" | "bulk_delete_users" => { let user_ids = form.list_values_any(&["user_ids[]", "user_ids"]); - let reason_code = form.parse_u32("reason_code").unwrap_or(2); - let days = form.parse_u32("days_until_deletion").unwrap_or(14); + let (Ok(reason_code), Ok(days)) = ( + form.parse_value::("reason_code"), + form.parse_value::("days_until_deletion"), + ) else { + return flash::redirect_with_flash( + &format!("{base}/bulk-actions"), + FlashData::error("Invalid deletion reason code or delay"), + config.secure_cookies(), + ); + }; let public_reason = form.clean("public_reason"); client .bulk_schedule_user_deletion( &user_ids, - reason_code, - days, + reason_code.unwrap_or(2), + days.unwrap_or(14), public_reason.as_deref(), audit_log_reason.as_deref(), ) @@ -357,7 +362,7 @@ pub(crate) async fn messages_browse_fragment( } } -fn parse_context_limit(value: Option<&str>) -> u32 { +pub(super) fn parse_context_limit(value: Option<&str>) -> u32 { value .and_then(|s| s.parse::().ok()) .filter(|n| *n > 0) diff --git a/fluxer_admin/src/routes/messages.rs b/fluxer_admin/src/routes/messages.rs index 9ef644209..a8207f0fd 100644 --- a/fluxer_admin/src/routes/messages.rs +++ b/fluxer_admin/src/routes/messages.rs @@ -16,7 +16,6 @@ use axum::{ use serde::Deserialize; #[derive(Deserialize)] -#[allow(dead_code)] struct MessagesQuery { channel_id: Option, message_id: Option, @@ -82,13 +81,7 @@ async fn messages_page( let before = query.before.as_deref().filter(|s| !s.is_empty()); let after = query.after.as_deref().filter(|s| !s.is_empty()); let search = query.search.as_deref().filter(|s| !s.is_empty()); - let context_limit = query - .context_limit - .as_deref() - .and_then(|s| s.parse::().ok()) - .filter(|n| *n > 0) - .unwrap_or(50) - .min(100); + let context_limit = super::message_actions::parse_context_limit(query.context_limit.as_deref()); let mut lookup_result = None; let mut browse_result = None; let mut search_result = None; diff --git a/fluxer_admin/src/routes/reports.rs b/fluxer_admin/src/routes/reports.rs index 6770cab2a..9ba227915 100644 --- a/fluxer_admin/src/routes/reports.rs +++ b/fluxer_admin/src/routes/reports.rs @@ -26,7 +26,7 @@ use axum::{ }; use serde::Deserialize; -const MAX_REPORT_OFFSET: u32 = 10_000; +const MAX_REPORT_OFFSET: u64 = 10_000; #[derive(Deserialize)] struct ReportsQuery { @@ -75,7 +75,7 @@ async fn reports_list( let config = state.config(); let page = query.page.unwrap_or(0); let limit = query.limit.unwrap_or(25).clamp(1, 200); - let offset = page.saturating_mul(limit); + let offset = u64::from(page) * u64::from(limit); if offset > MAX_REPORT_OFFSET { return reports_error_page( config, diff --git a/fluxer_admin/src/routes/system.rs b/fluxer_admin/src/routes/system.rs index 4e7c1d749..a2048bb04 100644 --- a/fluxer_admin/src/routes/system.rs +++ b/fluxer_admin/src/routes/system.rs @@ -17,14 +17,12 @@ use serde::Deserialize; use super::system_actions; #[derive(Deserialize)] -#[allow(dead_code)] struct GatewayQuery { leaderboard_limit: Option, node_stats: Option, } #[derive(Deserialize)] -#[allow(dead_code)] struct AuditLogsQuery { q: Option, admin_user_id: Option, @@ -136,7 +134,7 @@ async fn audit_logs_page( sort_by: Some(params.sort_by.to_owned()), sort_order: Some(params.sort_order.to_owned()), limit, - offset: current_page * limit, + offset: u64::from(current_page) * u64::from(limit), }; let result = client .search_audit_logs(&search_params) diff --git a/fluxer_admin/src/routes/user_actions.rs b/fluxer_admin/src/routes/user_actions.rs index f285e8126..0c529d8bd 100644 --- a/fluxer_admin/src/routes/user_actions.rs +++ b/fluxer_admin/src/routes/user_actions.rs @@ -60,7 +60,9 @@ pub async fn dispatch( "Failed to update user flags", ); } - let submitted = parse_u64_list(form, &["flags[]", "flags"]); + let Ok(submitted) = form.parse_list_values::(&["flags[]", "flags"]) else { + return DispatchOutcome::error("Invalid user flag value"); + }; let selected = submitted.iter().copied().collect::>(); let user = match client.get_user_by_id(user_id).await { Ok(user) => user, @@ -89,15 +91,22 @@ pub async fn dispatch( } "update_premium_flags" => { if has_legacy_flag_delta_fields(form) { - let add = parse_i32_list(form, &["add_flags[]", "add_flags"]); - let remove = parse_i32_list(form, &["remove_flags[]", "remove_flags"]); + let Ok(add) = form.parse_list_values::(&["add_flags[]", "add_flags"]) else { + return DispatchOutcome::error("Invalid premium flag value to add"); + }; + let Ok(remove) = form.parse_list_values::(&["remove_flags[]", "remove_flags"]) + else { + return DispatchOutcome::error("Invalid premium flag value to remove"); + }; return DispatchOutcome::from_result( client.update_premium_flags(user_id, &add, &remove).await, "Premium flags updated successfully", "Failed to update premium flags", ); } - let submitted = parse_i32_list(form, &["flags[]", "flags"]); + let Ok(submitted) = form.parse_list_values::(&["flags[]", "flags"]) else { + return DispatchOutcome::error("Invalid premium flag value"); + }; let selected = submitted.iter().copied().collect::>(); let user = match client.get_user_by_id(user_id).await { Ok(user) => user, @@ -124,9 +133,12 @@ pub async fn dispatch( ) } "update_suspicious_flags" => { - let flags = parse_i32_list(form, &["suspicious_flags[]", "suspicious_flags"]) - .into_iter() - .fold(0, |acc, flag| acc | flag); + let Ok(submitted) = + form.parse_list_values::(&["suspicious_flags[]", "suspicious_flags"]) + else { + return DispatchOutcome::error("Invalid suspicious activity flag value"); + }; + let flags = submitted.into_iter().fold(0, |acc, flag| acc | flag); DispatchOutcome::from_result( client.update_suspicious_flags(user_id, flags).await, "Suspicious activity flags updated successfully", @@ -225,15 +237,19 @@ pub async fn dispatch( ) } "temp_ban" => { - let dur = form - .parse_u32("duration_hours") - .or_else(|| form.parse_u32("duration")) - .unwrap_or(24); + let Ok(duration) = form.parse_value_any::(&["duration_hours", "duration"]) else { + return DispatchOutcome::error("Invalid ban duration"); + }; let reason = get("reason"); let private = get("private_reason"); DispatchOutcome::from_result( client - .temp_ban_user(user_id, dur, reason.as_deref(), private.as_deref()) + .temp_ban_user( + user_id, + duration.unwrap_or(24), + reason.as_deref(), + private.as_deref(), + ) .await, "User temporarily banned successfully", "Failed to temporarily ban user", @@ -249,7 +265,7 @@ pub async fn dispatch( return DispatchOutcome::error("IP address is required"); }; DispatchOutcome::from_result( - client.ban_ip(&ip).await, + client.ban_ip(&ip, None).await, "IP banned successfully", "Failed to ban IP", ) @@ -259,18 +275,29 @@ pub async fn dispatch( return DispatchOutcome::error("Avatar hash is required"); }; DispatchOutcome::from_result( - client.ban_avatar_hash(&hash).await, + client.ban_avatar_hash(&hash, None).await, "Avatar hash banned successfully", "Failed to ban avatar hash", ) } "schedule_deletion" => { - let reason_code = form.parse_i32("reason_code").unwrap_or(0); + let Ok(reason_code) = form.parse_value::("reason_code") else { + return DispatchOutcome::error("Invalid deletion reason code"); + }; let public_reason = get("public_reason"); - let days = form.parse_u32("days_until_deletion").unwrap_or(60); + let private_reason = get("private_reason"); + let Ok(days) = form.parse_value_any::(&["days_until_deletion", "days"]) else { + return DispatchOutcome::error("Invalid deletion delay"); + }; DispatchOutcome::from_result( client - .schedule_deletion(user_id, reason_code, public_reason.as_deref(), days) + .schedule_deletion( + user_id, + reason_code.unwrap_or(0), + public_reason.as_deref(), + days.unwrap_or(60), + private_reason.as_deref(), + ) .await, "User deletion scheduled successfully", "Failed to schedule user deletion", @@ -441,20 +468,6 @@ fn is_relationship_category(category: &str) -> bool { ) } -fn parse_u64_list(form: &MultiValueForm, keys: &[&str]) -> Vec { - form.list_values_any(keys) - .iter() - .filter_map(|value| value.parse().ok()) - .collect() -} - -fn parse_i32_list(form: &MultiValueForm, keys: &[&str]) -> Vec { - form.list_values_any(keys) - .iter() - .filter_map(|value| value.parse().ok()) - .collect() -} - fn parse_dry_run(value: Option<&str>) -> bool { !matches!(value.map(|value| value.trim().to_ascii_lowercase()), Some(value) if value == "false" || value == "0") } diff --git a/fluxer_admin/src/routes/user_tabs.rs b/fluxer_admin/src/routes/user_tabs.rs index d7ebf23f9..30a8e4f76 100644 --- a/fluxer_admin/src/routes/user_tabs.rs +++ b/fluxer_admin/src/routes/user_tabs.rs @@ -164,25 +164,29 @@ pub async fn render( Some(tabs::guilds::guilds_tab(config, user_id, &g)) } "reports" => { - let lim = query.reports_limit.unwrap_or(25); - let sp = query.reports_sent_page.unwrap_or(0); - let rp = query.reports_received_page.unwrap_or(0); + let limit = query.reports_limit.unwrap_or(25); + let sent_page = query.reports_sent_page.unwrap_or(0); + let received_page = query.reports_received_page.unwrap_or(0); let sent = client - .search_reports_by_reporter(user_id, lim, sp * lim) + .search_reports_by_reporter(user_id, limit, u64::from(sent_page) * u64::from(limit)) .await .log_error("load reports sent by user"); - let recv = client - .search_reports_by_reported_user(user_id, lim, rp * lim) + let received = client + .search_reports_by_reported_user( + user_id, + limit, + u64::from(received_page) * u64::from(limit), + ) .await .log_error("load reports against user"); Some(tabs::reports::reports_tab( config, user_id, sent.as_ref(), - recv.as_ref(), - sp, - rp, - lim, + received.as_ref(), + sent_page, + received_page, + limit, )) } "relationships" => { @@ -244,7 +248,7 @@ pub async fn render( sort_by: Some("created_at".to_owned()), sort_order: Some("desc".to_owned()), limit, - offset: page * limit, + offset: u64::from(page) * u64::from(limit), }) .await .log_error("load user admin audit logs")?; diff --git a/fluxer_admin/src/routes/users.rs b/fluxer_admin/src/routes/users.rs index 5f91705b0..2b66a1920 100644 --- a/fluxer_admin/src/routes/users.rs +++ b/fluxer_admin/src/routes/users.rs @@ -93,7 +93,7 @@ async fn users_list( .log_error("lookup users by ids") .map(|users| (users, false)) } else if params.has_search() { - let offset = params.page.saturating_mul(params.limit); + let offset = u64::from(params.page) * u64::from(params.limit); client .search_users( params.search_query(), @@ -105,7 +105,7 @@ async fn users_list( .await .log_error("search users") .map(|r| { - let has_more = u64::from(offset) + (r.users.len() as u64) < r.total; + let has_more = (r.users.len() as u64) < r.total.saturating_sub(offset); (r.users, has_more) }) } else { diff --git a/fluxer_admin/src/templates/components/archive.rs b/fluxer_admin/src/templates/components/archive.rs index a1d8cc05c..55c0b3a55 100644 --- a/fluxer_admin/src/templates/components/archive.rs +++ b/fluxer_admin/src/templates/components/archive.rs @@ -41,17 +41,14 @@ pub fn archives_tab( } } -fn status_text(archive: &Archive) -> String { +fn status_text(archive: &Archive) -> &str { if archive.failed_at.is_some() { - return "Failed".to_owned(); + return "Failed"; } if archive.completed_at.is_some() { - return "Completed".to_owned(); + return "Completed"; } - archive - .progress_step - .clone() - .unwrap_or_else(|| "In Progress".to_owned()) + archive.progress_step.as_deref().unwrap_or("In Progress") } fn archives_table(base: &str, archives: &[Archive]) -> Markup { diff --git a/fluxer_admin/src/templates/components/audit_logs_for_target.rs b/fluxer_admin/src/templates/components/audit_logs_for_target.rs index 653bfbef6..3d65b70e2 100644 --- a/fluxer_admin/src/templates/components/audit_logs_for_target.rs +++ b/fluxer_admin/src/templates/components/audit_logs_for_target.rs @@ -66,9 +66,15 @@ pub fn audit_logs_for_target( ) -> Markup { let has_previous = current_page > 0; let offset = (current_page as u64) * (PAGE_SIZE as u64); - let has_next = offset + (entries.len() as u64) < total; - let total_pages = ((total as f64) / (PAGE_SIZE as f64)).ceil().max(1.0) as u64; - let all_logs_href = format!("{base_path}/audit-logs?target_id={target_id}"); + let next_page = current_page + .checked_add(1) + .filter(|_| offset + (entries.len() as u64) < total); + let total_pages = total.div_ceil(u64::from(PAGE_SIZE)).max(1); + let page_number = u64::from(current_page) + 1; + let all_logs_href = format!( + "{base_path}/audit-logs?target_id={}", + urlencoding::encode(target_id) + ); html! { div class="rounded-lg bg-white transition-all border border-neutral-200 p-6" { @@ -108,7 +114,7 @@ pub fn audit_logs_for_target( })) })) } - @if has_previous || has_next { + @if has_previous || next_page.is_some() { div class="flex items-center justify-center gap-2" { @if has_previous { a href={(tab_href_base) "&audit_logs_page=" (current_page - 1)} @@ -119,10 +125,10 @@ pub fn audit_logs_for_target( } } span class="text-sm text-neutral-500" { - "Page " (current_page + 1) " of " (total_pages) + "Page " (page_number) " of " (total_pages) } - @if has_next { - a href={(tab_href_base) "&audit_logs_page=" (current_page + 1)} + @if let Some(page) = next_page { + a href={(tab_href_base) "&audit_logs_page=" (page)} class="inline-flex items-center justify-center gap-2 font-medium \ rounded-lg bg-neutral-50 text-neutral-700 border \ border-neutral-300 px-3 py-1.5 text-sm" { diff --git a/fluxer_admin/src/templates/components/media.rs b/fluxer_admin/src/templates/components/media.rs index 5b064f3a0..177c21eb6 100644 --- a/fluxer_admin/src/templates/components/media.rs +++ b/fluxer_admin/src/templates/components/media.rs @@ -81,20 +81,15 @@ pub fn guild_asset_url( } pub fn initials(name: &str) -> String { - let parts = name - .split_whitespace() - .filter(|part| !part.is_empty()) - .collect::>(); - match parts.as_slice() { - [] => "?".to_owned(), - [part] => part.chars().next().unwrap_or('?').to_uppercase().collect(), - [first, .., last] => { - let mut value = String::new(); - value.extend(first.chars().next().unwrap_or('?').to_uppercase()); - value.extend(last.chars().next().unwrap_or('?').to_uppercase()); - value - } - } + let mut parts = name.split_whitespace(); + let Some(first) = parts.next() else { + return "?".to_owned(); + }; + std::iter::once(first) + .chain(parts.next_back()) + .flat_map(|part| part.chars().take(1)) + .flat_map(char::to_uppercase) + .collect() } fn media_asset_url( diff --git a/fluxer_admin/src/templates/components/message_list.rs b/fluxer_admin/src/templates/components/message_list.rs index 11d796d49..cbdb95f6f 100644 --- a/fluxer_admin/src/templates/components/message_list.rs +++ b/fluxer_admin/src/templates/components/message_list.rs @@ -45,8 +45,8 @@ fn ncmec_badge(att: &Attachment) -> Markup { } fn render_image_attachments(msg: &Message, include_delete: bool) -> Markup { - let images: Vec<&Attachment> = msg.attachments.iter().filter(|a| is_image(a)).collect(); - if images.is_empty() { + let mut images = msg.attachments.iter().filter(|a| is_image(a)).peekable(); + if images.peek().is_none() { return html! {}; } let spacer = if !msg.content.is_empty() { @@ -56,7 +56,7 @@ fn render_image_attachments(msg: &Message, include_delete: bool) -> Markup { }; html! { div class=(spacer) { - @for att in &images { + @for att in images { div class="max-w-xl overflow-hidden rounded-xl border border-neutral-200 bg-neutral-50" { a href=(att.url) target="_blank" rel="noopener noreferrer" class="block overflow-hidden bg-neutral-100" { @@ -116,8 +116,8 @@ fn render_image_attachments(msg: &Message, include_delete: bool) -> Markup { } fn render_other_attachments(msg: &Message, has_content_or_images: bool) -> Markup { - let others: Vec<&Attachment> = msg.attachments.iter().filter(|a| !is_image(a)).collect(); - if others.is_empty() { + let mut others = msg.attachments.iter().filter(|a| !is_image(a)).peekable(); + if others.peek().is_none() { return html! {}; } let spacer = if has_content_or_images { @@ -127,7 +127,7 @@ fn render_other_attachments(msg: &Message, has_content_or_images: bool) -> Marku }; html! { div class=(spacer) { - @for att in &others { + @for att in others { div class="flex flex-wrap items-center gap-2 text-xs" { (paperclip_icon("text-neutral-400")) a href=(att.url) target="_blank" rel="noopener noreferrer" diff --git a/fluxer_admin/src/templates/components/mod.rs b/fluxer_admin/src/templates/components/mod.rs index 1e0099ecb..467b0a001 100644 --- a/fluxer_admin/src/templates/components/mod.rs +++ b/fluxer_admin/src/templates/components/mod.rs @@ -17,7 +17,6 @@ pub mod message_data; pub mod message_list; pub mod nsfw_indicators; pub mod page_container; -pub mod pagination; pub mod resource_link; pub mod section_card; pub mod stack; diff --git a/fluxer_admin/src/templates/components/nsfw_indicators.rs b/fluxer_admin/src/templates/components/nsfw_indicators.rs index 11a4d9a24..4861d35a8 100644 --- a/fluxer_admin/src/templates/components/nsfw_indicators.rs +++ b/fluxer_admin/src/templates/components/nsfw_indicators.rs @@ -19,7 +19,7 @@ pub fn adult_content_badge(is_adult: bool, label: Option<&str>) -> Markup { } fn truncate(text: &str, max: usize) -> String { - if text.len() <= max { + if text.chars().nth(max).is_none() { text.to_owned() } else { let boundary = max.saturating_sub(1); @@ -29,10 +29,7 @@ fn truncate(text: &str, max: usize) -> String { } pub fn content_warning_badge(level: Option, text: Option<&str>, inline_text: bool) -> Markup { - let Some(lvl) = level else { - return html! {}; - }; - if lvl != CONTENT_WARNING_LEVEL { + if level != Some(CONTENT_WARNING_LEVEL) { return html! {}; } let default_text = "This contains sensitive content."; @@ -82,13 +79,8 @@ fn resolve_nsfw_source( fn source_label(source: NsfwSource, explicit: Option) -> &'static str { match source { - NsfwSource::Channel => { - if explicit == Some(true) { - "(override on)" - } else { - "(override off)" - } - } + NsfwSource::Channel if explicit == Some(true) => "(override on)", + NsfwSource::Channel => "(override off)", NsfwSource::Category => "(from category)", NsfwSource::Community => "(from community)", NsfwSource::None => "", @@ -110,11 +102,7 @@ pub fn channel_nsfw_state_badge( } let has_context = nsfw_override.is_some() || category_override.is_some() || guild_nsfw.is_some(); - let (source, explicit) = if has_context { - resolve_nsfw_source(nsfw_override, category_override, guild_nsfw) - } else { - (NsfwSource::None, None) - }; + let (source, explicit) = resolve_nsfw_source(nsfw_override, category_override, guild_nsfw); html! { span class="inline-flex flex-wrap items-center gap-1" { @if is_nsfw { diff --git a/fluxer_admin/src/templates/components/pagination.rs b/fluxer_admin/src/templates/components/pagination.rs deleted file mode 100644 index eaf1b5929..000000000 --- a/fluxer_admin/src/templates/components/pagination.rs +++ /dev/null @@ -1,34 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -use maud::{Markup, PreEscaped, html}; - -pub fn pagination(base_url: &str, current_page: u32, has_more: bool, extra_params: &str) -> Markup { - let sep = if base_url.contains('?') { "&" } else { "?" }; - let has_previous = current_page > 1; - html! { - div class="mt-4 flex items-center justify-between" { - @if has_previous { - p class="text-sm font-normal text-neutral-500" { - a href={ - (base_url) (sep) "page=" (current_page - 1) (extra_params) - } class="text-neutral-900 underline decoration-neutral-300 hover:text-neutral-600 hover:decoration-neutral-500 hover:text-neutral-900" { - (PreEscaped("← Previous")) - } - } - } @else { - span {} - } - @if has_more { - p class="text-sm font-normal text-neutral-500" { - a href={ - (base_url) (sep) "page=" (current_page + 1) (extra_params) - } class="text-neutral-900 underline decoration-neutral-300 hover:text-neutral-600 hover:decoration-neutral-500 hover:text-neutral-900" { - (PreEscaped("Next →")) - } - } - } @else { - span {} - } - } - } -} diff --git a/fluxer_admin/src/templates/components/resource_link.rs b/fluxer_admin/src/templates/components/resource_link.rs index 714684ca2..6129a825b 100644 --- a/fluxer_admin/src/templates/components/resource_link.rs +++ b/fluxer_admin/src/templates/components/resource_link.rs @@ -12,8 +12,6 @@ const RESOURCE_LINK_CLASS: &str = "text-neutral-900 underline decoration-neutral hover:text-neutral-600 hover:decoration-neutral-500 text-sm"; const NAV_LINK_CLASS: &str = "label rounded-lg border border-neutral-300 bg-white \ px-3 py-2 text-neutral-700 transition-colors hover:bg-neutral-50"; -const TEXT_LINK_CLASS: &str = "text-neutral-900 underline decoration-neutral-300 \ - hover:text-neutral-600 hover:decoration-neutral-500"; impl ResourceType { fn path_segment(self) -> &'static str { @@ -91,18 +89,3 @@ pub fn nav_link(href: &str, content: Markup) -> Markup { a href=(href) class=(NAV_LINK_CLASS) { (content) } } } - -pub fn text_link(href: &str, content: Markup, external: bool, _mono: bool) -> Markup { - if external { - html! { - a href=(href) class=(TEXT_LINK_CLASS) - target="_blank" rel="noopener noreferrer" { - (content) - } - } - } else { - html! { - a href=(href) class=(TEXT_LINK_CLASS) { (content) } - } - } -} diff --git a/fluxer_admin/src/templates/pages/audit_logs.rs b/fluxer_admin/src/templates/pages/audit_logs.rs index 7eab358a6..d66f61442 100644 --- a/fluxer_admin/src/templates/pages/audit_logs.rs +++ b/fluxer_admin/src/templates/pages/audit_logs.rs @@ -72,27 +72,22 @@ fn filters_section(base: &str, params: &AuditLogsParams<'_>) -> Markup { } fn build_pagination_url(base: &str, page: u32, params: &AuditLogsParams<'_>) -> String { - let mut url = format!("{base}/audit-logs?page={page}"); - if !params.query.is_empty() { - url.push_str(&format!("&q={}", params.query)); - } - if !params.admin_user_id.is_empty() { - url.push_str(&format!("&admin_user_id={}", params.admin_user_id)); - } - if !params.target_id.is_empty() { - url.push_str(&format!("&target_id={}", params.target_id)); - } - if !params.target_type.is_empty() { - url.push_str(&format!("&target_type={}", params.target_type)); - } - if !params.sort_by.is_empty() { - url.push_str(&format!("&sort_by={}", params.sort_by)); - } - if !params.sort_order.is_empty() { - url.push_str(&format!("&sort_order={}", params.sort_order)); - } - url.push_str(&format!("&limit={}", params.limit)); - url + let mut query = url::form_urlencoded::Serializer::new(String::new()); + query.append_pair("page", &page.to_string()); + query.extend_pairs( + [ + ("q", params.query), + ("admin_user_id", params.admin_user_id), + ("target_id", params.target_id), + ("target_type", params.target_type), + ("sort_by", params.sort_by), + ("sort_order", params.sort_order), + ] + .into_iter() + .filter(|(_, value)| !value.is_empty()), + ); + query.append_pair("limit", ¶ms.limit.to_string()); + format!("{base}/audit-logs?{}", query.finish()) } pub fn audit_logs_page( @@ -107,10 +102,15 @@ pub fn audit_logs_page( let total = data.total; let entries = &data.logs; let total_pages = if params.limit > 0 { - ((total as f64) / (params.limit as f64)).ceil().max(1.0) as u64 + total.div_ceil(u64::from(params.limit)).max(1) } else { 1 }; + let page_number = u64::from(params.current_page) + 1; + let next_page = params + .current_page + .checked_add(1) + .filter(|page| u64::from(*page) < total_pages); let showing = format!("Showing {} of {} entries", entries.len(), total); html! { (page_header_with_actions("Audit Logs", None, html! { @@ -134,10 +134,10 @@ pub fn audit_logs_page( } } @else { span {} } span class="text-sm text-neutral-500" { - "Page " (params.current_page + 1) " of " (total_pages) + "Page " (page_number) " of " (total_pages) } - @if (params.current_page + 1) < total_pages as u32 { - a href=(build_pagination_url(base, params.current_page + 1, params)) + @if let Some(page) = next_page { + a href=(build_pagination_url(base, page, params)) class="text-sm text-neutral-900 underline" { (PreEscaped("Next →")) } diff --git a/fluxer_admin/src/templates/pages/audit_logs_table.rs b/fluxer_admin/src/templates/pages/audit_logs_table.rs index 498072972..e69df5e38 100644 --- a/fluxer_admin/src/templates/pages/audit_logs_table.rs +++ b/fluxer_admin/src/templates/pages/audit_logs_table.rs @@ -14,12 +14,7 @@ use crate::{ use maud::{Markup, html}; pub fn format_action(action: &str) -> String { - let replaced = action.replace('_', " "); - let mut chars = replaced.chars(); - match chars.next() { - None => String::new(), - Some(c) => c.to_uppercase().to_string() + chars.as_str(), - } + capitalise(&action.replace('_', " ")) } pub fn action_badge_variant(action: &str) -> BadgeVariant { @@ -92,10 +87,8 @@ pub fn admin_user_cell(base: &str, entry: &AuditLogEntry) -> Markup { } } -fn target_guild_label(guild: Option<&AuditLogGuildSummary>) -> String { - guild - .map(|guild| guild.name.clone()) - .unwrap_or_else(|| "Guild".to_owned()) +fn target_guild_label(guild: Option<&AuditLogGuildSummary>) -> &str { + guild.map(|guild| guild.name.as_str()).unwrap_or("Guild") } pub fn target_cell(base: &str, entry: &AuditLogEntry) -> Markup { diff --git a/fluxer_admin/src/templates/pages/gift_codes.rs b/fluxer_admin/src/templates/pages/gift_codes.rs index 0546aac46..2ecb09189 100644 --- a/fluxer_admin/src/templates/pages/gift_codes.rs +++ b/fluxer_admin/src/templates/pages/gift_codes.rs @@ -16,7 +16,7 @@ use crate::{ }; use maud::{Markup, html}; -const MAX_GIFT_CODES: u32 = 100; +pub const MAX_GIFT_CODES: u32 = 100; const DEFAULT_GIFT_COUNT: u32 = 10; pub fn gift_codes_page( @@ -44,12 +44,12 @@ pub fn gift_codes_page( (csrf_input(csrf_token)) div class="flex flex-col gap-4" { (form_field_group( - "Number of codes", "gift-count-slider", false, None, + "Number of codes", "gift-count-slider", true, None, Some(&format!("Range: 1-{MAX_GIFT_CODES}")), html! { input type="number" id="gift-count-slider" name="count" value=(DEFAULT_GIFT_COUNT) min="1" - max=(MAX_GIFT_CODES) + max=(MAX_GIFT_CODES) required class=(FORM_INPUT_CLASS); }, )) diff --git a/fluxer_admin/src/templates/pages/guild_detail_tabs/members.rs b/fluxer_admin/src/templates/pages/guild_detail_tabs/members.rs index 93c627884..fe4737fdb 100644 --- a/fluxer_admin/src/templates/pages/guild_detail_tabs/members.rs +++ b/fluxer_admin/src/templates/pages/guild_detail_tabs/members.rs @@ -25,7 +25,9 @@ pub fn members_tab( let total = response.total; let total_pages = if limit > 0 { total.div_ceil(limit) } else { 1 }; let has_previous = page > 0; - let has_next = (page as u64) < total_pages.saturating_sub(1); + let next_page = page + .checked_add(1) + .filter(|page| u64::from(*page) < total_pages); html! { div class="space-y-4" { @@ -34,12 +36,16 @@ pub fn members_tab( "Guild Members (" (total) ")" } p class="text-sm text-neutral-500" { - @let start = response.offset + 1; - @let end = std::cmp::min( - response.offset + response.members.len() as u64, - total, - ); - "Showing " (start) "-" (end) " of " (total) + @if response.members.is_empty() { + "Showing 0 of " (total) + } @else { + @let start = u128::from(response.offset) + 1; + @let end = std::cmp::min( + u128::from(response.offset) + response.members.len() as u128, + u128::from(total), + ); + "Showing " (start) "-" (end) " of " (total) + } } } @@ -70,10 +76,10 @@ pub fn members_tab( } } p class="text-sm text-neutral-500" { - "Page " (page + 1) " of " (total_pages) + "Page " (u64::from(page) + 1) " of " (total_pages) } - @if has_next { - a href={(base) "/guilds/" (guild.id) "?tab=members&page=" (page + 1)} + @if let Some(next_page) = next_page { + a href={(base) "/guilds/" (guild.id) "?tab=members&page=" (next_page)} class="inline-flex items-center rounded-md bg-brand-primary px-3 \ py-2 text-sm font-medium text-white hover:bg-brand-primary-dark" { "Next \u{2192}" diff --git a/fluxer_admin/src/templates/pages/guild_detail_tabs/overview.rs b/fluxer_admin/src/templates/pages/guild_detail_tabs/overview.rs index d5f5b764d..60e07abb6 100644 --- a/fluxer_admin/src/templates/pages/guild_detail_tabs/overview.rs +++ b/fluxer_admin/src/templates/pages/guild_detail_tabs/overview.rs @@ -40,13 +40,13 @@ fn channel_type_label(channel_type: i32) -> &'static str { pub fn overview_tab(config: &AdminConfig, guild: &GuildDetailInfo, csrf_token: &str) -> Markup { let base = &config.base_path; - let mut sorted_channels = guild.channels.clone(); + let mut sorted_channels: Vec<_> = guild.channels.iter().collect(); sorted_channels.sort_by_key(|c| c.position); let channels_by_id: std::collections::HashMap<&str, &crate::api::types::GuildChannelSummary> = guild.channels.iter().map(|c| (c.id.as_str(), c)).collect(); - let mut sorted_roles = guild.roles.clone(); + let mut sorted_roles: Vec<_> = guild.roles.iter().collect(); sorted_roles.sort_by_key(|role| std::cmp::Reverse(role.position)); let icon_url = guild_icon_url(config, &guild.id, guild.icon.as_deref(), 256, true); @@ -102,16 +102,10 @@ pub fn overview_tab(config: &AdminConfig, guild: &GuildDetailInfo, csrf_token: & p class="text-sm font-semibold text-neutral-500" { "Custom warning text" } - @if let Some(ref text) = guild.content_warning_text { - @if !text.trim().is_empty() { - blockquote class="border-amber-300 border-l-2 bg-amber-50 \ - px-3 py-2 text-neutral-800 text-sm italic" { - (text) - } - } @else { - p class="text-sm text-neutral-500" { - "\u{2014} (default fallback shown to users)" - } + @if let Some(text) = guild.content_warning_text.as_deref().filter(|text| !text.trim().is_empty()) { + blockquote class="border-amber-300 border-l-2 bg-amber-50 \ + px-3 py-2 text-neutral-800 text-sm italic" { + (text) } } @else { p class="text-sm text-neutral-500" { diff --git a/fluxer_admin/src/templates/pages/guild_detail_tabs/reports.rs b/fluxer_admin/src/templates/pages/guild_detail_tabs/reports.rs index 73bbe0d3c..440c2669a 100644 --- a/fluxer_admin/src/templates/pages/guild_detail_tabs/reports.rs +++ b/fluxer_admin/src/templates/pages/guild_detail_tabs/reports.rs @@ -17,8 +17,9 @@ pub fn reports_tab( let base = &config.base_path; let page_size: u64 = 25; let has_previous = page > 0; - let has_next = (page as u64) * page_size + reports.len() as u64 > 0 - && (page as u64 + 1) * page_size < total; + let next_page = page.checked_add(1).filter(|next_page| { + (page > 0 || !reports.is_empty()) && u64::from(*next_page) * page_size < total + }); html! { div class="space-y-4" { div class="flex items-center justify-between" { @@ -48,7 +49,7 @@ pub fn reports_tab( )) } - @if has_previous || has_next { + @if has_previous || next_page.is_some() { div class="flex justify-center gap-2" { @if has_previous { a href={(base) "/guilds/" (guild.id) "?tab=reports&reports_page=" (page - 1)} @@ -58,8 +59,8 @@ pub fn reports_tab( "\u{2190} Newer" } } - @if has_next { - a href={(base) "/guilds/" (guild.id) "?tab=reports&reports_page=" (page + 1)} + @if let Some(next_page) = next_page { + a href={(base) "/guilds/" (guild.id) "?tab=reports&reports_page=" (next_page)} class="inline-flex items-center rounded-md border border-neutral-300 \ bg-white px-3 py-2 text-sm font-medium text-neutral-700 \ hover:bg-neutral-50" { diff --git a/fluxer_admin/src/templates/pages/guilds_list.rs b/fluxer_admin/src/templates/pages/guilds_list.rs index ec8dc3ca4..81d45faec 100644 --- a/fluxer_admin/src/templates/pages/guilds_list.rs +++ b/fluxer_admin/src/templates/pages/guilds_list.rs @@ -64,7 +64,6 @@ impl GuildsListParams { pub struct GuildsListResults<'a> { pub guilds: Option<&'a [GuildInfo]>, pub total: Option, - pub has_more: bool, } pub fn guilds_list_page( @@ -73,14 +72,12 @@ pub fn guilds_list_page( params: &GuildsListParams, results: Option<&[GuildInfo]>, total: Option, - has_more: bool, is_htmx: bool, ) -> Markup { let base = &config.base_path; let result_state = GuildsListResults { guilds: results, total, - has_more, }; let has_results = results.is_some_and(|guilds| !guilds.is_empty()); let header = html! { @@ -164,7 +161,10 @@ fn render_results( fn pagination(config: &AdminConfig, params: &GuildsListParams, total: u64) -> Markup { let total_pages = total.div_ceil(u64::from(params.limit)).max(1); let has_previous = params.page > 0; - let has_next = u64::from(params.page) < total_pages.saturating_sub(1); + let next_page = params + .page + .checked_add(1) + .filter(|page| u64::from(*page) < total_pages); html! { div class="mt-6 flex items-center justify-center gap-3" { @if has_previous { @@ -181,10 +181,10 @@ fn pagination(config: &AdminConfig, params: &GuildsListParams, total: u64) -> Ma } } span class="text-neutral-600 text-sm" { - "Page " (params.page + 1) " of " (total_pages) + "Page " (u64::from(params.page) + 1) " of " (total_pages) } - @if has_next { - a href=(pagination_url(&config.base_path, params, params.page + 1)) + @if let Some(next_page) = next_page { + a href=(pagination_url(&config.base_path, params, next_page)) class="rounded-lg bg-neutral-900 px-6 py-2 font-medium text-sm \ text-white no-underline transition-colors hover:bg-neutral-800" { "Next \u{2192}" @@ -200,8 +200,7 @@ fn pagination(config: &AdminConfig, params: &GuildsListParams, total: u64) -> Ma } fn pagination_url(base: &str, params: &GuildsListParams, page: u32) -> String { - let mut parts = Vec::new(); - parts.push(format!("page={page}")); + let mut parts = vec![format!("page={page}"), format!("limit={}", params.limit)]; if !params.q.is_empty() { parts.push(format!("q={}", urlencoding::encode(¶ms.q))); } diff --git a/fluxer_admin/src/templates/pages/job_detail.rs b/fluxer_admin/src/templates/pages/job_detail.rs index 3dece0bae..604290c07 100644 --- a/fluxer_admin/src/templates/pages/job_detail.rs +++ b/fluxer_admin/src/templates/pages/job_detail.rs @@ -7,7 +7,6 @@ use crate::{ templates::{ components::{ auto_refresh::auto_refresh, - badge::{BadgeVariant, badge}, data_field::{data_field_mono, data_field_text, data_grid}, form::{csrf_input, danger_button, form_field_group}, page_container::{page_header_with_actions, page_header_with_back}, @@ -18,17 +17,7 @@ use crate::{ }; use maud::{Markup, html}; -fn status_badge(status: &str) -> Markup { - let variant = match status { - "queued" => BadgeVariant::Default, - "running" => BadgeVariant::Info, - "succeeded" => BadgeVariant::Success, - "failed" | "deadletter" => BadgeVariant::Danger, - "cancelled" => BadgeVariant::Warning, - _ => BadgeVariant::Default, - }; - badge(status, variant) -} +use super::jobs_list_helpers::status_badge; fn val_str<'a>(job: &'a serde_json::Value, key: &str) -> &'a str { job.get(key).and_then(|v| v.as_str()).unwrap_or("") @@ -71,7 +60,7 @@ fn progress_bar(job: &serde_json::Value) -> Markup { } (cur, Some(tot)) => { let c = cur.unwrap_or(0); - let pct = (c * 100 / tot).min(100); + let pct = (u128::from(c) * 100 / u128::from(tot)).min(100); html! { div role="progressbar" aria-valuenow=(pct) aria-valuemin="0" aria-valuemax="100" diff --git a/fluxer_admin/src/templates/pages/jobs_list_helpers.rs b/fluxer_admin/src/templates/pages/jobs_list_helpers.rs index 7c5fe6950..96f5521ec 100644 --- a/fluxer_admin/src/templates/pages/jobs_list_helpers.rs +++ b/fluxer_admin/src/templates/pages/jobs_list_helpers.rs @@ -33,25 +33,26 @@ pub(crate) fn format_progress(job: &serde_json::Value) -> String { (Some(cur), None | Some(0)) => format!("{cur}"), (cur, Some(tot)) => { let c = cur.unwrap_or(0); - let pct = c.saturating_mul(100).checked_div(tot).unwrap_or(0); + let pct = (u128::from(c) * 100) + .checked_div(u128::from(tot)) + .unwrap_or(0); format!("{c} / {tot} ({pct}%)") } } } -pub(crate) fn job_row(base: &str, job: &serde_json::Value) -> Markup { - let job_id = job - .get("job_id") - .and_then(|v| v.as_str().or_else(|| v.as_u64().map(|_| ""))) - .unwrap_or(""); - let job_id_display = job +fn job_id_text(value: &serde_json::Value) -> String { + value .get("job_id") .map(|v| match v { serde_json::Value::String(s) => s.clone(), - serde_json::Value::Number(n) => n.to_string(), _ => v.to_string(), }) - .unwrap_or_default(); + .unwrap_or_default() +} + +pub(crate) fn job_row(base: &str, job: &serde_json::Value) -> Markup { + let job_id = job_id_text(job); let task_type = job .get("task_type") .and_then(|v| v.as_str()) @@ -79,23 +80,17 @@ pub(crate) fn job_row(base: &str, job: &serde_json::Value) -> Markup { .unwrap_or_else(|| "cron".to_owned()); let progress = format_progress(job); - let link_id = if job_id.is_empty() { - &job_id_display - } else { - job_id - }; - table_row(html! { (table_cell(true, html! { span class="whitespace-nowrap text-sm" { (created_at) } })) (table_cell(false, html! { - a href={(base) "/jobs/" (link_id)} + a href={(base) "/jobs/" (job_id)} hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true" class="text-blue-600 text-sm hover:underline" { - (job_id_display) + (job_id) } })) (table_cell(false, html! { @@ -103,7 +98,7 @@ pub(crate) fn job_row(base: &str, job: &serde_json::Value) -> Markup { })) (table_cell(false, html! { (status_badge(status)) })) (table_cell(false, html! { - span class="text-sm" data-job-progress=(link_id) { (progress) } + span class="text-sm" data-job-progress=(job_id) { (progress) } })) (table_cell(true, html! { span class="text-sm" { (attempts) "/" (max_attempts) } @@ -112,7 +107,7 @@ pub(crate) fn job_row(base: &str, job: &serde_json::Value) -> Markup { span class="text-sm" { (requester) } })) (table_cell(false, html! { - a href={(base) "/jobs/" (link_id)} + a href={(base) "/jobs/" (job_id)} hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true" @@ -159,14 +154,7 @@ pub(crate) fn next_page_link( .get("created_at") .and_then(|v| v.as_str()) .unwrap_or(""); - let job_id = cursor - .get("job_id") - .map(|v| match v { - serde_json::Value::String(s) => s.clone(), - serde_json::Value::Number(n) => n.to_string(), - _ => v.to_string(), - }) - .unwrap_or_default(); + let job_id = job_id_text(cursor); let mut params = vec![ format!("cursor_bucket_day={bucket_day}"), diff --git a/fluxer_admin/src/templates/pages/reports_list.rs b/fluxer_admin/src/templates/pages/reports_list.rs index 3d2c56e47..58c25fa4d 100644 --- a/fluxer_admin/src/templates/pages/reports_list.rs +++ b/fluxer_admin/src/templates/pages/reports_list.rs @@ -466,7 +466,10 @@ fn reports_pagination( limit: u32, total: u64, ) -> Markup { - let total_pages = ((total + u64::from(limit).saturating_sub(1)) / u64::from(limit)).max(1); + let total_pages = total.div_ceil(u64::from(limit)).max(1); + let next_page = page + .checked_add(1) + .filter(|page| u64::from(*page) < total_pages); html! { div class="mt-4 flex items-center justify-between" { @if page > 0 { @@ -478,10 +481,10 @@ fn reports_pagination( span {} } span class="text-neutral-500 text-sm" { - "Page " (page + 1) " of " (total_pages) + "Page " (u64::from(page) + 1) " of " (total_pages) } - @if u64::from(page + 1) < total_pages { - a href=(reports_url(config, filters, page + 1, limit)) + @if let Some(next_page) = next_page { + a href=(reports_url(config, filters, next_page, limit)) class="text-neutral-900 underline decoration-neutral-300 hover:text-neutral-600 hover:decoration-neutral-500" { (PreEscaped("Next →")) } diff --git a/fluxer_admin/src/templates/pages/search_index.rs b/fluxer_admin/src/templates/pages/search_index.rs index f91e922fe..9e8a69fcd 100644 --- a/fluxer_admin/src/templates/pages/search_index.rs +++ b/fluxer_admin/src/templates/pages/search_index.rs @@ -121,7 +121,7 @@ fn status_section(base: &str, rs: &RefreshStatus) -> Markup { @if is_in_progress { @if let (Some(idx), Some(tot)) = (rs.indexed, rs.total) { - @let pct = idx.saturating_mul(100).checked_div(tot).unwrap_or(0); + @let pct = (u128::from(idx) * 100).checked_div(u128::from(tot)).unwrap_or(0); div class="space-y-2" { div class="flex items-center justify-between" { p class="text-sm text-neutral-700" { diff --git a/fluxer_admin/src/templates/pages/user_detail_tabs/moderation.rs b/fluxer_admin/src/templates/pages/user_detail_tabs/moderation.rs index 9aa423e2c..9c3bf9133 100644 --- a/fluxer_admin/src/templates/pages/user_detail_tabs/moderation.rs +++ b/fluxer_admin/src/templates/pages/user_detail_tabs/moderation.rs @@ -146,7 +146,7 @@ fn deletion_card(base: &str, user: &AdminUser, csrf_token: &str) -> Markup { (csrf_input(csrf_token)) div class="space-y-3" { (form_label("Days until deletion")) - input type="number" name="days" value="60" + input type="number" name="days_until_deletion" value="60" min="60" max="365" class="block w-full rounded-md border border-neutral-300 \ px-3 py-2 text-sm shadow-sm \ @@ -322,9 +322,8 @@ fn message_shred_status_content(status: &serde_json::Value) -> Markup { let total = value_u64(status, "total").unwrap_or(0); let processed = value_u64(status, "processed").unwrap_or(0); let skipped = value_u64(status, "skipped").unwrap_or(0); - let percentage = processed - .saturating_mul(100) - .checked_div(total) + let percentage = (u128::from(processed) * 100) + .checked_div(u128::from(total)) .unwrap_or(0) .min(100); html! { diff --git a/fluxer_admin/src/templates/pages/user_detail_tabs/reports.rs b/fluxer_admin/src/templates/pages/user_detail_tabs/reports.rs index 1fd97bd5f..db9b4dd62 100644 --- a/fluxer_admin/src/templates/pages/user_detail_tabs/reports.rs +++ b/fluxer_admin/src/templates/pages/user_detail_tabs/reports.rs @@ -78,9 +78,11 @@ fn report_section( } }; - let offset = current_page as u64 * limit as u64; + let offset = u64::from(current_page) * u64::from(limit); let has_previous = current_page > 0; - let has_next = offset + (reports.len() as u64) < total; + let next_page = current_page + .checked_add(1) + .filter(|_| (reports.len() as u64) < total.saturating_sub(offset)); let (sent_page, received_page) = if kind == "sent" { (current_page, other_page) @@ -115,7 +117,7 @@ fn report_section( )) } - @if has_previous || has_next { + @if has_previous || next_page.is_some() { div class="flex justify-center gap-2" { @if has_previous { a href={(base) "/users/" (user_id) "?tab=reports&reports_limit=" (limit) "&reports_sent_page=" (if kind == "sent" { current_page - 1 } else { sent_page }) "&reports_received_page=" (if kind == "received" { current_page - 1 } else { received_page })} @@ -125,8 +127,8 @@ fn report_section( "Previous" } } - @if has_next { - a href={(base) "/users/" (user_id) "?tab=reports&reports_limit=" (limit) "&reports_sent_page=" (if kind == "sent" { current_page + 1 } else { sent_page }) "&reports_received_page=" (if kind == "received" { current_page + 1 } else { received_page })} + @if let Some(next_page) = next_page { + a href={(base) "/users/" (user_id) "?tab=reports&reports_limit=" (limit) "&reports_sent_page=" (if kind == "sent" { next_page } else { sent_page }) "&reports_received_page=" (if kind == "received" { next_page } else { received_page })} class="inline-flex items-center rounded-md border \ border-neutral-300 bg-white px-3 py-2 text-sm \ font-medium text-neutral-700 hover:bg-neutral-50" { diff --git a/fluxer_admin/src/templates/pages/users_list.rs b/fluxer_admin/src/templates/pages/users_list.rs index f5ac4d8d3..2c988a999 100644 --- a/fluxer_admin/src/templates/pages/users_list.rs +++ b/fluxer_admin/src/templates/pages/users_list.rs @@ -393,6 +393,7 @@ fn render_users_table(config: &AdminConfig, users: &[AdminUser], can_view_email: } fn pagination_controls(base: &str, params: &UserListParams, has_more: bool) -> Markup { + let next_page = params.page.checked_add(1).filter(|_| has_more); html! { div class="mt-4 flex items-center justify-between" { @if params.page > 0 { @@ -403,8 +404,8 @@ fn pagination_controls(base: &str, params: &UserListParams, has_more: bool) -> M } @else { span {} } - @if has_more { - a href=(users_url(base, params, params.page + 1)) + @if let Some(next_page) = next_page { + a href=(users_url(base, params, next_page)) class="text-neutral-900 underline decoration-neutral-300 hover:text-neutral-600 hover:decoration-neutral-500" { "Next >" } diff --git a/fluxer_admin/src/utils/forms.rs b/fluxer_admin/src/utils/forms.rs index 719faaa05..423376668 100644 --- a/fluxer_admin/src/utils/forms.rs +++ b/fluxer_admin/src/utils/forms.rs @@ -17,11 +17,14 @@ pub fn clean_string(value: &str) -> Option { } pub fn parse_comma_separated(value: &str) -> Vec { + comma_separated_values(value).map(str::to_owned).collect() +} + +fn comma_separated_values(value: &str) -> impl Iterator { value .split([',', '\n', '\r']) - .map(|s| s.trim().to_owned()) - .filter(|s| !s.is_empty()) - .collect() + .map(str::trim) + .filter(|value| !value.is_empty()) } #[derive(Clone, Debug, Default, Eq, PartialEq)] @@ -63,12 +66,18 @@ impl MultiValueForm { self.first(key).and_then(clean_string) } - pub fn parse_i32(&self, key: &str) -> Option { - self.first(key).and_then(|value| value.parse().ok()) + pub fn parse_value(&self, key: &str) -> Result, T::Err> { + self.parse_value_any(&[key]) } - pub fn parse_i64(&self, key: &str) -> Option { - self.first(key).and_then(|value| value.parse().ok()) + pub fn parse_value_any( + &self, + keys: &[&str], + ) -> Result, T::Err> { + keys.iter() + .find_map(|key| self.first(key)) + .map(str::parse) + .transpose() } pub fn parse_u32(&self, key: &str) -> Option { @@ -92,13 +101,21 @@ impl MultiValueForm { .get(key) .into_iter() .flat_map(|values| values.iter()) - .flat_map(|value| parse_comma_separated(value)) + .flat_map(|value| comma_separated_values(value)) + .map(str::to_owned) .collect() } pub fn list_values_any(&self, keys: &[&str]) -> Vec { keys.iter().flat_map(|key| self.list_values(key)).collect() } + + pub fn parse_list_values(&self, keys: &[&str]) -> Result, T::Err> { + self.list_values_any(keys) + .iter() + .map(|value| value.parse()) + .collect() + } } pub fn parse_page(value: Option<&str>) -> u32 {