// 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::{ self, pages::user_detail_tabs::account::{ PASSWORD_RESET_LINK_RESULT_ID, password_reset_link_result, }, }, 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 client = AdminApiClient::new(state.http_client(), config, &auth.0.session); let username_sign_in = state.account_identity(&client).await.is_username(); let can_view_email = acl::has_permission(admin_acls, acl::USER_VIEW_EMAIL) && !username_sign_in; 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, username_sign_in, 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() { let account_identity = state.account_identity(&client).await; user_tabs::render( &client, config, &csrf.0.0, &user_id, active_tab, &tq, admin_acls, account_identity, ) .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, csrf: 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(""); if action == "create_password_reset_link" { return create_password_reset_link(&state, &headers, &auth.0, &csrf.0.0, &client, &user_id) .await; } 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 create_password_reset_link( state: &AppState, headers: &HeaderMap, auth: &AuthContext, csrf_token: &str, client: &AdminApiClient, user_id: &str, ) -> Response { let config = state.config(); let account_url = format!("{}/users/{user_id}?tab=account", config.base_path); let link = match client.create_password_reset_link(user_id).await { Ok(link) => link, Err(error) => { tracing::warn!(%error, user_id, "admin API request failed: create password reset link"); let flash = flash::FlashData::error("Failed to create password reset link"); if htmx::is_htmx_request(headers) && (htmx::targets(headers, "flash-container") || htmx::targets(headers, PASSWORD_RESET_LINK_RESULT_ID)) { return htmx::toast_response(&flash); } return flash::redirect_with_flash(&account_url, flash, config.secure_cookies()); } }; if htmx::is_htmx_request(headers) && htmx::targets(headers, PASSWORD_RESET_LINK_RESULT_ID) { return Html(password_reset_link_result(Some(&link)).into_string()).into_response(); } let admin_acls = auth .admin_user .as_ref() .map(|user| user.acls.as_slice()) .unwrap_or(&[]); let (user, badge_name, account_identity) = tokio::join!( async { client .get_user_by_id(user_id) .await .log_error("load user after creating password reset link") }, self_hosted_premium_badge_name(state, client), state.account_identity(client) ); let tab_body = user_tabs::render_account( client, config, csrf_token, user_id, &templates::pages::user_detail_tabs::account::AccountTabOptions { admin_acls, account_identity, password_reset_link: Some(&link), }, ) .await; let premium_badge_name = user.as_ref().and(badge_name); let markup = templates::pages::user_detail::user_detail_with_tab( config, auth, user.as_ref(), user_id, "account", tab_body, premium_badge_name.as_deref(), htmx::targets(headers, "main-content"), ); Html(markup.into_string()).into_response() } 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 account_identity = state.account_identity(&client).await; let markup = match user { Some(ref u) => user_tabs::render( &client, config, &csrf.0.0, &user_id, &tab, &tq, admin_acls, account_identity, ) .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)); } }