/* * asklyphe-frontend routes/admin.rs * - http routes for the admin panel * * Copyright (C) 2025 Real Microsoft, LLC * * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. * * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. If not, see . */ use std::sync::Arc; use askama::Template; use asklyphe_common::nats::authservice::{AuthServiceQuery, AuthServiceRequest, AuthServiceResponse}; use asklyphe_common::nats::authservice::admin::{AdminCreateAnnouncementRequest, AdminCreateAnnouncementResponse, AdminInviteCodeGenerateRequest, AdminInviteCodeGenerateResponse, AdminListAllUsersRequest, AdminListAllUsersResponse, AdminListInviteCodesRequest, AdminListInviteCodesResponse, InviteCodeSerialized, Stats, StatsRequest, StatsResponse, UserSerialized}; use asklyphe_common::nats::authservice::profile::{UserInfoRequest, UserInfoResponse}; use asklyphe_common::nats::comms; use asklyphe_common::nats::comms::ServiceResponse; use async_nats::jetstream; use axum::{Extension, Form}; use axum::response::{IntoResponse, Redirect}; use axum_extra::extract::CookieJar; use rand::Rng; use serde::Deserialize; use tracing::{debug, error}; use crate::Opts; use crate::routes::{authenticate_user, UserInfo}; const MOTD: &[&str] = &[ "be evil :3c", "pong", ">:o", "\" UNION SELECT username, password FROM users; -- ", ]; #[derive(Template)] #[template(path = "admin/error.html")] pub struct AdminErrorTemplate { motd: &'static str, error: String, } #[derive(Template)] #[template(path = "admin/home.html")] pub struct AdminHomeTemplate { motd: &'static str, last_ten_users: Vec, user_count: usize, admin_count: usize, active_verification_requests: usize, } fn motd() -> &'static str { let mut rng = rand::thread_rng(); MOTD[rng.gen_range(0..MOTD.len())] } fn adminerror(message: String) -> impl IntoResponse { AdminErrorTemplate { motd: motd(), error: message, } } async fn stats(nats: Arc, token: String) -> Result { let response = comms::query_service( comms::Query::AuthService(AuthServiceQuery { request: AuthServiceRequest::StatsRequest(StatsRequest { token, }), replyto: "".to_string(), }), &nats, false, ).await; if let Err(e) = response { error!("nats error: {:?}", e); Err(adminerror(format!("nats error: {:?}", e)).into_response()) } else { let response = response.unwrap(); match response { ServiceResponse::SearchService(_) => { error!("sent search service response when asking for auth service!! investigate ASAP!!!"); Err(adminerror("sent search service response when asking auth service!".to_string()).into_response()) } ServiceResponse::BingService(_) => { error!("sent bing service response when asking for auth service!! investigate ASAP!!!"); Err(adminerror("sent bing service response when asking auth service!".to_string()).into_response()) } ServiceResponse::AuthService(r) => match r { AuthServiceResponse::StatsResponse(stats) => { match stats { StatsResponse::Success(stats) => { Ok(stats) } StatsResponse::InternalServerError(e) => { Err(adminerror(format!("internal server error: {e}")).into_response()) } StatsResponse::Logout => { Err(Redirect::to("/").into_response()) } StatsResponse::AccessDenied => { Err(Redirect::to("/").into_response()) } } } x => { error!("auth service gave {} to our user info request!", x); Err(adminerror(format!("auth service gave {} to our user info request!", x)).into_response()) } }, } } } async fn list_invite_codes(nats: Arc, token: String, show_used: bool) -> Result, impl IntoResponse> { let response = comms::query_service( comms::Query::AuthService(AuthServiceQuery { request: AuthServiceRequest::AdminListInviteCodesRequest(AdminListInviteCodesRequest { token, show_used, }), replyto: "".to_string(), }), &nats, false, ).await; if let Err(e) = response { error!("nats error: {:?}", e); Err(adminerror(format!("nats error: {:?}", e)).into_response()) } else { let response = response.unwrap(); match response { ServiceResponse::SearchService(_) => { error!("sent search service response when asking for auth service!! investigate ASAP!!!"); Err(adminerror("sent search service response when asking auth service!".to_string()).into_response()) } ServiceResponse::BingService(_) => { error!("sent bing service response when asking for auth service!! investigate ASAP!!!"); Err(adminerror("sent bing service response when asking auth service!".to_string()).into_response()) } ServiceResponse::AuthService(r) => match r { AuthServiceResponse::AdminListInviteCodesResponse(v) => { match v { AdminListInviteCodesResponse::Success(v) => { Ok(v) } AdminListInviteCodesResponse::InternalServerError(e) => { Err(adminerror(format!("internal server error: {e}")).into_response()) } AdminListInviteCodesResponse::Logout => { Err(Redirect::to("/").into_response()) } AdminListInviteCodesResponse::AccessDenied => { Err(Redirect::to("/").into_response()) } } } x => { error!("auth service gave {} to our list invite codes request!", x); Err(adminerror(format!("auth service gave {} to our list invite codes request!", x)).into_response()) } }, } } } async fn list_all_users(nats: Arc, token: String) -> Result, impl IntoResponse> { let response = comms::query_service( comms::Query::AuthService(AuthServiceQuery { request: AuthServiceRequest::AdminListAllUsersRequest(AdminListAllUsersRequest { token, }), replyto: "".to_string(), }), &nats, false, ).await; if let Err(e) = response { error!("nats error: {:?}", e); Err(adminerror(format!("nats error: {:?}", e)).into_response()) } else { let response = response.unwrap(); match response { ServiceResponse::SearchService(_) => { error!("sent search service response when asking for auth service!! investigate ASAP!!!"); Err(adminerror("sent search service response when asking auth service!".to_string()).into_response()) } ServiceResponse::BingService(_) => { error!("sent bing service response when asking for auth service!! investigate ASAP!!!"); Err(adminerror("sent bing service response when asking auth service!".to_string()).into_response()) } ServiceResponse::AuthService(r) => match r { AuthServiceResponse::AdminListAllUsersResponse(v) => { match v { AdminListAllUsersResponse::Success(v) => { Ok(v) } AdminListAllUsersResponse::InternalServerError(e) => { Err(adminerror(format!("internal server error: {e}")).into_response()) } AdminListAllUsersResponse::Logout => { Err(Redirect::to("/").into_response()) } AdminListAllUsersResponse::AccessDenied => { Err(Redirect::to("/").into_response()) } } } x => { error!("auth service gave {} to our list users request!", x); Err(adminerror(format!("auth service gave {} to our list users request!", x)).into_response()) } }, } } } async fn gen_invite_code(nats: Arc, token: String, code: String, comment: String) -> Result<(), impl IntoResponse> { let response = comms::query_service( comms::Query::AuthService(AuthServiceQuery { request: AuthServiceRequest::AdminInviteCodeGenerateRequest(AdminInviteCodeGenerateRequest { token, invite_code: code, comment, }), replyto: "".to_string(), }), &nats, false, ).await; if let Err(e) = response { error!("nats error: {:?}", e); Err(adminerror(format!("nats error: {:?}", e)).into_response()) } else { let response = response.unwrap(); match response { ServiceResponse::SearchService(_) => { error!("sent search service response when asking for auth service!! investigate ASAP!!!"); Err(adminerror("sent search service response when asking auth service!".to_string()).into_response()) } ServiceResponse::BingService(_) => { error!("sent bing service response when asking for auth service!! investigate ASAP!!!"); Err(adminerror("sent bing service response when asking auth service!".to_string()).into_response()) } ServiceResponse::AuthService(r) => match r { AuthServiceResponse::AdminInviteCodeGenerateResponse(res) => { match res { AdminInviteCodeGenerateResponse::Success => { Ok(()) } AdminInviteCodeGenerateResponse::InternalServerError(e) => { Err(adminerror(format!("internal server error: {e}")).into_response()) } AdminInviteCodeGenerateResponse::Logout => { Err(Redirect::to("/").into_response()) } AdminInviteCodeGenerateResponse::AccessDenied => { Err(Redirect::to("/").into_response()) } AdminInviteCodeGenerateResponse::CodeAlreadyExists => { Err(adminerror("code already exists".to_string()).into_response()) } } } x => { error!("auth service gave {} to our user info request!", x); Err(adminerror(format!("auth service gave {} to our geninvitecode request!", x)).into_response()) } }, } } } pub async fn admin_home( jar: CookieJar, Extension(nats): Extension>, Extension(opts): Extension, ) -> impl IntoResponse { if let Some(token) = jar.get("token") { let token = token.value().to_string(); let info = match authenticate_user(nats.clone(), token.clone()).await { Ok(i) => i, Err(e) => { return Redirect::to("/").into_response(); } }; if !info.administrator { return Redirect::to("/").into_response(); } let stats = match stats(nats.clone(), token).await { Ok(s) => s, Err(e) => { return e.into_response(); } }; AdminHomeTemplate { motd: motd(), last_ten_users: stats.last_ten_users, user_count: stats.user_count, admin_count: stats.admin_count, active_verification_requests: stats.active_verification_requests, }.into_response() } else { Redirect::to("/").into_response() } } #[derive(Template)] #[template(path = "admin/invitecode.html")] pub struct AdminInviteCodeTemplate { motd: &'static str, username: String, active_codes: Vec, used_codes: Vec, } pub async fn admin_invitecode( jar: CookieJar, Extension(nats): Extension>, Extension(opts): Extension, ) -> impl IntoResponse { if let Some(token) = jar.get("token") { let token = token.value().to_string(); let info = match authenticate_user(nats.clone(), token.clone()).await { Ok(i) => i, Err(e) => { return Redirect::to("/").into_response(); } }; if !info.administrator { return Redirect::to("/").into_response(); } let active_codes = match list_invite_codes(nats.clone(), token.clone(), false).await { Ok(v) => v, Err(e) => { return e.into_response(); } }; let used_codes = match list_invite_codes(nats.clone(), token.clone(), true).await { Ok(v) => v.into_iter().map(|mut v| if v.used_at.is_none() { v.used_at = Some(String::from("unset")); v } else { v }).collect(), Err(e) => { return e.into_response(); } }; AdminInviteCodeTemplate { motd: motd(), username: info.username, active_codes, used_codes, }.into_response() } else { Redirect::to("/").into_response() } } #[derive(Deserialize, Debug)] pub struct InviteCodeForm { code: Option, comment: Option, } pub async fn admin_invitecode_post( jar: CookieJar, Extension(nats): Extension>, Extension(opts): Extension, Form(input): Form, ) -> impl IntoResponse { if let Some(token) = jar.get("token") { let token = token.value().to_string(); let info = match authenticate_user(nats.clone(), token.clone()).await { Ok(i) => i, Err(e) => { return Redirect::to("/").into_response(); } }; if !info.administrator { return Redirect::to("/").into_response(); } if input.code.is_none() || input.comment.is_none() { return adminerror("code and comment required".to_string()).into_response(); } if input.code.as_ref().unwrap().is_empty() || input.comment.as_ref().unwrap().is_empty() { return adminerror("code and comment required".to_string()).into_response(); } match gen_invite_code(nats.clone(), token, input.code.unwrap(), input.comment.unwrap()).await { Ok(_) => { Redirect::to("/admin/invitecode").into_response() } Err(e) => { e.into_response() } } } else { Redirect::to("/").into_response() } } #[derive(Template)] #[template(path = "admin/announcement.html")] pub struct AdminAnnouncementTemplate { motd: &'static str, error: Option, username: String, slug: String, title: String, short_content: String, full_content: String, } pub async fn admin_announcement( jar: CookieJar, Extension(nats): Extension>, Extension(opts): Extension, ) -> impl IntoResponse { if let Some(token) = jar.get("token") { let token = token.value().to_string(); let info = match authenticate_user(nats.clone(), token.clone()).await { Ok(i) => i, Err(e) => { return Redirect::to("/").into_response(); } }; if !info.administrator { return Redirect::to("/").into_response(); } AdminAnnouncementTemplate { motd: motd(), error: None, username: info.username, slug: "the-post-like-this".to_string(), title: "Changes Happened!".to_string(), short_content: "we made some changes!".to_string(), full_content: "we made some changes! here are the details!".to_string(), }.into_response() } else { Redirect::to("/").into_response() } } #[derive(Deserialize, Debug, Clone)] pub struct AnnouncementForm { slug: Option, title: Option, short_content: Option, full_content: Option, send_emails: Option, } pub async fn admin_announcement_post( jar: CookieJar, Extension(nats): Extension>, Extension(opts): Extension, Form(input): Form, ) -> impl IntoResponse { fn announcement_error(error: &str, username: String, form: AnnouncementForm) -> impl IntoResponse { AdminAnnouncementTemplate { motd: motd(), error: Some(error.to_string()), username, slug: form.slug.unwrap_or_default(), title: form.title.unwrap_or_default(), short_content: form.short_content.unwrap_or_default(), full_content: form.full_content.unwrap_or_default(), } } if let Some(token) = jar.get("token") { let token = token.value().to_string(); let info = match authenticate_user(nats.clone(), token.clone()).await { Ok(i) => i, Err(e) => { return Redirect::to("/").into_response(); } }; if !info.administrator { return Redirect::to("/").into_response(); } if input.slug.is_none() || input.slug.as_ref().unwrap().is_empty() { return announcement_error("slug required", info.username, input).into_response(); } if input.title.is_none() || input.title.as_ref().unwrap().is_empty() { return announcement_error("title required", info.username, input).into_response(); } if input.short_content.is_none() || input.short_content.as_ref().unwrap().is_empty() { return announcement_error("short content required", info.username, input).into_response(); } if input.full_content.is_none() || input.full_content.as_ref().unwrap().is_empty() { return announcement_error("full content required", info.username, input).into_response(); } debug!("sendemails: {:?}", input.send_emails); let input_og = input.clone(); let response = comms::query_service(comms::Query::AuthService(AuthServiceQuery { request: AuthServiceRequest::AdminCreateAnnouncementRequest(AdminCreateAnnouncementRequest { token: token.clone(), title: input.title.unwrap(), slug: input.slug.unwrap(), short_text: input.short_content.unwrap(), full_markdown_text: input.full_content.unwrap(), send_emails: !input.send_emails.unwrap_or("".to_string()).is_empty(), }), replyto: "".to_string(), }), &nats, false).await; if let Err(e) = response { error!("nats error: {:?}", e); announcement_error(&format!("nats error: {:?}", e), info.username, input_og).into_response() } else { let response = response.unwrap(); match response { ServiceResponse::SearchService(_) => { error!("sent search service response when asking for auth service!! investigate ASAP!!!"); announcement_error("auth service error", info.username, input_og).into_response() } ServiceResponse::BingService(_) => { error!("sent bing service response when asking for auth service!! investigate ASAP!!!"); announcement_error("auth service error", info.username, input_og).into_response() } ServiceResponse::AuthService(r) => match r { AuthServiceResponse::AdminCreateAnnouncementResponse(r) => { match r { AdminCreateAnnouncementResponse::Success => { AdminAnnouncementTemplate { motd: motd(), error: Some("success!".to_string()), username: info.username, slug: "".to_string(), title: "".to_string(), short_content: "".to_string(), full_content: "".to_string(), }.into_response() } AdminCreateAnnouncementResponse::SlugTaken => { announcement_error("slug taken!", info.username, input_og).into_response() } AdminCreateAnnouncementResponse::InternalServerError(e) => { announcement_error(&format!("internal server error: {}", e), info.username, input_og).into_response() } AdminCreateAnnouncementResponse::Logout => { Redirect::to("/").into_response() } AdminCreateAnnouncementResponse::AccessDenied => { Redirect::to("/").into_response() } } } x => { error!("auth survace gave {} to our create announcement request!", x); announcement_error(&format!("auth service gave {x} to our create announcement request!"), info.username, input_og).into_response() } } } } } else { Redirect::to("/").into_response() } } #[derive(Template)] #[template(path = "admin/allusers.html")] pub struct AdminUserListTemplate { motd: &'static str, users: Vec } pub async fn admin_user_list( jar: CookieJar, Extension(nats): Extension>, Extension(opts): Extension, ) -> impl IntoResponse { if let Some(token) = jar.get("token") { let token = token.value().to_string(); let info = match authenticate_user(nats.clone(), token.clone()).await { Ok(i) => i, Err(e) => { return Redirect::to("/").into_response(); } }; if !info.administrator { return Redirect::to("/").into_response(); } match list_all_users(nats.clone(), token).await { Ok(mut v) => { for v in &mut v { if v.username.len() > 32 { v.username = format!("{}...", &v.username[0..32]); } } AdminUserListTemplate { motd: motd(), users: v, }.into_response() } Err(e) => { e.into_response() } } } else { Redirect::to("/").into_response() } }