//! Admin dashboard for Kittybox and its related API routes. // This needs to be rewritten to have proper authentication. // // The cookies will be shared with the login system, and should check // if the login is the same as the website owner. If it is, the admin // dashboard and API should be available. // // Alternatively, the API could check for an access token with _some // kind_ of privileged scope, like `kittybox:admin` (namespaced to // prevent collisions with future well-known scopes). use std::collections::HashSet; use axum::extract::{State, Host}; use axum::response::{Response, IntoResponse}; use axum::Form; use axum_extra::extract::CookieJar; use hyper::StatusCode; use crate::database::settings::{SiteName, Setting}; use crate::database::{Storage, StorageError}; use crate::indieauth::backend::AuthBackend; #[derive(serde::Deserialize)] struct NameChange { name: String } #[derive(serde::Deserialize)] struct PasswordChange { old_password: String, new_password: String } static SESSION_STORE: std::sync::LazyLock<tokio::sync::RwLock<HashSet<uuid::Uuid>>> = std::sync::LazyLock::new(|| Default::default()); async fn set_name<D: Storage + 'static>( Host(host): Host, State(db): State<D>, Form(NameChange { name }): Form<NameChange> ) -> Result<(), StorageError> { db.set_setting::<SiteName>(&host, name).await } async fn get_name<D: Storage + 'static>(Host(host): Host, State(db): State<D>) -> Result<String, StorageError> { db.get_setting::<SiteName>(&host).await.map(|name| name.as_ref().to_owned()) } async fn change_password<A: AuthBackend>( Host(host): Host, State(auth): State<A>, Form(PasswordChange { old_password, new_password }): Form<PasswordChange> ) -> StatusCode { let website = url::Url::parse(&format!("https://{host}/")).unwrap(); if auth.verify_password(&website, old_password).await.is_ok() { if let Err(err) = auth.enroll_password(&website, new_password).await { tracing::error!("Error changing password: {}", err); StatusCode::INTERNAL_SERVER_ERROR } else { StatusCode::OK } } else { StatusCode::BAD_REQUEST } } impl axum::response::IntoResponse for StorageError { fn into_response(self) -> axum::response::Response { let code = match self.kind() { crate::database::ErrorKind::Backend => StatusCode::INTERNAL_SERVER_ERROR, crate::database::ErrorKind::PermissionDenied => StatusCode::FORBIDDEN, crate::database::ErrorKind::JsonParsing => StatusCode::BAD_REQUEST, crate::database::ErrorKind::NotFound => StatusCode::NOT_FOUND, crate::database::ErrorKind::BadRequest => StatusCode::BAD_REQUEST, crate::database::ErrorKind::Conflict => StatusCode::CONFLICT, crate::database::ErrorKind::Other => StatusCode::INTERNAL_SERVER_ERROR, }; (code, self.to_string()).into_response() } } async fn dashboard<D: Storage + 'static, A: AuthBackend>( Host(host): Host, cookies: CookieJar, State(db): State<D>, State(auth): State<A> ) -> axum::response::Response { let page = kittybox_frontend_renderer::admin::AdminHome {}; (page.to_string().into_response()) } pub fn router<St, A, S, M>() -> axum::Router<St> where A: AuthBackend + FromRef<St> + 'static, S: Storage + FromRef<St> + 'static, M: MediaStore + FromRef<St> + 'static, Q: crate::webmentions::JobQueue<crate::webmentions::Webmention> + FromRef<St> + 'static, axum_extra::extract::cookie::Key: FromRef<St> { axum::Router::new() .nest("/.kittybox/admin", axum::Router::new() // routes go here .route( "/", axum::routing::get(dashboard::<S, A>) ) .route( "/api/settings/name", axum::routing::post(set_name::<S>) .get(get_name::<S>) ) .route("/api/settings/password", axum::routing::post(change_password::<A>)) ) }