Added auth

This commit is contained in:
Connor Johnstone
2026-03-19 14:02:33 -04:00
parent 93392db27c
commit 421ec3199b
21 changed files with 719 additions and 26 deletions

62
src/auth.rs Normal file
View 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)
}

View File

@@ -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())

View File

@@ -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;

View File

@@ -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)

View File

@@ -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?
}

View File

@@ -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
View 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())
}

View File

@@ -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" })))

View File

@@ -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)

View File

@@ -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))

View File

@@ -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

View File

@@ -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))