use actix_session::Session; use actix_web::{HttpResponse, web}; use serde::Deserialize; use shanty_db::entities::user::UserRole; use shanty_db::queries; use crate::auth; use crate::error::ApiError; use crate::state::AppState; pub fn configure(cfg: &mut web::ServiceConfig) { cfg.service(web::resource("/auth/setup-required").route(web::get().to(setup_required))) .service(web::resource("/auth/setup").route(web::post().to(setup))) .service(web::resource("/auth/login").route(web::post().to(login))) .service(web::resource("/auth/logout").route(web::post().to(logout))) .service(web::resource("/auth/me").route(web::get().to(me))) .service( web::resource("/auth/users") .route(web::get().to(list_users)) .route(web::post().to(create_user)), ) .service(web::resource("/auth/users/{id}").route(web::delete().to(delete_user))) .service( web::resource("/auth/subsonic-password").route(web::put().to(set_subsonic_password)), ) .service( web::resource("/auth/subsonic-password-status") .route(web::get().to(subsonic_password_status)), ); } #[derive(Deserialize)] struct SetupRequest { username: String, password: String, } #[derive(Deserialize)] struct LoginRequest { username: String, password: String, } #[derive(Deserialize)] struct CreateUserRequest { username: String, password: String, } #[derive(Deserialize)] struct SubsonicPasswordRequest { password: String, } /// Check if initial setup is required (no users in database). async fn setup_required(state: web::Data) -> Result { let count = queries::users::count(state.db.conn()).await?; Ok(HttpResponse::Ok().json(serde_json::json!({ "required": count == 0 }))) } /// Create the first admin user. Only works when no users exist. async fn setup( state: web::Data, session: Session, body: web::Json, ) -> Result { let count = queries::users::count(state.db.conn()).await?; if count > 0 { return Err(ApiError::BadRequest("setup already completed".into())); } if body.username.trim().is_empty() || body.password.len() < 4 { return Err(ApiError::BadRequest( "username required, password must be at least 4 characters".into(), )); } let password_hash = auth::hash_password(&body.password)?; let user = queries::users::create( state.db.conn(), body.username.trim(), &password_hash, UserRole::Admin, ) .await?; // Adopt any orphaned wanted items from before auth was added let adopted = queries::users::adopt_orphaned_wanted_items(state.db.conn(), user.id).await?; if adopted > 0 { tracing::info!( count = adopted, user_id = user.id, "adopted orphaned wanted items" ); } auth::set_session(&session, user.id, &user.username, "admin"); tracing::info!(username = %user.username, "admin user created via setup"); Ok(HttpResponse::Ok().json(serde_json::json!({ "id": user.id, "username": user.username, "role": "admin", }))) } /// Log in with username and password. async fn login( state: web::Data, session: Session, body: web::Json, ) -> Result { let user = queries::users::find_by_username(state.db.conn(), &body.username) .await? .ok_or_else(|| ApiError::Unauthorized("invalid username or password".into()))?; if !auth::verify_password(&body.password, &user.password_hash) { return Err(ApiError::Unauthorized( "invalid username or password".into(), )); } let role = match user.role { UserRole::Admin => "admin", UserRole::User => "user", }; auth::set_session(&session, user.id, &user.username, role); tracing::info!(username = %user.username, "user logged in"); Ok(HttpResponse::Ok().json(serde_json::json!({ "id": user.id, "username": user.username, "role": role, }))) } /// Log out (clear session). async fn logout(session: Session) -> Result { session.purge(); Ok(HttpResponse::Ok().json(serde_json::json!({ "status": "logged out" }))) } /// Get current user info from session. async fn me(session: Session) -> Result { let (user_id, username, role) = auth::require_auth(&session)?; Ok(HttpResponse::Ok().json(serde_json::json!({ "id": user_id, "username": username, "role": role, }))) } /// List all users (admin only). async fn list_users( state: web::Data, session: Session, ) -> Result { auth::require_admin(&session)?; let users = queries::users::list(state.db.conn()).await?; Ok(HttpResponse::Ok().json(users)) } /// Create a new user (admin only). async fn create_user( state: web::Data, session: Session, body: web::Json, ) -> Result { auth::require_admin(&session)?; if body.username.trim().is_empty() || body.password.len() < 4 { return Err(ApiError::BadRequest( "username required, password must be at least 4 characters".into(), )); } if queries::users::find_by_username(state.db.conn(), body.username.trim()) .await? .is_some() { return Err(ApiError::BadRequest("username already taken".into())); } let password_hash = auth::hash_password(&body.password)?; let user = queries::users::create( state.db.conn(), body.username.trim(), &password_hash, UserRole::User, ) .await?; tracing::info!(username = %user.username, "user created by admin"); Ok(HttpResponse::Ok().json(serde_json::json!({ "id": user.id, "username": user.username, "role": "user", }))) } /// Delete a user (admin only). async fn delete_user( state: web::Data, session: Session, path: web::Path, ) -> Result { let (admin_id, _, _) = auth::require_admin(&session)?; let user_id = path.into_inner(); if user_id == admin_id { return Err(ApiError::BadRequest("cannot delete yourself".into())); } queries::users::delete(state.db.conn(), user_id).await?; tracing::info!(user_id = user_id, "user deleted by admin"); Ok(HttpResponse::NoContent().finish()) } /// Set the Subsonic password for the current user. async fn set_subsonic_password( state: web::Data, session: Session, body: web::Json, ) -> Result { let (user_id, _, _) = auth::require_auth(&session)?; if body.password.len() < 4 { return Err(ApiError::BadRequest( "password must be at least 4 characters".into(), )); } queries::users::set_subsonic_password(state.db.conn(), user_id, &body.password).await?; tracing::info!(user_id = user_id, "subsonic password set"); Ok(HttpResponse::Ok().json(serde_json::json!({ "status": "ok" }))) } /// Check whether the current user has a Subsonic password set. async fn subsonic_password_status( state: web::Data, session: Session, ) -> Result { let (user_id, _, _) = auth::require_auth(&session)?; let user = queries::users::get_by_id(state.db.conn(), user_id).await?; Ok(HttpResponse::Ok().json(serde_json::json!({ "set": user.subsonic_password.is_some(), }))) }