/*
* 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()
}
}