From 89c19e08d9d023476a170b67931972d77aa41652 Mon Sep 17 00:00:00 2001 From: Greg Shuflin Date: Sun, 2 Feb 2025 20:44:00 -0800 Subject: [PATCH] Setup page --- migrations/20240320000004_add_admin_field.sql | 2 + src/main.rs | 26 ++++- src/user.rs | 96 ++++++++++++++++++- static/css/style.css | 56 +++++++++++ templates/setup.html.tera | 72 ++++++++++++++ 5 files changed, 247 insertions(+), 5 deletions(-) create mode 100644 migrations/20240320000004_add_admin_field.sql create mode 100644 templates/setup.html.tera diff --git a/migrations/20240320000004_add_admin_field.sql b/migrations/20240320000004_add_admin_field.sql new file mode 100644 index 0000000..f7d05fd --- /dev/null +++ b/migrations/20240320000004_add_admin_field.sql @@ -0,0 +1,2 @@ +-- Add admin field to users table +ALTER TABLE users ADD COLUMN admin BOOLEAN NOT NULL DEFAULT FALSE; \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 61b3dbe..d1fdd9f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,7 +10,7 @@ mod user; use rocket::fs::FileServer; use rocket::response::Redirect; -use rocket_db_pools::{sqlx, Database}; +use rocket_db_pools::{sqlx, Database, Connection}; use rocket_dyn_templates::{context, Template}; use user::AuthenticatedUser; @@ -33,8 +33,20 @@ fn index(_user: AuthenticatedUser) -> Template { } #[get("/", rank = 2)] -fn index_redirect() -> Redirect { - Redirect::to(uri!(login)) +async fn index_redirect(mut db: Connection) -> Redirect { + // Check if any users exist + let count = sqlx::query!("SELECT COUNT(*) as count FROM users") + .fetch_one(&mut **db) + .await + .map_err(|_| Redirect::to(uri!(login))) + .unwrap() + .count; + + if count == 0 { + Redirect::to(uri!(user::setup_page)) + } else { + Redirect::to(uri!(login)) + } } #[get("/login")] @@ -48,6 +60,12 @@ fn rocket() -> _ { let db_url = format!("sqlite:{}", args.database); + // Check if database file exists, create it if it doesn't + if !std::path::Path::new(&args.database).exists() { + use std::fs::File; + File::create(&args.database).expect("Failed to create database file"); + } + // Run migrations before starting the server let rt = tokio::runtime::Runtime::new().unwrap(); rt.block_on(async { @@ -75,6 +93,8 @@ fn rocket() -> _ { user::delete_user, user::login, user::logout, + user::setup_page, + user::setup, feeds::create_feed, feeds::get_feed, feeds::list_feeds, diff --git a/src/user.rs b/src/user.rs index 8b8a8d6..85b0f44 100644 --- a/src/user.rs +++ b/src/user.rs @@ -1,6 +1,7 @@ use rocket::http::{Cookie, CookieJar, Status}; use rocket::serde::{json::Json, Deserialize, Serialize}; use rocket_db_pools::Connection; +use rocket_dyn_templates::{context, Template}; use uuid::Uuid; use crate::Db; @@ -14,6 +15,7 @@ pub struct User { pub email: Option, pub display_name: Option, pub created_at: chrono::DateTime, + pub admin: bool, } impl User { @@ -30,6 +32,7 @@ impl User { email, display_name, created_at: chrono::Utc::now(), + admin: false, } } } @@ -57,6 +60,12 @@ pub struct LoginResponse { username: String, } +#[derive(Debug, Serialize)] +#[serde(crate = "rocket::serde")] +pub struct SetupError { + error: String, +} + #[post("/users", data = "")] pub async fn create_user( mut db: Connection, @@ -76,7 +85,7 @@ pub async fn create_user( ); let query = sqlx::query( - "INSERT INTO users (id, username, password_hash, email, display_name, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)" + "INSERT INTO users (id, username, password_hash, email, display_name, created_at, admin) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)" ) .bind(user.id.to_string()) .bind(user.username.as_str()) @@ -84,6 +93,7 @@ pub async fn create_user( .bind(user.email.as_ref()) .bind(user.display_name.as_ref()) .bind(user.created_at.to_rfc3339()) + .bind(false) .execute(&mut **db).await; match query { @@ -110,7 +120,8 @@ pub async fn get_users(mut db: Connection) -> Result>, Status password_hash, email, display_name, - created_at as "created_at: chrono::DateTime" + created_at as "created_at: chrono::DateTime", + admin as "admin: bool" FROM users "# ) @@ -131,6 +142,7 @@ pub async fn get_users(mut db: Connection) -> Result>, Status email: row.email, display_name: row.display_name, created_at: row.created_at, + admin: row.admin, }) .collect::>(); @@ -247,3 +259,83 @@ impl<'r> rocket::request::FromRequest<'r> for AuthenticatedUser { } } } + +#[get("/setup")] +pub async fn setup_page(mut db: Connection) -> Result { + // Check if any users exist + let count = sqlx::query!("SELECT COUNT(*) as count FROM users") + .fetch_one(&mut **db) + .await + .map_err(|_| Status::InternalServerError)? + .count; + + if count > 0 { + // If users exist, redirect to login + Err(Status::SeeOther) + } else { + // Show setup page + Ok(Template::render("setup", context! {})) + } +} + +#[post("/setup", data = "")] +pub async fn setup(mut db: Connection, new_user: Json) -> Result> { + // Check if any users exist + let count = sqlx::query!("SELECT COUNT(*) as count FROM users") + .fetch_one(&mut **db) + .await + .map_err(|e| { + eprintln!("Database error: {}", e); + Json(SetupError { + error: "Internal server error".to_string(), + }) + })? + .count; + + if count > 0 { + return Err(Json(SetupError { + error: "Setup has already been completed".to_string(), + })); + } + + // Hash the password + let password_hash = bcrypt::hash(new_user.password.as_bytes(), bcrypt::DEFAULT_COST).map_err(|e| { + eprintln!("Password hashing error: {}", e); + Json(SetupError { + error: "Failed to process password".to_string(), + }) + })?; + + // Create admin user + let user_id = Uuid::new_v4().to_string(); + let now = chrono::Utc::now().to_rfc3339(); + + let result = sqlx::query( + "INSERT INTO users (id, username, password_hash, email, display_name, created_at, admin) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + ) + .bind(&user_id) + .bind(&new_user.username) + .bind(&password_hash) + .bind(&new_user.email) + .bind(&new_user.display_name) + .bind(&now) + .bind(true) // This is an admin user + .execute(&mut **db) + .await; + + match result { + Ok(_) => Ok(Status::Created), + Err(e) => { + eprintln!("Database error: {}", e); + match e { + sqlx::Error::Database(db_err) if db_err.is_unique_violation() => Err(Json(SetupError { + error: "Username already exists".to_string(), + })), + _ => Err(Json(SetupError { + error: "Failed to create user".to_string(), + })), + } + } + } +} diff --git a/static/css/style.css b/static/css/style.css index bf0c83c..b7f0f5e 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -384,4 +384,60 @@ button:disabled { padding: 1rem; background-color: rgba(244, 63, 63, 0.1); border-radius: 4px; +} + +.setup-container { + max-width: 500px; + margin: 50px auto; + padding: 2rem; + background-color: var(--sidebar-bg); + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +.setup-container h1 { + text-align: center; + margin-bottom: 1rem; + color: var(--text-color); +} + +.setup-container p { + text-align: center; + margin-bottom: 2rem; + color: var(--text-muted); +} + +.setup-form { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.setup-form .form-group { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.setup-form label { + color: var(--text-color); + font-weight: 500; +} + +.setup-form input { + padding: 0.5rem; + border: 1px solid var(--border-color); + border-radius: 4px; + background-color: var(--input-bg); + color: var(--text-color); +} + +.setup-form input:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 2px rgba(var(--primary-color-rgb), 0.2); +} + +.setup-form button { + margin-top: 1rem; } diff --git a/templates/setup.html.tera b/templates/setup.html.tera new file mode 100644 index 0000000..4f04858 --- /dev/null +++ b/templates/setup.html.tera @@ -0,0 +1,72 @@ + + + + + + Initial Setup - RSS Reader + + + +
+

Welcome to RSS Reader

+

Please create your initial admin user account.

+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+
+ + + + \ No newline at end of file