From 421ec3199b4f8e4e5af57606940862d458b0dfe5 Mon Sep 17 00:00:00 2001 From: Connor Johnstone Date: Thu, 19 Mar 2026 14:02:33 -0400 Subject: [PATCH] Added auth --- Cargo.toml | 3 + frontend/src/api.rs | 43 +++++++ frontend/src/components/navbar.rs | 25 +++- frontend/src/main.rs | 84 +++++++++++-- frontend/src/pages/login.rs | 71 +++++++++++ frontend/src/pages/mod.rs | 2 + frontend/src/pages/setup.rs | 92 ++++++++++++++ frontend/src/types.rs | 14 +++ frontend/style.css | 31 +++++ src/auth.rs | 62 +++++++++ src/error.rs | 8 ++ src/lib.rs | 1 + src/main.rs | 9 +- src/routes/albums.rs | 13 +- src/routes/artists.rs | 19 ++- src/routes/auth.rs | 203 ++++++++++++++++++++++++++++++ src/routes/downloads.rs | 16 ++- src/routes/mod.rs | 2 + src/routes/search.rs | 10 ++ src/routes/system.rs | 31 +++-- src/routes/tracks.rs | 6 + 21 files changed, 719 insertions(+), 26 deletions(-) create mode 100644 frontend/src/pages/login.rs create mode 100644 frontend/src/pages/setup.rs create mode 100644 src/auth.rs create mode 100644 src/routes/auth.rs diff --git a/Cargo.toml b/Cargo.toml index 3fa297d..fdbe97f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,9 @@ sea-orm = { version = "1", features = ["sqlx-sqlite", "runtime-tokio-native-tls" actix-web = "4" actix-cors = "0.7" actix-files = "0.6" +actix-session = { version = "0.10", features = ["cookie-session"] } +argon2 = "0.5" +rand = "0.9" thiserror = "2" anyhow = "1" reqwest = { version = "0.12", features = ["json"] } diff --git a/frontend/src/api.rs b/frontend/src/api.rs index fb50675..8c83c24 100644 --- a/frontend/src/api.rs +++ b/frontend/src/api.rs @@ -55,6 +55,49 @@ async fn delete(url: &str) -> Result<(), ApiError> { Ok(()) } +// --- Auth --- +pub async fn check_setup_required() -> Result { + get_json(&format!("{BASE}/auth/setup-required")).await +} + +pub async fn setup(username: &str, password: &str) -> Result { + let body = serde_json::json!({"username": username, "password": password}).to_string(); + post_json(&format!("{BASE}/auth/setup"), &body).await +} + +pub async fn login(username: &str, password: &str) -> Result { + let body = serde_json::json!({"username": username, "password": password}).to_string(); + post_json(&format!("{BASE}/auth/login"), &body).await +} + +pub async fn logout() -> Result<(), ApiError> { + let resp = Request::post(&format!("{BASE}/auth/logout")) + .send() + .await + .map_err(|e| ApiError(e.to_string()))?; + if !resp.ok() { + return Err(ApiError(format!("HTTP {}", resp.status()))); + } + Ok(()) +} + +pub async fn get_me() -> Result { + get_json(&format!("{BASE}/auth/me")).await +} + +pub async fn list_users() -> Result, ApiError> { + get_json(&format!("{BASE}/auth/users")).await +} + +pub async fn create_user(username: &str, password: &str) -> Result { + let body = serde_json::json!({"username": username, "password": password}).to_string(); + post_json(&format!("{BASE}/auth/users"), &body).await +} + +pub async fn delete_user(id: i32) -> Result<(), ApiError> { + delete(&format!("{BASE}/auth/users/{id}")).await +} + // --- Status --- pub async fn get_status() -> Result { get_json(&format!("{BASE}/status")).await diff --git a/frontend/src/components/navbar.rs b/frontend/src/components/navbar.rs index a35ba96..1616b3e 100644 --- a/frontend/src/components/navbar.rs +++ b/frontend/src/components/navbar.rs @@ -3,8 +3,15 @@ use yew_router::prelude::*; use crate::pages::Route; +#[derive(Properties, PartialEq)] +pub struct Props { + pub username: String, + pub role: String, + pub on_logout: Callback<()>, +} + #[function_component(Navbar)] -pub fn navbar() -> Html { +pub fn navbar(props: &Props) -> Html { let route = use_route::(); let link = |to: Route, label: &str| { @@ -15,6 +22,14 @@ pub fn navbar() -> Html { } }; + let on_logout = { + let cb = props.on_logout.clone(); + Callback::from(move |e: MouseEvent| { + e.prevent_default(); + cb.emit(()); + }) + }; + html! { } } diff --git a/frontend/src/main.rs b/frontend/src/main.rs index 8f37443..4ed4380 100644 --- a/frontend/src/main.rs +++ b/frontend/src/main.rs @@ -8,18 +8,86 @@ use yew_router::prelude::*; use components::navbar::Navbar; use pages::{switch, Route}; +use types::UserInfo; + +#[derive(Clone, PartialEq)] +enum AuthState { + Loading, + NeedsSetup, + NeedsLogin, + Authenticated(UserInfo), +} #[function_component(App)] fn app() -> Html { - html! { - -
- -
- render={switch} /> -
+ let auth = use_state(|| AuthState::Loading); + + // Check auth state on mount + { + let auth = auth.clone(); + use_effect_with((), move |_| { + wasm_bindgen_futures::spawn_local(async move { + match api::get_me().await { + Ok(user) => auth.set(AuthState::Authenticated(user)), + Err(_) => { + // Not logged in — check if setup is needed + match api::check_setup_required().await { + Ok(sr) if sr.required => auth.set(AuthState::NeedsSetup), + _ => auth.set(AuthState::NeedsLogin), + } + } + } + }); + }); + } + + let on_auth_success = { + let auth = auth.clone(); + Callback::from(move |_: ()| { + let auth = auth.clone(); + wasm_bindgen_futures::spawn_local(async move { + if let Ok(user) = api::get_me().await { + auth.set(AuthState::Authenticated(user)); + } + }); + }) + }; + + match &*auth { + AuthState::Loading => html! { +
+

{ "Loading..." }

- + }, + AuthState::NeedsSetup => html! { + + }, + AuthState::NeedsLogin => html! { + + }, + AuthState::Authenticated(user) => { + let user = user.clone(); + let on_logout = { + let auth = auth.clone(); + Callback::from(move |_: ()| { + let auth = auth.clone(); + wasm_bindgen_futures::spawn_local(async move { + let _ = api::logout().await; + auth.set(AuthState::NeedsLogin); + }); + }) + }; + html! { + +
+ +
+ render={switch} /> +
+
+
+ } + } } } diff --git a/frontend/src/pages/login.rs b/frontend/src/pages/login.rs new file mode 100644 index 0000000..a897671 --- /dev/null +++ b/frontend/src/pages/login.rs @@ -0,0 +1,71 @@ +use web_sys::HtmlInputElement; +use yew::prelude::*; + +use crate::api; + +#[derive(Properties, PartialEq)] +pub struct Props { + pub on_login: Callback<()>, +} + +#[function_component(LoginPage)] +pub fn login_page(props: &Props) -> Html { + let username = use_state(String::new); + let password = use_state(String::new); + let error = use_state(|| None::); + + let on_submit = { + let username = username.clone(); + let password = password.clone(); + let error = error.clone(); + let on_login = props.on_login.clone(); + Callback::from(move |e: SubmitEvent| { + e.prevent_default(); + let u = (*username).clone(); + let p = (*password).clone(); + let error = error.clone(); + let on_login = on_login.clone(); + wasm_bindgen_futures::spawn_local(async move { + match api::login(&u, &p).await { + Ok(_) => on_login.emit(()), + Err(e) => error.set(Some(e.0)), + } + }); + }) + }; + + html! { +
+
+

{ "Shanty" }

+

{ "Sign in to continue" }

+ + if let Some(ref err) = *error { +
{ err }
+ } + +
+
+ + +
+
+ + +
+ +
+
+
+ } +} diff --git a/frontend/src/pages/mod.rs b/frontend/src/pages/mod.rs index aa3fc56..bf77159 100644 --- a/frontend/src/pages/mod.rs +++ b/frontend/src/pages/mod.rs @@ -3,8 +3,10 @@ pub mod artist; pub mod dashboard; pub mod downloads; pub mod library; +pub mod login; pub mod search; pub mod settings; +pub mod setup; use yew::prelude::*; use yew_router::prelude::*; diff --git a/frontend/src/pages/setup.rs b/frontend/src/pages/setup.rs new file mode 100644 index 0000000..4ceae17 --- /dev/null +++ b/frontend/src/pages/setup.rs @@ -0,0 +1,92 @@ +use web_sys::HtmlInputElement; +use yew::prelude::*; + +use crate::api; + +#[derive(Properties, PartialEq)] +pub struct Props { + pub on_setup: Callback<()>, +} + +#[function_component(SetupPage)] +pub fn setup_page(props: &Props) -> Html { + let username = use_state(String::new); + let password = use_state(String::new); + let confirm = use_state(String::new); + let error = use_state(|| None::); + + let on_submit = { + let username = username.clone(); + let password = password.clone(); + let confirm = confirm.clone(); + let error = error.clone(); + let on_setup = props.on_setup.clone(); + Callback::from(move |e: SubmitEvent| { + e.prevent_default(); + let u = (*username).clone(); + let p = (*password).clone(); + let c = (*confirm).clone(); + let error = error.clone(); + let on_setup = on_setup.clone(); + + if p != c { + error.set(Some("Passwords do not match".into())); + return; + } + if p.len() < 4 { + error.set(Some("Password must be at least 4 characters".into())); + return; + } + + wasm_bindgen_futures::spawn_local(async move { + match api::setup(&u, &p).await { + Ok(_) => on_setup.emit(()), + Err(e) => error.set(Some(e.0)), + } + }); + }) + }; + + html! { +
+
+

{ "Shanty" }

+

{ "Create your admin account to get started" }

+ + if let Some(ref err) = *error { +
{ err }
+ } + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ } +} diff --git a/frontend/src/types.rs b/frontend/src/types.rs index ba80ea6..507e9b2 100644 --- a/frontend/src/types.rs +++ b/frontend/src/types.rs @@ -1,5 +1,19 @@ use serde::{Deserialize, Serialize}; +// --- Auth --- + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct UserInfo { + pub id: i32, + pub username: String, + pub role: String, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct SetupRequired { + pub required: bool, +} + // --- Library entities --- #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] diff --git a/frontend/style.css b/frontend/style.css index 8f48f39..e5bfe63 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -38,6 +38,8 @@ a:hover { color: var(--accent-hover); } top: 0; bottom: 0; overflow-y: auto; + display: flex; + flex-direction: column; } .sidebar h1 { font-size: 1.5rem; margin-bottom: 2rem; color: var(--accent); } .sidebar nav a { @@ -177,3 +179,32 @@ table.tasks-table td { overflow: hidden; text-overflow: ellipsis; } .loading { color: var(--text-muted); font-style: italic; } .error { color: var(--danger); } .actions { display: flex; gap: 0.5rem; flex-wrap: wrap; } + +/* Auth pages */ +.auth-page { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + padding: 2rem; +} +.auth-card { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 2rem; + width: 100%; + max-width: 400px; +} +.auth-card h1 { font-size: 1.8rem; color: var(--accent); margin-bottom: 0.25rem; } +.auth-card p { margin-bottom: 1.5rem; } + +/* Sidebar user section */ +.sidebar-user { + margin-top: auto; + padding-top: 1rem; + border-top: 1px solid var(--border); + display: flex; + flex-direction: column; + gap: 0.25rem; +} diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 0000000..4c4e3da --- /dev/null +++ b/src/auth.rs @@ -0,0 +1,62 @@ +use actix_session::Session; +use argon2::{ + Argon2, + password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng}, +}; + +use crate::error::ApiError; + +/// Hash a password using Argon2id. +pub fn hash_password(password: &str) -> Result { + let salt = SaltString::generate(&mut OsRng); + let argon2 = Argon2::default(); + argon2 + .hash_password(password.as_bytes(), &salt) + .map(|h| h.to_string()) + .map_err(|e| ApiError::Internal(format!("failed to hash password: {e}"))) +} + +/// Verify a password against a stored hash. +pub fn verify_password(password: &str, hash: &str) -> bool { + let Ok(parsed) = PasswordHash::new(hash) else { + return false; + }; + Argon2::default() + .verify_password(password.as_bytes(), &parsed) + .is_ok() +} + +/// Session keys. +const SESSION_USER_ID: &str = "user_id"; +const SESSION_USERNAME: &str = "username"; +const SESSION_ROLE: &str = "role"; + +/// Store user info in the session. +pub fn set_session(session: &Session, user_id: i32, username: &str, role: &str) { + let _ = session.insert(SESSION_USER_ID, user_id); + let _ = session.insert(SESSION_USERNAME, username.to_string()); + let _ = session.insert(SESSION_ROLE, role.to_string()); +} + +/// Extract user info from session. Returns (user_id, username, role). +pub fn get_session_user(session: &Session) -> Option<(i32, String, String)> { + let user_id = session.get::(SESSION_USER_ID).ok()??; + let username = session.get::(SESSION_USERNAME).ok()??; + let role = session.get::(SESSION_ROLE).ok()??; + Some((user_id, username, role)) +} + +/// Require authentication. Returns (user_id, username, role) or 401. +pub fn require_auth(session: &Session) -> Result<(i32, String, String), ApiError> { + get_session_user(session) + .ok_or_else(|| ApiError::Unauthorized("not logged in".into())) +} + +/// Require admin role. Returns (user_id, username, role) or 403. +pub fn require_admin(session: &Session) -> Result<(i32, String, String), ApiError> { + let user = require_auth(session)?; + if user.2 != "admin" { + return Err(ApiError::Forbidden("admin access required".into())); + } + Ok(user) +} diff --git a/src/error.rs b/src/error.rs index 688f731..ace6435 100644 --- a/src/error.rs +++ b/src/error.rs @@ -15,6 +15,12 @@ pub enum ApiError { #[error("bad request: {0}")] BadRequest(String), + #[error("unauthorized: {0}")] + Unauthorized(String), + + #[error("forbidden: {0}")] + Forbidden(String), + #[error("rate limited: {0}")] TooManyRequests(String), @@ -27,6 +33,8 @@ impl ResponseError for ApiError { let (status, message) = match self { ApiError::NotFound(msg) => (actix_web::http::StatusCode::NOT_FOUND, msg.clone()), ApiError::BadRequest(msg) => (actix_web::http::StatusCode::BAD_REQUEST, msg.clone()), + ApiError::Unauthorized(msg) => (actix_web::http::StatusCode::UNAUTHORIZED, msg.clone()), + ApiError::Forbidden(msg) => (actix_web::http::StatusCode::FORBIDDEN, msg.clone()), ApiError::TooManyRequests(msg) => { tracing::warn!(error = %msg, "rate limited"); (actix_web::http::StatusCode::TOO_MANY_REQUESTS, msg.clone()) diff --git a/src/lib.rs b/src/lib.rs index 08e6138..c1f5ae9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,6 +4,7 @@ //! API consumed by the Elm frontend. Handles background tasks, configuration, //! and orchestration of indexing, tagging, downloading, and more. +pub mod auth; pub mod config; pub mod error; pub mod routes; diff --git a/src/main.rs b/src/main.rs index 38fe6db..470da3b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ use actix_cors::Cors; -use actix_web::{App, HttpServer, web}; +use actix_session::{SessionMiddleware, storage::CookieSessionStore}; +use actix_web::{App, HttpServer, cookie::Key, web}; use clap::Parser; use tracing_actix_web::TracingLogger; use tracing_subscriber::EnvFilter; @@ -89,12 +90,18 @@ async fn main() -> anyhow::Result<()> { }; tracing::info!(path = %static_dir.display(), "serving static files"); + let session_key = Key::generate(); + HttpServer::new(move || { let cors = Cors::permissive(); let static_dir = static_dir.clone(); App::new() .wrap(cors) + .wrap(SessionMiddleware::builder( + CookieSessionStore::default(), + session_key.clone(), + ).cookie_secure(false).build()) .wrap(TracingLogger::default()) .app_data(state.clone()) .configure(routes::configure) diff --git a/src/routes/albums.rs b/src/routes/albums.rs index 333e172..9acb484 100644 --- a/src/routes/albums.rs +++ b/src/routes/albums.rs @@ -1,3 +1,4 @@ +use actix_session::Session; use actix_web::{HttpResponse, web}; use serde::{Deserialize, Serialize}; @@ -5,6 +6,7 @@ use shanty_db::entities::wanted_item::WantedStatus; use shanty_db::queries; use shanty_tag::provider::MetadataProvider; +use crate::auth; use crate::error::ApiError; use crate::state::AppState; @@ -47,8 +49,10 @@ pub fn configure(cfg: &mut web::ServiceConfig) { async fn list_albums( state: web::Data, + session: Session, query: web::Query, ) -> Result { + auth::require_auth(&session)?; let albums = queries::albums::list(state.db.conn(), query.limit, query.offset).await?; Ok(HttpResponse::Ok().json(albums)) } @@ -58,8 +62,10 @@ async fn list_albums( /// and browses for its first release. async fn get_album( state: web::Data, + session: Session, path: web::Path, ) -> Result { + auth::require_auth(&session)?; let mbid = path.into_inner(); // Try fetching as a release first @@ -77,7 +83,7 @@ async fn get_album( }; // Get all wanted items to check local status - let all_wanted = queries::wanted::list(state.db.conn(), None).await?; + let all_wanted = queries::wanted::list(state.db.conn(), None, None).await?; let tracks: Vec = mb_tracks .into_iter() @@ -161,8 +167,10 @@ async fn resolve_release_from_group( async fn add_album( state: web::Data, + session: Session, body: web::Json, ) -> Result { + let (user_id, _, _) = auth::require_auth(&session)?; if body.artist.is_none() && body.album.is_none() && body.mbid.is_none() { return Err(ApiError::BadRequest("provide artist+album or mbid".into())); } @@ -176,13 +184,13 @@ async fn add_album( body.album.as_deref(), mbid.as_deref(), &state.mb_client, + Some(user_id), ) .await; let summary = match result { Ok(s) => s, Err(_) if mbid.is_some() => { - // MBID might be a release-group — resolve to the first release let rg_mbid = mbid.as_deref().unwrap(); let release_mbid = resolve_release_from_group(&state, rg_mbid).await?; mbid = Some(release_mbid); @@ -192,6 +200,7 @@ async fn add_album( body.album.as_deref(), mbid.as_deref(), &state.mb_client, + Some(user_id), ) .await? } diff --git a/src/routes/artists.rs b/src/routes/artists.rs index c155581..da6aab0 100644 --- a/src/routes/artists.rs +++ b/src/routes/artists.rs @@ -1,3 +1,4 @@ +use actix_session::Session; use actix_web::{HttpResponse, web}; use serde::{Deserialize, Serialize}; @@ -6,6 +7,7 @@ use shanty_db::queries; use shanty_search::SearchProvider; use shanty_tag::provider::MetadataProvider; +use crate::auth; use crate::error::ApiError; use crate::state::AppState; @@ -79,10 +81,12 @@ pub fn configure(cfg: &mut web::ServiceConfig) { async fn list_artists( state: web::Data, + session: Session, query: web::Query, ) -> Result { + auth::require_auth(&session)?; let artists = queries::artists::list(state.db.conn(), query.limit, query.offset).await?; - let wanted = queries::wanted::list(state.db.conn(), None).await?; + let wanted = queries::wanted::list(state.db.conn(), None, None).await?; let mut items: Vec = Vec::new(); for a in &artists { @@ -128,8 +132,10 @@ async fn list_artists( async fn get_artist( state: web::Data, + session: Session, path: web::Path, ) -> Result { + auth::require_auth(&session)?; let id_or_mbid = path.into_inner(); if let Ok(id) = id_or_mbid.parse::() { let artist = queries::artists::get_by_id(state.db.conn(), id).await?; @@ -254,9 +260,11 @@ pub struct ArtistFullParams { async fn get_artist_full( state: web::Data, + session: Session, path: web::Path, query: web::Query, ) -> Result { + auth::require_auth(&session)?; let id_or_mbid = path.into_inner(); let quick_mode = query.quick; let result = enrich_artist(&state, &id_or_mbid, quick_mode).await?; @@ -342,7 +350,7 @@ pub async fn enrich_artist( .collect(); // Get all wanted items for this artist - let all_wanted = queries::wanted::list(state.db.conn(), None).await?; + let all_wanted = queries::wanted::list(state.db.conn(), None, None).await?; let artist_wanted: Vec<_> = all_wanted .iter() .filter(|w| id.is_some() && w.artist_id == id) @@ -565,7 +573,7 @@ pub async fn enrich_artist( /// Enrich all watched artists in the background, updating their cached totals. pub async fn enrich_all_watched_artists(state: &AppState) -> Result { - let all_wanted = queries::wanted::list(state.db.conn(), None).await?; + let all_wanted = queries::wanted::list(state.db.conn(), None, None).await?; // Collect unique artist IDs that have any wanted items let mut artist_ids: Vec = all_wanted.iter().filter_map(|w| w.artist_id).collect(); @@ -585,8 +593,10 @@ pub async fn enrich_all_watched_artists(state: &AppState) -> Result, + session: Session, body: web::Json, ) -> Result { + let (user_id, _, _) = auth::require_auth(&session)?; if body.name.is_none() && body.mbid.is_none() { return Err(ApiError::BadRequest("provide name or mbid".into())); } @@ -595,6 +605,7 @@ async fn add_artist( body.name.as_deref(), body.mbid.as_deref(), &state.mb_client, + Some(user_id), ) .await?; @@ -616,8 +627,10 @@ async fn add_artist( async fn delete_artist( state: web::Data, + session: Session, path: web::Path, ) -> Result { + auth::require_admin(&session)?; let id = path.into_inner(); queries::artists::delete(state.db.conn(), id).await?; Ok(HttpResponse::NoContent().finish()) diff --git a/src/routes/auth.rs b/src/routes/auth.rs new file mode 100644 index 0000000..cca57b4 --- /dev/null +++ b/src/routes/auth.rs @@ -0,0 +1,203 @@ +use actix_session::Session; +use actix_web::{web, HttpResponse}; +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))); +} + +#[derive(Deserialize)] +struct SetupRequest { + username: String, + password: String, +} + +#[derive(Deserialize)] +struct LoginRequest { + username: String, + password: String, +} + +#[derive(Deserialize)] +struct CreateUserRequest { + username: String, + 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()) +} diff --git a/src/routes/downloads.rs b/src/routes/downloads.rs index 98fd44c..53c1920 100644 --- a/src/routes/downloads.rs +++ b/src/routes/downloads.rs @@ -1,9 +1,11 @@ +use actix_session::Session; use actix_web::{HttpResponse, web}; use serde::Deserialize; use shanty_db::entities::download_queue::DownloadStatus; use shanty_db::queries; +use crate::auth; use crate::error::ApiError; use crate::routes::artists::enrich_all_watched_artists; use crate::state::AppState; @@ -29,8 +31,10 @@ pub fn configure(cfg: &mut web::ServiceConfig) { async fn list_queue( state: web::Data, + session: Session, query: web::Query, ) -> Result { + auth::require_auth(&session)?; let filter = match query.status.as_deref() { Some("pending") => Some(DownloadStatus::Pending), Some("downloading") => Some(DownloadStatus::Downloading), @@ -45,13 +49,16 @@ async fn list_queue( async fn enqueue_download( state: web::Data, + session: Session, body: web::Json, ) -> Result { + auth::require_auth(&session)?; let item = queries::downloads::enqueue(state.db.conn(), &body.query, None, "ytdlp").await?; Ok(HttpResponse::Ok().json(item)) } -async fn sync_downloads(state: web::Data) -> Result { +async fn sync_downloads(state: web::Data, session: Session) -> Result { + auth::require_auth(&session)?; let stats = shanty_dl::sync_wanted_to_queue(state.db.conn(), false).await?; Ok(HttpResponse::Ok().json(serde_json::json!({ "found": stats.found, @@ -60,7 +67,8 @@ async fn sync_downloads(state: web::Data) -> Result) -> Result { +async fn trigger_process(state: web::Data, session: Session) -> Result { + auth::require_auth(&session)?; let task_id = state.tasks.register("download"); let state = state.clone(); let tid = task_id.clone(); @@ -120,18 +128,22 @@ async fn trigger_process(state: web::Data) -> Result, path: web::Path, ) -> Result { + auth::require_auth(&session)?; let id = path.into_inner(); queries::downloads::retry_failed(state.db.conn(), id).await?; Ok(HttpResponse::Ok().json(serde_json::json!({ "status": "requeued" }))) } async fn cancel_download( + session: Session, state: web::Data, path: web::Path, ) -> Result { + auth::require_auth(&session)?; let id = path.into_inner(); queries::downloads::update_status(state.db.conn(), id, DownloadStatus::Cancelled, None).await?; Ok(HttpResponse::Ok().json(serde_json::json!({ "status": "cancelled" }))) diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 6cf29b5..a4ec009 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -1,5 +1,6 @@ pub mod albums; pub mod artists; +pub mod auth; pub mod downloads; pub mod search; pub mod system; @@ -10,6 +11,7 @@ use actix_web::web; pub fn configure(cfg: &mut web::ServiceConfig) { cfg.service( web::scope("/api") + .configure(auth::configure) .configure(artists::configure) .configure(albums::configure) .configure(tracks::configure) diff --git a/src/routes/search.rs b/src/routes/search.rs index c599ab2..65b27a3 100644 --- a/src/routes/search.rs +++ b/src/routes/search.rs @@ -1,8 +1,10 @@ +use actix_session::Session; use actix_web::{HttpResponse, web}; use serde::Deserialize; use shanty_search::SearchProvider; +use crate::auth; use crate::error::ApiError; use crate::state::AppState; @@ -34,16 +36,20 @@ pub fn configure(cfg: &mut web::ServiceConfig) { async fn search_artist( state: web::Data, + session: Session, query: web::Query, ) -> Result { + auth::require_auth(&session)?; let results = state.search.search_artist(&query.q, query.limit).await?; Ok(HttpResponse::Ok().json(results)) } async fn search_album( state: web::Data, + session: Session, query: web::Query, ) -> Result { + auth::require_auth(&session)?; let results = state .search .search_album(&query.q, query.artist.as_deref(), query.limit) @@ -53,8 +59,10 @@ async fn search_album( async fn search_track( state: web::Data, + session: Session, query: web::Query, ) -> Result { + auth::require_auth(&session)?; let results = state .search .search_track(&query.q, query.artist.as_deref(), query.limit) @@ -64,8 +72,10 @@ async fn search_track( async fn get_discography( state: web::Data, + session: Session, path: web::Path, ) -> Result { + auth::require_auth(&session)?; let artist_id = path.into_inner(); let disco = state.search.get_discography(&artist_id).await?; Ok(HttpResponse::Ok().json(disco)) diff --git a/src/routes/system.rs b/src/routes/system.rs index 43ecc7c..622cac3 100644 --- a/src/routes/system.rs +++ b/src/routes/system.rs @@ -1,9 +1,11 @@ +use actix_session::Session; use actix_web::{HttpResponse, web}; use serde::Deserialize; use shanty_db::entities::download_queue::DownloadStatus; use shanty_db::queries; +use crate::auth; use crate::config::AppConfig; use crate::error::ApiError; use crate::routes::artists::enrich_all_watched_artists; @@ -25,7 +27,8 @@ pub fn configure(cfg: &mut web::ServiceConfig) { ); } -async fn get_status(state: web::Data) -> Result { +async fn get_status(state: web::Data, session: Session) -> Result { + auth::require_auth(&session)?; let summary = shanty_watch::library_summary(state.db.conn()).await?; let pending_items = queries::downloads::list(state.db.conn(), Some(DownloadStatus::Pending)).await?; @@ -58,7 +61,8 @@ async fn get_status(state: web::Data) -> Result) -> Result { +async fn trigger_index(state: web::Data, session: Session) -> Result { + auth::require_auth(&session)?; let task_id = state.tasks.register("index"); let state = state.clone(); let tid = task_id.clone(); @@ -82,7 +86,8 @@ async fn trigger_index(state: web::Data) -> Result) -> Result { +async fn trigger_tag(state: web::Data, session: Session) -> Result { + auth::require_auth(&session)?; let task_id = state.tasks.register("tag"); let state = state.clone(); let tid = task_id.clone(); @@ -114,7 +119,8 @@ async fn trigger_tag(state: web::Data) -> Result) -> Result { +async fn trigger_organize(state: web::Data, session: Session) -> Result { + auth::require_auth(&session)?; let task_id = state.tasks.register("organize"); let state = state.clone(); let tid = task_id.clone(); @@ -150,7 +156,8 @@ async fn trigger_organize(state: web::Data) -> Result) -> Result { +async fn trigger_pipeline(state: web::Data, session: Session) -> Result { + auth::require_auth(&session)?; let sync_id = state.tasks.register_pending("sync"); let download_id = state.tasks.register_pending("download"); let index_id = state.tasks.register_pending("index"); @@ -308,8 +315,10 @@ async fn trigger_pipeline(state: web::Data) -> Result, + session: Session, path: web::Path, ) -> Result { + auth::require_auth(&session)?; let id = path.into_inner(); match state.tasks.get(&id) { Some(task) => Ok(HttpResponse::Ok().json(task)), @@ -317,21 +326,25 @@ async fn get_task( } } -async fn list_watchlist(state: web::Data) -> Result { - let items = shanty_watch::list_items(state.db.conn(), None, None).await?; +async fn list_watchlist(state: web::Data, session: Session) -> Result { + let (user_id, _, _) = auth::require_auth(&session)?; + let items = shanty_watch::list_items(state.db.conn(), None, None, Some(user_id)).await?; Ok(HttpResponse::Ok().json(items)) } async fn remove_watchlist( state: web::Data, + session: Session, path: web::Path, ) -> Result { + auth::require_auth(&session)?; let id = path.into_inner(); shanty_watch::remove_item(state.db.conn(), id).await?; Ok(HttpResponse::NoContent().finish()) } -async fn get_config(state: web::Data) -> Result { +async fn get_config(state: web::Data, session: Session) -> Result { + auth::require_auth(&session)?; let config = state.config.read().await; Ok(HttpResponse::Ok().json(&*config)) } @@ -344,8 +357,10 @@ struct SaveConfigRequest { async fn save_config( state: web::Data, + session: Session, body: web::Json, ) -> Result { + auth::require_admin(&session)?; let new_config = body.into_inner().config; // Persist to YAML diff --git a/src/routes/tracks.rs b/src/routes/tracks.rs index 2d970b0..8d26acd 100644 --- a/src/routes/tracks.rs +++ b/src/routes/tracks.rs @@ -1,8 +1,10 @@ +use actix_session::Session; use actix_web::{HttpResponse, web}; use serde::Deserialize; use shanty_db::queries; +use crate::auth; use crate::error::ApiError; use crate::state::AppState; @@ -26,8 +28,10 @@ pub fn configure(cfg: &mut web::ServiceConfig) { async fn list_tracks( state: web::Data, + session: Session, query: web::Query, ) -> Result { + auth::require_auth(&session)?; let tracks = if let Some(ref q) = query.q { queries::tracks::search(state.db.conn(), q).await? } else { @@ -38,8 +42,10 @@ async fn list_tracks( async fn get_track( state: web::Data, + session: Session, path: web::Path, ) -> Result { + auth::require_auth(&session)?; let id = path.into_inner(); let track = queries::tracks::get_by_id(state.db.conn(), id).await?; Ok(HttpResponse::Ok().json(track))