Compare commits
1 Commits
93392db27c
...
421ec3199b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
421ec3199b |
@@ -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"] }
|
||||
|
||||
@@ -55,6 +55,49 @@ async fn delete(url: &str) -> Result<(), ApiError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- Auth ---
|
||||
pub async fn check_setup_required() -> Result<SetupRequired, ApiError> {
|
||||
get_json(&format!("{BASE}/auth/setup-required")).await
|
||||
}
|
||||
|
||||
pub async fn setup(username: &str, password: &str) -> Result<UserInfo, ApiError> {
|
||||
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<UserInfo, ApiError> {
|
||||
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<UserInfo, ApiError> {
|
||||
get_json(&format!("{BASE}/auth/me")).await
|
||||
}
|
||||
|
||||
pub async fn list_users() -> Result<Vec<UserInfo>, ApiError> {
|
||||
get_json(&format!("{BASE}/auth/users")).await
|
||||
}
|
||||
|
||||
pub async fn create_user(username: &str, password: &str) -> Result<UserInfo, ApiError> {
|
||||
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<Status, ApiError> {
|
||||
get_json(&format!("{BASE}/status")).await
|
||||
|
||||
@@ -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::<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! {
|
||||
<div class="sidebar">
|
||||
<h1>{ "Shanty" }</h1>
|
||||
@@ -23,8 +38,14 @@ pub fn navbar() -> Html {
|
||||
{ link(Route::Search, "Search") }
|
||||
{ link(Route::Library, "Library") }
|
||||
{ link(Route::Downloads, "Downloads") }
|
||||
{ link(Route::Settings, "Settings") }
|
||||
if props.role == "admin" {
|
||||
{ link(Route::Settings, "Settings") }
|
||||
}
|
||||
</nav>
|
||||
<div class="sidebar-user">
|
||||
<span class="text-sm text-muted">{ &props.username }</span>
|
||||
<a href="#" class="text-sm" onclick={on_logout}>{ "Logout" }</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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! {
|
||||
<BrowserRouter>
|
||||
<div class="app">
|
||||
<Navbar />
|
||||
<div class="main-content">
|
||||
<Switch<Route> render={switch} />
|
||||
</div>
|
||||
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! {
|
||||
<div class="auth-page">
|
||||
<p class="loading">{ "Loading..." }</p>
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
},
|
||||
AuthState::NeedsSetup => html! {
|
||||
<pages::setup::SetupPage on_setup={on_auth_success.clone()} />
|
||||
},
|
||||
AuthState::NeedsLogin => html! {
|
||||
<pages::login::LoginPage on_login={on_auth_success.clone()} />
|
||||
},
|
||||
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! {
|
||||
<BrowserRouter>
|
||||
<div class="app">
|
||||
<Navbar username={user.username.clone()} role={user.role.clone()} on_logout={on_logout} />
|
||||
<div class="main-content">
|
||||
<Switch<Route> render={switch} />
|
||||
</div>
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
71
frontend/src/pages/login.rs
Normal file
71
frontend/src/pages/login.rs
Normal file
@@ -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::<String>);
|
||||
|
||||
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! {
|
||||
<div class="auth-page">
|
||||
<div class="auth-card">
|
||||
<h1>{ "Shanty" }</h1>
|
||||
<p class="text-muted">{ "Sign in to continue" }</p>
|
||||
|
||||
if let Some(ref err) = *error {
|
||||
<div class="error">{ err }</div>
|
||||
}
|
||||
|
||||
<form onsubmit={on_submit}>
|
||||
<div class="form-group">
|
||||
<label>{ "Username" }</label>
|
||||
<input type="text" autocomplete="username" value={(*username).clone()}
|
||||
oninput={let u = username.clone(); Callback::from(move |e: InputEvent| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
u.set(input.value());
|
||||
})} />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{ "Password" }</label>
|
||||
<input type="password" autocomplete="current-password" value={(*password).clone()}
|
||||
oninput={let p = password.clone(); Callback::from(move |e: InputEvent| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
p.set(input.value());
|
||||
})} />
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" style="width: 100%; margin-top: 1rem;">
|
||||
{ "Sign In" }
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
92
frontend/src/pages/setup.rs
Normal file
92
frontend/src/pages/setup.rs
Normal file
@@ -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::<String>);
|
||||
|
||||
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! {
|
||||
<div class="auth-page">
|
||||
<div class="auth-card">
|
||||
<h1>{ "Shanty" }</h1>
|
||||
<p class="text-muted">{ "Create your admin account to get started" }</p>
|
||||
|
||||
if let Some(ref err) = *error {
|
||||
<div class="error">{ err }</div>
|
||||
}
|
||||
|
||||
<form onsubmit={on_submit}>
|
||||
<div class="form-group">
|
||||
<label>{ "Username" }</label>
|
||||
<input type="text" autocomplete="username" value={(*username).clone()}
|
||||
oninput={let u = username.clone(); Callback::from(move |e: InputEvent| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
u.set(input.value());
|
||||
})} />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{ "Password" }</label>
|
||||
<input type="password" autocomplete="new-password" value={(*password).clone()}
|
||||
oninput={let p = password.clone(); Callback::from(move |e: InputEvent| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
p.set(input.value());
|
||||
})} />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{ "Confirm Password" }</label>
|
||||
<input type="password" autocomplete="new-password" value={(*confirm).clone()}
|
||||
oninput={let c = confirm.clone(); Callback::from(move |e: InputEvent| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
c.set(input.value());
|
||||
})} />
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" style="width: 100%; margin-top: 1rem;">
|
||||
{ "Create Admin Account" }
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
62
src/auth.rs
Normal file
62
src/auth.rs
Normal file
@@ -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<String, ApiError> {
|
||||
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::<i32>(SESSION_USER_ID).ok()??;
|
||||
let username = session.get::<String>(SESSION_USERNAME).ok()??;
|
||||
let role = session.get::<String>(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)
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<AppState>,
|
||||
session: Session,
|
||||
query: web::Query<PaginationParams>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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<AppState>,
|
||||
session: Session,
|
||||
path: web::Path<String>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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<AlbumTrackInfo> = mb_tracks
|
||||
.into_iter()
|
||||
@@ -161,8 +167,10 @@ async fn resolve_release_from_group(
|
||||
|
||||
async fn add_album(
|
||||
state: web::Data<AppState>,
|
||||
session: Session,
|
||||
body: web::Json<AddAlbumRequest>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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?
|
||||
}
|
||||
|
||||
@@ -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<AppState>,
|
||||
session: Session,
|
||||
query: web::Query<PaginationParams>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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<ArtistListItem> = Vec::new();
|
||||
for a in &artists {
|
||||
@@ -128,8 +132,10 @@ async fn list_artists(
|
||||
|
||||
async fn get_artist(
|
||||
state: web::Data<AppState>,
|
||||
session: Session,
|
||||
path: web::Path<String>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
auth::require_auth(&session)?;
|
||||
let id_or_mbid = path.into_inner();
|
||||
if let Ok(id) = id_or_mbid.parse::<i32>() {
|
||||
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<AppState>,
|
||||
session: Session,
|
||||
path: web::Path<String>,
|
||||
query: web::Query<ArtistFullParams>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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<u32, ApiError> {
|
||||
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<i32> = all_wanted.iter().filter_map(|w| w.artist_id).collect();
|
||||
@@ -585,8 +593,10 @@ pub async fn enrich_all_watched_artists(state: &AppState) -> Result<u32, ApiErro
|
||||
|
||||
async fn add_artist(
|
||||
state: web::Data<AppState>,
|
||||
session: Session,
|
||||
body: web::Json<AddArtistRequest>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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<AppState>,
|
||||
session: Session,
|
||||
path: web::Path<i32>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
auth::require_admin(&session)?;
|
||||
let id = path.into_inner();
|
||||
queries::artists::delete(state.db.conn(), id).await?;
|
||||
Ok(HttpResponse::NoContent().finish())
|
||||
|
||||
203
src/routes/auth.rs
Normal file
203
src/routes/auth.rs
Normal file
@@ -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<AppState>) -> Result<HttpResponse, ApiError> {
|
||||
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<AppState>,
|
||||
session: Session,
|
||||
body: web::Json<SetupRequest>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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<AppState>,
|
||||
session: Session,
|
||||
body: web::Json<LoginRequest>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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<HttpResponse, ApiError> {
|
||||
session.purge();
|
||||
Ok(HttpResponse::Ok().json(serde_json::json!({ "status": "logged out" })))
|
||||
}
|
||||
|
||||
/// Get current user info from session.
|
||||
async fn me(session: Session) -> Result<HttpResponse, ApiError> {
|
||||
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<AppState>,
|
||||
session: Session,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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<AppState>,
|
||||
session: Session,
|
||||
body: web::Json<CreateUserRequest>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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<AppState>,
|
||||
session: Session,
|
||||
path: web::Path<i32>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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())
|
||||
}
|
||||
@@ -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<AppState>,
|
||||
session: Session,
|
||||
query: web::Query<QueueParams>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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<AppState>,
|
||||
session: Session,
|
||||
body: web::Json<EnqueueRequest>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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<AppState>) -> Result<HttpResponse, ApiError> {
|
||||
async fn sync_downloads(state: web::Data<AppState>, session: Session) -> Result<HttpResponse, ApiError> {
|
||||
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<AppState>) -> Result<HttpResponse, ApiE
|
||||
})))
|
||||
}
|
||||
|
||||
async fn trigger_process(state: web::Data<AppState>) -> Result<HttpResponse, ApiError> {
|
||||
async fn trigger_process(state: web::Data<AppState>, session: Session) -> Result<HttpResponse, ApiError> {
|
||||
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<AppState>) -> Result<HttpResponse, Api
|
||||
}
|
||||
|
||||
async fn retry_download(
|
||||
session: Session,
|
||||
state: web::Data<AppState>,
|
||||
path: web::Path<i32>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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<AppState>,
|
||||
path: web::Path<i32>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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" })))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<AppState>,
|
||||
session: Session,
|
||||
query: web::Query<ArtistSearchParams>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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<AppState>,
|
||||
session: Session,
|
||||
query: web::Query<AlbumTrackSearchParams>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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<AppState>,
|
||||
session: Session,
|
||||
query: web::Query<AlbumTrackSearchParams>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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<AppState>,
|
||||
session: Session,
|
||||
path: web::Path<String>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
auth::require_auth(&session)?;
|
||||
let artist_id = path.into_inner();
|
||||
let disco = state.search.get_discography(&artist_id).await?;
|
||||
Ok(HttpResponse::Ok().json(disco))
|
||||
|
||||
@@ -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<AppState>) -> Result<HttpResponse, ApiError> {
|
||||
async fn get_status(state: web::Data<AppState>, session: Session) -> Result<HttpResponse, ApiError> {
|
||||
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<AppState>) -> Result<HttpResponse, ApiError
|
||||
})))
|
||||
}
|
||||
|
||||
async fn trigger_index(state: web::Data<AppState>) -> Result<HttpResponse, ApiError> {
|
||||
async fn trigger_index(state: web::Data<AppState>, session: Session) -> Result<HttpResponse, ApiError> {
|
||||
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<AppState>) -> Result<HttpResponse, ApiEr
|
||||
Ok(HttpResponse::Accepted().json(serde_json::json!({ "task_id": task_id })))
|
||||
}
|
||||
|
||||
async fn trigger_tag(state: web::Data<AppState>) -> Result<HttpResponse, ApiError> {
|
||||
async fn trigger_tag(state: web::Data<AppState>, session: Session) -> Result<HttpResponse, ApiError> {
|
||||
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<AppState>) -> Result<HttpResponse, ApiErro
|
||||
Ok(HttpResponse::Accepted().json(serde_json::json!({ "task_id": task_id })))
|
||||
}
|
||||
|
||||
async fn trigger_organize(state: web::Data<AppState>) -> Result<HttpResponse, ApiError> {
|
||||
async fn trigger_organize(state: web::Data<AppState>, session: Session) -> Result<HttpResponse, ApiError> {
|
||||
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<AppState>) -> Result<HttpResponse, Ap
|
||||
Ok(HttpResponse::Accepted().json(serde_json::json!({ "task_id": task_id })))
|
||||
}
|
||||
|
||||
async fn trigger_pipeline(state: web::Data<AppState>) -> Result<HttpResponse, ApiError> {
|
||||
async fn trigger_pipeline(state: web::Data<AppState>, session: Session) -> Result<HttpResponse, ApiError> {
|
||||
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<AppState>) -> Result<HttpResponse, Ap
|
||||
|
||||
async fn get_task(
|
||||
state: web::Data<AppState>,
|
||||
session: Session,
|
||||
path: web::Path<String>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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<AppState>) -> Result<HttpResponse, ApiError> {
|
||||
let items = shanty_watch::list_items(state.db.conn(), None, None).await?;
|
||||
async fn list_watchlist(state: web::Data<AppState>, session: Session) -> Result<HttpResponse, ApiError> {
|
||||
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<AppState>,
|
||||
session: Session,
|
||||
path: web::Path<i32>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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<AppState>) -> Result<HttpResponse, ApiError> {
|
||||
async fn get_config(state: web::Data<AppState>, session: Session) -> Result<HttpResponse, ApiError> {
|
||||
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<AppState>,
|
||||
session: Session,
|
||||
body: web::Json<SaveConfigRequest>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
auth::require_admin(&session)?;
|
||||
let new_config = body.into_inner().config;
|
||||
|
||||
// Persist to YAML
|
||||
|
||||
@@ -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<AppState>,
|
||||
session: Session,
|
||||
query: web::Query<SearchParams>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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<AppState>,
|
||||
session: Session,
|
||||
path: web::Path<i32>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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))
|
||||
|
||||
Reference in New Issue
Block a user