// SPDX-License-Identifier: AGPL-3.0-or-later use crate::{ acl, api::{ client::{AdminApiClient, ApiResult, ApiResultExt}, types::{AdminUser, PremiumBranding}, }, middleware::{auth::AuthContext, csrf::CsrfToken, flash, htmx}, routes::user_tabs, state::AppState, templates, utils::forms::MultiValueForm, }; use axum::{ Router, extract::{Path, Query, Request, State}, http::HeaderMap, response::{Html, IntoResponse, Response}, routing::get, }; use serde::Deserialize; const USER_ID_LOOKUP_BATCH: usize = 100; const DEFAULT_PREMIUM_NAME: &str = "Premium"; #[derive(Deserialize)] struct UserListQuery { q: Option, email: Option, ip: Option, ids: Option, limit: Option, page: Option, } #[derive(Deserialize)] struct UserDetailQuery { tab: Option, reports_sent_page: Option, reports_received_page: Option, reports_limit: Option, dm_before: Option, dm_after: Option, dm_limit: Option, audit_logs_page: Option, message_shred_job_id: Option, delete_all_messages_dry_run: Option, delete_all_messages_channel_count: Option, delete_all_messages_message_count: Option, } #[derive(Deserialize)] struct ActionQuery { action: Option, tab: Option, } pub fn router() -> Router { Router::new() .route("/users", get(users_list)) .route("/users/{user_id}", get(user_detail).post(user_detail_post)) .route("/users/{user_id}/tabs/{tab}", get(user_tab)) .route("/users/{user_id}/fragment", get(user_peek)) } async fn users_list( State(state): State, headers: HeaderMap, auth: axum::Extension, Query(query): Query, ) -> Response { let config = state.config(); let params = templates::pages::users_list::UserListParams::from_query( query.q, query.email, query.ip, query.ids, query.limit, query.page, ); let is_results_fragment = htmx::targets(&headers, "users-results"); let admin_acls = auth .0 .admin_user .as_ref() .map(|user| user.acls.as_slice()) .unwrap_or(&[]); let can_view_email = acl::has_permission(admin_acls, acl::USER_VIEW_EMAIL); let client = AdminApiClient::new(state.http_client(), config, &auth.0.session); let searching = params.has_id_lookup() || params.has_search(); let results = async { if params.has_id_lookup() { lookup_users_in_batches(&client, ¶ms.requested_ids) .await .log_error("lookup users by ids") .map(|users| (users, false)) } else if params.has_search() { let offset = u64::from(params.page) * u64::from(params.limit); client .search_users( params.search_query(), params.email_query(), params.ip_query(), params.limit, offset, ) .await .log_error("search users") .map(|r| { let has_more = (r.users.len() as u64) < r.total.saturating_sub(offset); (r.users, has_more) }) } else { None } }; let badge = async { if searching { self_hosted_premium_badge_name(&state, &client).await } else { None } }; let (results, badge_name) = tokio::join!(results, badge); let result_users = results.as_ref().map(|r| r.0.as_slice()); let has_more = results.as_ref().is_some_and(|r| r.1); let premium_badge_name = match result_users { Some(users) if !users.is_empty() => badge_name, _ => None, }; let markup = templates::pages::users_list::users_list_page( config, &auth.0, ¶ms, result_users, has_more, can_view_email, premium_badge_name.as_deref(), is_results_fragment, ); Html(markup.into_string()).into_response() } async fn self_hosted_premium_badge_name( state: &AppState, client: &AdminApiClient, ) -> Option { if !state.config().self_hosted { return None; } premium_badge_name(state.premium_branding(client).await.as_ref()) } fn premium_badge_name(branding: Option<&PremiumBranding>) -> Option { match branding { Some(branding) => branding.premium_enabled.then(|| { branding .name .clone() .unwrap_or_else(|| DEFAULT_PREMIUM_NAME.to_owned()) }), None => Some(DEFAULT_PREMIUM_NAME.to_owned()), } } async fn lookup_users_in_batches( client: &AdminApiClient, user_ids: &[String], ) -> ApiResult> { let mut users = Vec::new(); for batch in user_ids.chunks(USER_ID_LOOKUP_BATCH) { users.extend(client.lookup_users_by_ids(batch).await?); } Ok(users) } async fn user_detail( State(state): State, headers: HeaderMap, auth: axum::Extension, csrf: axum::Extension, Path(user_id): Path, Query(query): Query, ) -> Response { let config = state.config(); let is_detail_fragment = htmx::targets(&headers, "main-content"); let active_tab = query.tab.as_deref().unwrap_or("overview"); let client = AdminApiClient::new(state.http_client(), config, &auth.0.session); let (user, badge_name) = tokio::join!( async { client .get_user_by_id(&user_id) .await .log_error("load user detail") }, self_hosted_premium_badge_name(&state, &client) ); let tq = to_tab_query(&query); let admin_acls = auth .0 .admin_user .as_ref() .map(|user| user.acls.as_slice()) .unwrap_or(&[]); let tab_body = if user.is_some() { user_tabs::render( &client, config, &csrf.0.0, &user_id, active_tab, &tq, admin_acls, ) .await } else { None }; let premium_badge_name = user.as_ref().and(badge_name); let markup = templates::pages::user_detail::user_detail_with_tab( config, &auth.0, user.as_ref(), &user_id, active_tab, tab_body, premium_badge_name.as_deref(), is_detail_fragment, ); Html(markup.into_string()).into_response() } async fn user_detail_post( State(state): State, headers: HeaderMap, auth: axum::Extension, Path(user_id): Path, Query(aq): Query, request: Request, ) -> Response { let config = state.config(); let base = &config.base_path; let tab = aq.tab.as_deref().unwrap_or(""); let form = match MultiValueForm::from_request(request).await { Some(form) => form, None => { let flash = flash::FlashData::error("Invalid form data"); if htmx::is_htmx_request(&headers) && htmx::targets(&headers, "flash-container") { return htmx::toast_response(&flash); } return flash::redirect_with_flash( &format!("{base}/users/{user_id}"), flash, config.secure_cookies(), ); } }; let client = AdminApiClient::new(state.http_client(), config, &auth.0.session); let action = aq.action.as_deref().unwrap_or(""); let outcome = super::user_actions::dispatch(&client, &user_id, action, &form).await; let mut redirect = if tab.is_empty() { format!("{base}/users/{user_id}") } else { format!("{base}/users/{user_id}?tab={tab}") }; append_query_params(&mut redirect, &outcome.redirect_params); if htmx::is_htmx_request(&headers) && htmx::targets(&headers, "flash-container") && outcome.redirect_params.is_empty() { return htmx::toast_response(&outcome.flash); } flash::redirect_with_flash(&redirect, outcome.flash, config.secure_cookies()) } async fn user_tab( State(state): State, auth: axum::Extension, csrf: axum::Extension, Path((user_id, tab)): Path<(String, String)>, ) -> Response { let config = state.config(); let client = AdminApiClient::new(state.http_client(), config, &auth.0.session); let user = client .get_user_by_id(&user_id) .await .log_error("load user tab detail"); let tq = user_tabs::TabQuery { reports_sent_page: None, reports_received_page: None, reports_limit: None, dm_before: None, dm_after: None, dm_limit: None, audit_logs_page: None, message_shred_job_id: None, delete_all_messages_dry_run: None, delete_all_messages_channel_count: None, delete_all_messages_message_count: None, }; let admin_acls = auth .0 .admin_user .as_ref() .map(|user| user.acls.as_slice()) .unwrap_or(&[]); let markup = match user { Some(ref u) => { user_tabs::render(&client, config, &csrf.0.0, &user_id, &tab, &tq, admin_acls) .await .unwrap_or_else(|| { templates::pages::user_detail::simple_tab_content(config, u, &tab) }) } None => maud::html! { div class="p-4 text-red-600 text-sm" { "Failed to load user data." } }, }; Html(markup.into_string()).into_response() } async fn user_peek( State(state): State, auth: axum::Extension, Path(user_id): Path, ) -> Response { let config = state.config(); let client = AdminApiClient::new(state.http_client(), config, &auth.0.session); let (user, badge_name) = tokio::join!( async { client .get_user_by_id(&user_id) .await .log_error("load user peek") }, self_hosted_premium_badge_name(&state, &client) ); let admin_acls = auth .0 .admin_user .as_ref() .map(|user| user.acls.as_slice()) .unwrap_or(&[]); let premium_badge_name = user.as_ref().and(badge_name); let markup = match user { Some(ref u) => templates::pages::user_peek::user_peek_fragment( config, u, admin_acls, premium_badge_name.as_deref(), ), None => maud::html! { div class="p-4 text-red-600 text-sm" { "User not found." } }, }; Html(markup.into_string()).into_response() } fn to_tab_query(q: &UserDetailQuery) -> user_tabs::TabQuery { user_tabs::TabQuery { reports_sent_page: q.reports_sent_page, reports_received_page: q.reports_received_page, reports_limit: q.reports_limit, dm_before: q.dm_before.clone(), dm_after: q.dm_after.clone(), dm_limit: q.dm_limit, audit_logs_page: q.audit_logs_page, message_shred_job_id: q.message_shred_job_id.clone(), delete_all_messages_dry_run: q.delete_all_messages_dry_run.clone(), delete_all_messages_channel_count: q.delete_all_messages_channel_count, delete_all_messages_message_count: q.delete_all_messages_message_count, } } fn append_query_params(url: &mut String, params: &[(String, String)]) { for (key, value) in params { let separator = if url.contains('?') { '&' } else { '?' }; url.push(separator); url.push_str(&urlencoding::encode(key)); url.push('='); url.push_str(&urlencoding::encode(value)); } } #[cfg(test)] mod tests { use super::*; #[test] fn badge_name_follows_the_cached_branding_and_falls_back_to_the_default() { let gold = PremiumBranding { name: Some("Gold".to_owned()), premium_enabled: true, }; assert_eq!(premium_badge_name(Some(&gold)).as_deref(), Some("Gold")); let unnamed = PremiumBranding { name: None, premium_enabled: true, }; assert_eq!( premium_badge_name(Some(&unnamed)).as_deref(), Some("Premium") ); let everyone = PremiumBranding { name: Some("Gold".to_owned()), premium_enabled: false, }; assert_eq!(premium_badge_name(Some(&everyone)), None); assert_eq!(premium_badge_name(None).as_deref(), Some("Premium")); } }