From 16e0611a883de33d7bd7d590822df2bc7c8a78ad Mon Sep 17 00:00:00 2001 From: Per Stark Date: Wed, 22 Jan 2025 12:30:52 +0100 Subject: [PATCH] feat: admin status, first user is admin, site settings --- src/bin/server.rs | 28 ++++---------- src/server/routes/html/admin_panel.rs | 52 +++++++++++++++++++++++++ src/server/routes/html/mod.rs | 1 + src/server/routes/html/signup.rs | 5 ++- src/storage/types/analytics.rs | 50 ++++++++++++++++++++++++ src/storage/types/mod.rs | 4 +- src/storage/types/system_settings.rs | 56 +++++++++++++++++++++++++++ src/storage/types/user.rs | 25 +++++++----- templates/auth/account_settings.html | 2 +- templates/auth/admin_panel.html | 13 +++++++ templates/body_base.html | 2 +- templates/errors/error.html | 7 ++-- 12 files changed, 207 insertions(+), 38 deletions(-) create mode 100644 src/server/routes/html/admin_panel.rs create mode 100644 src/storage/types/analytics.rs create mode 100644 src/storage/types/system_settings.rs create mode 100644 templates/auth/admin_panel.html diff --git a/src/bin/server.rs b/src/bin/server.rs index 28b3d43..162ce6d 100644 --- a/src/bin/server.rs +++ b/src/bin/server.rs @@ -27,6 +27,7 @@ use zettle_db::{ }, html::{ account::{delete_account, set_api_key, show_account_page}, + admin_panel::show_admin_panel, documentation::index::show_documentation_index, gdpr::{accept_gdpr, deny_gdpr}, index::index_handler, @@ -41,7 +42,10 @@ use zettle_db::{ }, AppState, }, - storage::{db::SurrealDbClient, types::user::User}, + storage::{ + db::SurrealDbClient, + types::{analytics::Analytics, system_settings::SystemSettings, user::User}, + }, utils::{config::get_config, mailer::Mailer}, }; @@ -106,25 +110,8 @@ async fn main() -> Result<(), Box> { app_state.surreal_db_client.build_indexes().await?; setup_auth(&app_state.surreal_db_client).await?; - // app_state.surreal_db_client.drop_table::().await?; - // app_state.surreal_db_client.drop_table::().await?; - // app_state.surreal_db_client.drop_table::().await?; - // app_state - // .surreal_db_client - // .drop_table::() - // .await?; - // app_state - // .surreal_db_client - // .drop_table::() - // .await?; - // app_state - // .surreal_db_client - // .drop_table::() - // .await?; - // app_state - // .surreal_db_client - // .drop_table::() - // .await?; + Analytics::ensure_initialized(&app_state.surreal_db_client).await?; + SystemSettings::ensure_initialized(&app_state.surreal_db_client).await?; // Create Axum router let app = Router::new() @@ -181,6 +168,7 @@ fn html_routes( .route("/queue", get(show_queue_tasks)) .route("/queue/:delivery_tag", delete(delete_task)) .route("/account", get(show_account_page)) + .route("/admin", get(show_admin_panel)) .route("/set-api-key", post(set_api_key)) .route("/delete-account", delete(delete_account)) .route( diff --git a/src/server/routes/html/admin_panel.rs b/src/server/routes/html/admin_panel.rs new file mode 100644 index 0000000..e4a4475 --- /dev/null +++ b/src/server/routes/html/admin_panel.rs @@ -0,0 +1,52 @@ +use axum::{ + extract::State, + http::{StatusCode, Uri}, + response::{IntoResponse, Redirect}, +}; +use axum_htmx::HxRedirect; +use axum_session_auth::AuthSession; +use axum_session_surreal::SessionSurrealPool; +use surrealdb::{engine::any::Any, Surreal}; + +use crate::{ + error::{AppError, HtmlError}, + page_data, + server::{routes::html::render_template, AppState}, + storage::types::{analytics::Analytics, system_settings::SystemSettings, user::User}, +}; + +page_data!(AdminPanelData, "auth/admin_panel.html", { + user: User, + settings: SystemSettings, + analytics: Analytics +}); + +pub async fn show_admin_panel( + State(state): State, + auth: AuthSession, Surreal>, +) -> Result { + // Early return if the user is not authenticated + let user = match auth.current_user { + Some(user) => user, + None => return Ok(Redirect::to("/").into_response()), + }; + + let settings = SystemSettings::get_current(&state.surreal_db_client) + .await + .map_err(|e| HtmlError::new(e, state.templates.clone()))?; + let analytics = Analytics::get_current(&state.surreal_db_client) + .await + .map_err(|e| HtmlError::new(e, state.templates.clone()))?; + + let output = render_template( + AdminPanelData::template_name(), + AdminPanelData { + user, + settings, + analytics, + }, + state.templates.clone(), + )?; + + Ok(output.into_response()) +} diff --git a/src/server/routes/html/mod.rs b/src/server/routes/html/mod.rs index 840f1cf..bd5b1a3 100644 --- a/src/server/routes/html/mod.rs +++ b/src/server/routes/html/mod.rs @@ -6,6 +6,7 @@ use minijinja_autoreload::AutoReloader; use crate::error::{HtmlError, IntoHtmlError}; pub mod account; +pub mod admin_panel; pub mod documentation; pub mod gdpr; pub mod index; diff --git a/src/server/routes/html/signup.rs b/src/server/routes/html/signup.rs index c731670..4066200 100644 --- a/src/server/routes/html/signup.rs +++ b/src/server/routes/html/signup.rs @@ -7,6 +7,7 @@ use axum::{ use axum_htmx::{HxBoosted, HxRedirect}; use axum_session_auth::AuthSession; use axum_session_surreal::SessionSurrealPool; +use chrono::RoundingError; use serde::{Deserialize, Serialize}; use surrealdb::{engine::any::Any, Surreal}; use tracing::info; @@ -59,8 +60,8 @@ pub async fn process_signup_and_show_verification( let user = match User::create_new(form.email, form.password, &state.surreal_db_client).await { Ok(user) => user, Err(e) => { - info!("{:?}", e); - return Ok(Html("

User already exists

").into_response()); + tracing::error!("{:?}", e); + return Ok(Html(format!("

{}

", e)).into_response()); } }; diff --git a/src/storage/types/analytics.rs b/src/storage/types/analytics.rs new file mode 100644 index 0000000..0c0c4f5 --- /dev/null +++ b/src/storage/types/analytics.rs @@ -0,0 +1,50 @@ +use crate::storage::types::file_info::deserialize_flexible_id; +use serde::{Deserialize, Serialize}; + +use crate::{error::AppError, storage::db::SurrealDbClient}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct Analytics { + #[serde(deserialize_with = "deserialize_flexible_id")] + pub id: String, + pub visitors: i64, +} + +impl Analytics { + pub async fn ensure_initialized(db: &SurrealDbClient) -> Result { + let analytics = db.select(("analytics", "current")).await?; + + if analytics.is_none() { + let created: Option = db + .create(("analytics", "current")) + .content(Analytics { + id: "current".to_string(), + visitors: 0, + }) + .await?; + + return created.ok_or(AppError::Validation("Failed to initialize settings".into())); + }; + + analytics.ok_or(AppError::Validation("Failed to initialize settings".into())) + } + pub async fn get_current(db: &SurrealDbClient) -> Result { + let analytics: Option = db + .client + .query("SELECT * FROM type::thing('analytics', 'current')") + .await? + .take(0)?; + + analytics.ok_or(AppError::NotFound("Analytics not found".into())) + } + + pub async fn increment_visitors(db: &SurrealDbClient) -> Result { + let updated: Option = db + .client + .query("UPDATE type::thing('analytics', 'current') SET visitors += 1 RETURN AFTER") + .await? + .take(0)?; + + updated.ok_or(AppError::Validation("Failed to update analytics".into())) + } +} diff --git a/src/storage/types/mod.rs b/src/storage/types/mod.rs index d15dcca..8793430 100644 --- a/src/storage/types/mod.rs +++ b/src/storage/types/mod.rs @@ -1,9 +1,11 @@ use axum::async_trait; use serde::{Deserialize, Serialize}; +pub mod analytics; pub mod file_info; pub mod job; pub mod knowledge_entity; pub mod knowledge_relationship; +pub mod system_settings; pub mod text_chunk; pub mod text_content; pub mod user; @@ -58,7 +60,7 @@ macro_rules! stored_object { } } - fn deserialize_flexible_id<'de, D>(deserializer: D) -> Result + pub fn deserialize_flexible_id<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, { diff --git a/src/storage/types/system_settings.rs b/src/storage/types/system_settings.rs new file mode 100644 index 0000000..724a05f --- /dev/null +++ b/src/storage/types/system_settings.rs @@ -0,0 +1,56 @@ +use crate::storage::types::file_info::deserialize_flexible_id; +use serde::{Deserialize, Serialize}; + +use crate::{error::AppError, storage::db::SurrealDbClient}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct SystemSettings { + #[serde(deserialize_with = "deserialize_flexible_id")] + pub id: String, + pub registrations_enabled: bool, + pub require_email_verification: bool, +} + +impl SystemSettings { + pub async fn ensure_initialized(db: &SurrealDbClient) -> Result { + let settings = db.select(("system_settings", "current")).await?; + + if settings.is_none() { + let created: Option = db + .create(("system_settings", "current")) + .content(SystemSettings { + id: "current".to_string(), + registrations_enabled: true, + require_email_verification: false, + }) + .await?; + + return created.ok_or(AppError::Validation("Failed to initialize settings".into())); + }; + + settings.ok_or(AppError::Validation("Failed to initialize settings".into())) + } + + pub async fn get_current(db: &SurrealDbClient) -> Result { + let settings: Option = db + .client + .query("SELECT * FROM type::thing('system_settings', 'current')") + .await? + .take(0)?; + + settings.ok_or(AppError::NotFound("System settings not found".into())) + } + + pub async fn update(db: &SurrealDbClient, changes: Self) -> Result { + let updated: Option = db + .client + .query("UPDATE type::thing('system_settings', 'current') MERGE $changes RETURN AFTER") + .bind(("changes", changes)) + .await? + .take(0)?; + + updated.ok_or(AppError::Validation( + "Something went wrong updating the settings".into(), + )) + } +} diff --git a/src/storage/types/user.rs b/src/storage/types/user.rs index 5239ef1..e88e493 100644 --- a/src/storage/types/user.rs +++ b/src/storage/types/user.rs @@ -7,13 +7,16 @@ use axum_session_auth::Authentication; use surrealdb::{engine::any::Any, Surreal}; use uuid::Uuid; -use super::{knowledge_entity::KnowledgeEntity, text_content::TextContent}; +use super::{ + knowledge_entity::KnowledgeEntity, system_settings::SystemSettings, text_content::TextContent, +}; stored_object!(User, "user", { email: String, password: String, anonymous: bool, - api_key: Option + api_key: Option, + admin: bool }); #[async_trait] @@ -42,31 +45,35 @@ impl User { password: String, db: &SurrealDbClient, ) -> Result { - // Check if user exists - if (Self::find_by_email(&email, db).await?).is_some() { - return Err(AppError::Auth("User already exists".into())); + // verify that the application allows new creations + let systemsettings = SystemSettings::get_current(db).await?; + if !systemsettings.registrations_enabled { + return Err(AppError::Auth("Registration is not allowed".into())); } let now = Utc::now(); - let id = Uuid::new_v4().to_string(); + let user: Option = db .client .query( - "CREATE type::thing('user', $id) SET - email = $email, + "LET $count = (SELECT count() FROM type::table($table))[0].count; + CREATE type::thing('user', $id) SET + email = $email, password = crypto::argon2::generate($password), + admin = $count < 1, // Changed from == 0 to < 1 anonymous = false, created_at = $created_at, updated_at = $updated_at", ) + .bind(("table", "user")) .bind(("id", id)) .bind(("email", email)) .bind(("password", password)) .bind(("created_at", now)) .bind(("updated_at", now)) .await? - .take(0)?; + .take(1)?; user.ok_or(AppError::Auth("User failed to create".into())) } diff --git a/templates/auth/account_settings.html b/templates/auth/account_settings.html index 75d89e0..e2a2b43 100644 --- a/templates/auth/account_settings.html +++ b/templates/auth/account_settings.html @@ -5,7 +5,7 @@ opacity: 0.5; } -
+

Account Settings

{% endblock %} \ No newline at end of file