From af6df1c9f328b3aadccd50287001a2402aa7e697 Mon Sep 17 00:00:00 2001 From: Brendon Walsh Date: Tue, 21 Nov 2023 10:27:16 -0500 Subject: [PATCH] Axum routes migration (#577) * Migrate dashboard routes to Axum * Migrate tv/season/episode routes to Axum * Migrate filebrowser routes to Axum * Do not attempt to extract user in TV routes where it is unused * Library routes moved to be within auth middleware * Migrate search route to Axum * Migrate settings routes to Axum * Migrate mediafile routes to Axum * Migrate user routes to Axum * Migrate media routes to Axum * Move media_routes within Axum auth middleware * Cleanup imports and move to displaydoc --- Cargo.lock | 3 +- dim-core/Cargo.toml | 1 - dim-core/src/errors.rs | 5 - dim-core/src/lib.rs | 1 - dim-core/src/routes/auth.rs | 203 -------- dim-core/src/routes/general.rs | 256 ---------- dim-core/src/routes/host.rs | 53 --- dim-core/src/routes/library.rs | 439 ------------------ dim-core/src/routes/mediafile.rs | 196 -------- dim-core/src/routes/mod.rs | 10 - dim-core/src/routes/rematch_media.rs | 139 ------ dim-core/src/routes/settings.rs | 132 +----- dim-core/src/routes/tv.rs | 268 ----------- dim-core/src/routes/user.rs | 343 -------------- dim-web/Cargo.toml | 3 +- dim-web/src/error.rs | 6 +- dim-web/src/lib.rs | 293 +++++++----- dim-web/src/routes/auth.rs | 55 ++- {dim-core => dim-web}/src/routes/dashboard.rs | 220 ++++----- dim-web/src/routes/filebrowser.rs | 101 ++++ {dim-core => dim-web}/src/routes/media.rs | 396 ++++++++-------- dim-web/src/routes/mediafile.rs | 176 +++++++ dim-web/src/routes/mod.rs | 8 + dim-web/src/routes/search.rs | 178 +++++++ dim-web/src/routes/settings.rs | 60 +++ dim-web/src/routes/tv.rs | 148 ++++++ dim-web/src/routes/user.rs | 299 ++++++++++++ ui/src/actions/auth.js | 4 +- ui/src/actions/user.js | 2 +- 29 files changed, 1472 insertions(+), 2526 deletions(-) delete mode 100644 dim-core/src/routes/auth.rs delete mode 100644 dim-core/src/routes/general.rs delete mode 100644 dim-core/src/routes/host.rs delete mode 100644 dim-core/src/routes/library.rs delete mode 100644 dim-core/src/routes/mediafile.rs delete mode 100644 dim-core/src/routes/rematch_media.rs delete mode 100644 dim-core/src/routes/tv.rs delete mode 100644 dim-core/src/routes/user.rs rename {dim-core => dim-web}/src/routes/dashboard.rs (77%) create mode 100644 dim-web/src/routes/filebrowser.rs rename {dim-core => dim-web}/src/routes/media.rs (63%) create mode 100644 dim-web/src/routes/mediafile.rs create mode 100644 dim-web/src/routes/search.rs create mode 100644 dim-web/src/routes/settings.rs create mode 100644 dim-web/src/routes/tv.rs create mode 100644 dim-web/src/routes/user.rs diff --git a/Cargo.lock b/Cargo.lock index 7d363fcff..d432e5f56 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -241,6 +241,7 @@ dependencies = [ "matchit", "memchr", "mime", + "multer", "percent-encoding", "pin-project-lite", "rustversion", @@ -853,7 +854,6 @@ name = "dim-core" version = "0.4.0-dev" dependencies = [ "async-trait", - "bytes 1.5.0", "cfg-if", "chrono", "criterion", @@ -969,6 +969,7 @@ name = "dim-web" version = "0.1.0" dependencies = [ "axum", + "cfg-if", "chrono", "dim-core", "dim-database", diff --git a/dim-core/Cargo.toml b/dim-core/Cargo.toml index 4495a36e5..6cca17288 100644 --- a/dim-core/Cargo.toml +++ b/dim-core/Cargo.toml @@ -36,7 +36,6 @@ serde_derive = "^1.0.125" serde_json = "^1.0.64" async-trait = "0.1.50" -bytes = "1.0.1" cfg-if = "1.0.0" chrono = { version = "0.4.19", features = ["serde"] } dia-i18n = "0.10.0" diff --git a/dim-core/src/errors.rs b/dim-core/src/errors.rs index 5eccbdbe9..77ccc2365 100644 --- a/dim-core/src/errors.rs +++ b/dim-core/src/errors.rs @@ -5,7 +5,6 @@ use thiserror::Error; use serde::Serialize; use serde_json::json; -use crate::routes::mediafile; use nightfall::error::NightfallError; use http::StatusCode; @@ -57,9 +56,6 @@ pub enum DimError { UsernameNotAvailable, /// An error has occured while parsing cookies: {0:?} CookieError(#[source] dim_auth::AuthError), - /// Error occured in the `/api/v1/mediafile` routes. - #[error(transparent)] - MediafileRouteError(#[from] mediafile::Error), /// User does not exist UserNotFound, /// Couldn't find the tmdb id provided. @@ -121,7 +117,6 @@ impl warp::Reply for DimError { Self::UnsupportedFile | Self::InvalidMediaType | Self::MissingFieldInBody { .. } => { StatusCode::NOT_ACCEPTABLE } - Self::MediafileRouteError(ref e) => e.status_code(), }; let resp = json!({ diff --git a/dim-core/src/lib.rs b/dim-core/src/lib.rs index 2190d072d..b2344d859 100644 --- a/dim-core/src/lib.rs +++ b/dim-core/src/lib.rs @@ -28,7 +28,6 @@ use tracing_subscriber::EnvFilter; /// Various utilities pub mod utils; -pub(crate) use utils::json; /// Module contains our core initialization logic. pub mod core; diff --git a/dim-core/src/routes/auth.rs b/dim-core/src/routes/auth.rs deleted file mode 100644 index d4ce96825..000000000 --- a/dim-core/src/routes/auth.rs +++ /dev/null @@ -1,203 +0,0 @@ -//! This module contains all docs and APIs related to authentication and user creation. -//! -//! # Request Authentication and Authorization -//! Most API endpoints require a valid JWT authentication token. If no such token is supplied, the -//! API will return [`Unauthenticated`]. Authentication tokens can be obtained by logging in with -//! the [`login`] method. Authentication tokens must be passed to the server through a -//! `Authroization` header. -//! -//! ## Example of an authenticated call -//! ```text -//! curl -X POST http://127.0.0.1:8000/api/v1/auth/whoami -H "Content-type: application/json" -H -//! "Authorization: eyJhb....." -//! ``` -//! -//! # Token expiration -//! By default tokens expire after exactly two weeks, once the tokens expire the client must renew -//! them. At the moment renewing the token is only possible by logging in again. -//! -//! [`Unauthenticated`]: crate::errors::DimError::Unauthenticated -//! [`login`]: fn@login -use crate::core::DbConnection; -use crate::errors; - -use dim_database::user::verify; -use dim_database::user::InsertableUser; -use dim_database::user::Login; -use dim_database::user::User; - -use serde_json::json; - -use warp::reply; - -pub mod filters { - use crate::core::DbConnection; - - use warp::reject; - use warp::Filter; - - use dim_database::user::Login; - - use super::super::global_filters::with_db; - - pub fn login( - conn: DbConnection, - ) -> impl Filter + Clone { - warp::path!("api" / "v1" / "auth" / "login") - .and(warp::post()) - .and(warp::body::json::()) - .and(with_db(conn)) - .and_then(|new_login: Login, conn: DbConnection| async move { - super::login(new_login, conn) - .await - .map_err(|e| reject::custom(e)) - }) - } - - pub fn register( - conn: DbConnection, - ) -> impl Filter + Clone { - warp::path!("api" / "v1" / "auth" / "register") - .and(warp::post()) - .and(warp::body::json::()) - .and(with_db(conn)) - .and_then(|new_login: Login, conn: DbConnection| async move { - super::register(new_login, conn) - .await - .map_err(|e| reject::custom(e)) - }) - } -} - -/// # POST `/api/v1/auth/login` -/// Method will log a user in and return a authentication token that can be used to authenticate other -/// requests. -/// -/// # Request -/// This method accepts a JSON body that deserializes into [`Login`]. -/// -/// ## Example -/// ```text -/// curl -X POST http://127.0.0.1:8000/api/v1/auth/login -H "Content-type: application/json" -d -/// '{"username": "testuser", "password": "testpassword"}' -/// ``` -/// -/// # Response -/// If authentication is successful, this method will return status `200 0K` as well as a -/// authentication token. -/// ```no_compile -/// { -/// "token": "...." -/// } -/// ``` -/// -/// # Errors -/// * [`InvalidCredentials`] - The provided username or password is incorrect. -/// -/// [`InvalidCredentials`]: crate::errors::DimError::InvalidCredentials -/// [`Login`]: dim_database::user::Login -pub async fn login( - new_login: Login, - conn: DbConnection, -) -> Result { - let mut tx = conn.read().begin().await?; - let user = User::get(&mut tx, &new_login.username) - .await - .map_err(|_| errors::DimError::InvalidCredentials)?; - let pass = user.get_pass(&mut tx).await?; - if verify(user.username, pass, new_login.password) { - let token = dim_database::user::Login::create_cookie(user.id); - - return Ok(reply::json(&json!({ - "token": token, - }))); - } - - Err(errors::DimError::InvalidCredentials) -} - -pub async fn admin_exists(conn: DbConnection) -> Result { - let mut tx = conn.read().begin().await?; - Ok(reply::json(&json!({ - "exists": !User::get_all(&mut tx).await?.is_empty() - }))) -} - -/// # POST `/api/v1/auth/register` -/// Method will create a new user and return it a authentication token if a user has been -/// successfuly created. -/// -/// # Request -/// This method accepts a JSON body that deserializes into [`Login`]. If there are no other users -/// in the database, this route will give the new user `owner` permissions. Additionally this route -/// will not require an invite token. -/// -/// If there is a user in the database, this request will require an invite token and the user will -/// be given only `user` permissions. -/// -/// ## Example -/// ```text -/// curl -X POST http://127.0.0.1:8000/api/v1/auth/login -H "Content-type: application/json" -d -/// '{"username": "testuser", "password": "testpassword", "invite_token": -/// "72390330-b8af-4413-8305-5f8cae1c8f88"}' -/// ``` -/// -/// # Response -/// If a user is successfully created, this method will return status `200 0K` as well as the -/// create user's username. -/// ```no_compile -/// { -/// "username": "...." -/// } -/// ``` -/// -/// # Errors -/// * [`NoToken`] - Either the request doesnt contain an invite token, or the invite token is -/// invalid. -/// -/// [`NoToken`]: crate::errors::DimError::NoToken -/// [`Login`]: dim_database::user::Login -pub async fn register( - new_user: Login, - conn: DbConnection, -) -> Result { - // FIXME: Return INTERNAL SERVER ERROR maybe with a traceback? - let mut lock = conn.writer().lock_owned().await; - let mut tx = dim_database::write_tx(&mut lock).await?; - // NOTE: I doubt this method can faily all the time, we should map server error here too. - let users_empty = User::get_all(&mut tx).await?.is_empty(); - - if !users_empty - && (new_user.invite_token.is_none() || !new_user.invite_token_valid(&mut tx).await?) - { - return Err(errors::DimError::NoToken); - } - - let roles = dim_database::user::Roles(if !users_empty { - vec!["user".to_string()] - } else { - vec!["owner".to_string()] - }); - - let claimed_invite = if users_empty { - // NOTE: Double check what we are returning here. - Login::new_invite(&mut tx).await? - } else { - new_user.invite_token.ok_or(errors::DimError::NoToken)? - }; - - let res = InsertableUser { - username: new_user.username.clone(), - password: new_user.password.clone(), - roles, - claimed_invite, - prefs: Default::default(), - } - .insert(&mut tx) - .await?; - - // FIXME: Return internal server error. - tx.commit().await?; - - Ok(reply::json(&json!({ "username": res.username }))) -} diff --git a/dim-core/src/routes/general.rs b/dim-core/src/routes/general.rs deleted file mode 100644 index 49195d123..000000000 --- a/dim-core/src/routes/general.rs +++ /dev/null @@ -1,256 +0,0 @@ -use crate::core::DbConnection; -use crate::errors; - -use dim_database::user::User; -use serde::Serialize; - -use dim_database::genre::*; - -use tokio::task::spawn_blocking; - -use std::fs; -use std::io; -use std::path::PathBuf; - -use warp::reply; - -pub mod filters { - use dim_database::DbConnection; - - use dim_database::user::User; - use warp::reject; - use warp::Filter; - use warp::Rejection; - - use crate::routes::global_filters::with_auth; - - use super::super::global_filters::with_state; - use serde::Deserialize; - - pub fn get_directory_structure( - conn: DbConnection, - ) -> impl Filter + Clone { - warp::path!("api" / "v1" / "filebrowser" / ..) - .and(warp::path::tail()) - .and(with_auth(conn)) - .and_then(|tail: warp::path::Tail, user: User| async move { - let decoded_path = percent_encoding::percent_decode(tail.as_str().as_bytes()) - .decode_utf8() - .unwrap() - .to_string(); - - super::get_directory_structure(decoded_path.into(), user) - .await - .map_err(|e| reject::custom(e)) - }) - } - - pub fn search( - conn: DbConnection, - ) -> impl Filter + Clone { - #[derive(Deserialize)] - struct SearchArgs { - query: Option, - year: Option, - library_id: Option, - genre: Option, - quick: Option, - } - - warp::path!("api" / "v1" / "search") - .and(warp::get()) - .and(with_auth(conn.clone())) - .and(with_state::(conn)) - .and(warp::query::query::()) - .and_then( - |auth: User, conn: DbConnection, args: SearchArgs| async move { - super::search( - conn, - args.query, - args.year, - args.library_id, - args.genre, - args.quick, - auth, - ) - .await - .map_err(|e| reject::custom(e)) - }, - ) - } -} - -pub fn enumerate_directory>(path: T) -> io::Result> { - let mut dirs: Vec = fs::read_dir(path)? - .into_iter() - .filter_map(|x| x.ok()) - .filter(|x| { - !x.file_name() - .to_str() - .map(|s| s.starts_with('.')) - .unwrap_or(false) - && !x.path().is_file() - }) - .map(|x| { - let path = x.path().to_string_lossy().to_string().replace("\\", "/"); - if cfg!(windows) { - path.replace("C:", "") - } else { - path - } - }) - .collect::>(); - - dirs.sort(); - Ok(dirs) -} - -pub async fn get_directory_structure( - path: PathBuf, - _user: User, -) -> Result { - cfg_if::cfg_if! { - if #[cfg(target_os = "windows")] { - let path_prefix = "C:/"; - } else { - let path_prefix = "/"; - } - } - - let path = if path.starts_with(path_prefix) { - path - } else { - let mut new_path = PathBuf::new(); - new_path.push(path_prefix); - new_path.push(path); - new_path - }; - - Ok(reply::json( - &spawn_blocking(|| enumerate_directory(path)) - .await - .unwrap()?, - )) -} - -pub async fn search( - conn: DbConnection, - query: Option, - year: Option, - _library_id: Option, - genre: Option, - _quick: Option, - _user: User, -) -> Result { - let mut tx = conn.read().begin().await?; - if let Some(query_string) = query { - let query_string = query_string - .split(' ') - .map(|x| format!("%{}%", x)) - .collect::>() - .as_slice() - .join(" "); - - return search_by_name(&mut tx, &query_string, 15).await; - } - - if let Some(x) = genre { - let genre_id = Genre::get_by_name(&mut tx, x).await?.id; - return search_by_genre(&mut tx, genre_id).await; - } - - if let Some(x) = year { - return search_by_release_year(&mut tx, x as i64).await; - } - - Err(errors::DimError::NotFoundError) -} - -async fn search_by_name( - conn: &mut dim_database::Transaction<'_>, - query: &str, - limit: i64, -) -> Result { - #[derive(Serialize)] - struct Record { - id: i64, - library_id: i64, - name: String, - poster_path: Option, - } - - let data = sqlx::query_as!( - Record, - r#"SELECT _tblmedia.id, library_id, name, assets.local_path as poster_path FROM _tblmedia - LEFT JOIN assets on _tblmedia.poster = assets.id - WHERE NOT media_type = "episode" - AND UPPER(name) LIKE ? - LIMIT ?"#, - query, - limit - ) - .fetch_all(conn) - .await - .map_err(|_| errors::DimError::NotFoundError)?; - - Ok(reply::json(&data)) -} - -async fn search_by_genre( - conn: &mut dim_database::Transaction<'_>, - genre_id: i64, -) -> Result { - #[derive(Serialize)] - struct Record { - id: i64, - library_id: i64, - name: String, - poster_path: Option, - } - - let data = sqlx::query_as!( - Record, - r#"SELECT _tblmedia.id, library_id, name, assets.local_path as poster_path - FROM _tblmedia - LEFT JOIN assets on _tblmedia.poster = assets.id - INNER JOIN genre_media ON genre_media.media_id = _tblmedia.id - WHERE NOT media_type = "episode" - AND genre_media.genre_id = ? - "#, - genre_id, - ) - .fetch_all(conn) - .await - .map_err(|_| errors::DimError::NotFoundError)?; - - Ok(reply::json(&data)) -} - -async fn search_by_release_year( - conn: &mut dim_database::Transaction<'_>, - year: i64, -) -> Result { - #[derive(Serialize)] - struct Record { - id: i64, - library_id: i64, - name: String, - poster_path: Option, - } - - let data = sqlx::query_as!( - Record, - r#"SELECT _tblmedia.id, library_id, name, assets.local_path as poster_path - FROM _tblmedia - LEFT JOIN assets on _tblmedia.poster = assets.id - WHERE NOT media_type = "episode" - AND year = ? - "#, - year, - ) - .fetch_all(conn) - .await - .map_err(|_| errors::DimError::NotFoundError)?; - - Ok(warp::reply::json(&data)) -} diff --git a/dim-core/src/routes/host.rs b/dim-core/src/routes/host.rs deleted file mode 100644 index 59e2ebde1..000000000 --- a/dim-core/src/routes/host.rs +++ /dev/null @@ -1,53 +0,0 @@ -//! This module contains the docs and implementation of various host-related API endpoints. -use crate::core::DbConnection; -use crate::errors; -use crate::json; -use dim_database::user::User; -use warp::reply; - -/// # GET `/api/v1/host/admin_exists` -/// Method will hint to the client whether an admin has already been created on this server. -/// -/// # Authentication -/// This method does not require any authentication tokens and is fully public. -/// -/// ## Example -/// ```text -/// curl -X GET http://127.0.0.1:8000/api/v1/host/admin_exists -/// ``` -/// -/// # Response -/// ```no_compile -/// { -/// "exists": bool -/// } -/// ``` -pub async fn admin_exists(conn: DbConnection) -> Result { - let mut tx = conn.read().begin().await?; - Ok(reply::json(&json!({ - "exists": !User::get_all(&mut tx).await?.is_empty() - }))) -} - -#[doc(hidden)] -pub(crate) mod filters { - use crate::core::DbConnection; - use warp::reject; - use warp::Filter; - - use super::super::global_filters::with_state; - - #[allow(dead_code)] - pub fn admin_exists( - conn: DbConnection, - ) -> impl Filter + Clone { - warp::path!("api" / "v1" / "host" / "admin_exists") - .and(warp::get()) - .and(with_state(conn)) - .and_then(|conn: DbConnection| async move { - super::admin_exists(conn) - .await - .map_err(|e| reject::custom(e)) - }) - } -} diff --git a/dim-core/src/routes/library.rs b/dim-core/src/routes/library.rs deleted file mode 100644 index b5f336215..000000000 --- a/dim-core/src/routes/library.rs +++ /dev/null @@ -1,439 +0,0 @@ -use crate::core::DbConnection; -use crate::core::EventTx; -use crate::errors; -use crate::json; -use crate::scanner; -use crate::scanner::daemon::FsWatcher; -use crate::tree; - -use dim_database::compact_mediafile::CompactMediafile; -use dim_database::library::InsertableLibrary; -use dim_database::library::Library; -use dim_database::library::MediaType; -use dim_database::media::Media; -use dim_database::mediafile::MediaFile; -use dim_database::user::User; - -use dim_extern_api::tmdb::TMDBMetadataProvider; - -use std::collections::HashMap; -use std::sync::Arc; - -use warp::http::StatusCode; -use warp::reply; - -use serde::Deserialize; -use serde::Serialize; - -use tracing::error; -use tracing::info; -use tracing::instrument; - -use fuzzy_matcher::skim::SkimMatcherV2; -use fuzzy_matcher::FuzzyMatcher; - -pub mod filters { - use warp::reject; - use warp::Filter; - - use super::super::global_filters::with_auth; - use super::super::global_filters::with_db; - - use dim_database::DbConnection; - - use super::super::global_filters::with_state; - use super::*; - - use crate::core::EventTx; - - pub fn library_get( - conn: DbConnection, - ) -> impl Filter + Clone { - warp::path!("api" / "v1" / "library") - .and(warp::get()) - .and(with_db(conn.clone())) - .and(with_auth(conn)) - .and_then(|conn, auth| async move { - super::library_get(conn, auth) - .await - .map_err(|e| reject::custom(e)) - }) - } - - pub fn library_post( - conn: DbConnection, - event_tx: EventTx, - ) -> impl Filter + Clone { - warp::path!("api" / "v1" / "library") - .and(warp::post()) - .and(warp::body::json::()) - .and(with_auth(conn.clone())) - .and(with_state::(event_tx)) - .and(with_state::(conn)) - .and_then( - |new_library: InsertableLibrary, - user: User, - event_tx: EventTx, - conn: DbConnection| async move { - super::library_post(conn, new_library, event_tx, user) - .await - .map_err(|e| reject::custom(e)) - }, - ) - } - - pub fn library_delete( - conn: DbConnection, - ) -> impl Filter + Clone { - warp::path!("api" / "v1" / "library" / i64) - .and(warp::delete()) - .and(with_auth(conn.clone())) - .and(with_state::(conn)) - .and_then(|id: i64, user: User, conn: DbConnection| async move { - super::library_delete(id, user, conn) - .await - .map_err(|e| reject::custom(e)) - }) - } - - pub fn library_get_self( - conn: DbConnection, - ) -> impl Filter + Clone { - warp::path!("api" / "v1" / "library" / i64) - .and(warp::get()) - .and(with_auth(conn.clone())) - .and(with_state::(conn)) - .and_then(|id: i64, user: User, conn: DbConnection| async move { - super::get_self(conn, id, user) - .await - .map_err(|e| reject::custom(e)) - }) - } - - pub fn get_all_of_library( - conn: DbConnection, - ) -> impl Filter + Clone { - warp::path!("api" / "v1" / "library" / i64 / "media") - .and(warp::get()) - .and(with_auth(conn.clone())) - .and(with_state::(conn)) - .and_then(|id: i64, user: User, conn: DbConnection| async move { - super::get_all_library(conn, id, user) - .await - .map_err(|e| reject::custom(e)) - }) - } - - pub fn get_all_unmatched_media( - conn: DbConnection, - ) -> impl Filter + Clone { - #[derive(Deserialize)] - struct Args { - search: Option, - } - - warp::path!("api" / "v1" / "library" / i64 / "unmatched") - .and(warp::get()) - .and(with_auth(conn.clone())) - .and(with_state::(conn)) - .and(warp::filters::query::query::()) - .and_then( - |id: i64, user: User, conn: DbConnection, Args { search }: Args| async move { - super::get_all_unmatched_media(conn, id, user, search) - .await - .map_err(|e| reject::custom(e)) - }, - ) - } -} - -/// # GET `/api/v1/library` -/// Method maps to `GET /api/v1/library` and returns a list of all libraries in te database. -/// -/// # Request -/// An authenticated request must be made to this endpoint. No other inputs are required. -/// -/// ## Example -/// ```text -/// curl -X GET http://127.0.0.1:8000/api/v1/library -H "Authroization: ...." -/// ``` -/// -/// # Response -/// This method will return `200 OK` as well as a JSON payload of the following format: -/// ```json -/// [ -/// { -/// "id": number, -/// "name": string, -/// "media_type": "movie" | "tv", -/// "media_count": number, -/// }, -/// ... -/// ] -/// ``` -pub async fn library_get( - conn: DbConnection, - _user: User, -) -> Result { - let mut tx = conn.read().begin().await?; - - let mut libraries = Library::get_all(&mut tx).await; - libraries.sort_by(|a, b| a.name.cmp(&b.name)); - - let mut reply = vec![]; - - for library in libraries.into_iter() { - let library_size = Library::get_size(&mut tx, library.id).await?; - - reply.push(json!({ - "id": library.id, - "name": library.name, - "media_type": library.media_type, - "media_count": library_size, - })); - } - - Ok(reply::json(&reply)) -} - -/// Method maps to `POST /api/v1/library`, it adds a new library to the database, starts a new -/// scanner for it, then dispatches a event to all clients notifying them that a new library has -/// been created. This method can only be accessed by authenticated users. Method returns 200 OK -/// -/// # Arguments -/// * `conn` - database connection -/// * `new_library` - new library information posted by client -/// * `log` - logger -/// * `_user` - Auth middleware -pub async fn library_post( - mut conn: DbConnection, - new_library: InsertableLibrary, - event_tx: EventTx, - _user: User, -) -> Result { - let mut lock = conn.writer().lock_owned().await; - let mut tx = dim_database::write_tx(&mut lock).await?; - let id = new_library.insert(&mut tx).await?; - tx.commit().await?; - drop(lock); - - let tx_clone = event_tx.clone(); - - let provider = TMDBMetadataProvider::new("38c372f5bc572c8aadde7a802638534e"); - - let provider = match new_library.media_type { - MediaType::Movie => Arc::new(provider.movies()) as Arc<_>, - MediaType::Tv => Arc::new(provider.tv_shows()) as Arc<_>, - _ => unreachable!(), - }; - - let mut fs_watcher = FsWatcher::new( - conn.clone(), - id, - new_library.media_type, - tx_clone.clone(), - Arc::clone(&provider), - ); - - tokio::spawn(async move { fs_watcher.start_daemon().await }); - tokio::spawn(async move { scanner::start(&mut conn, id, tx_clone, provider).await }); - - Ok(reply::json(&json!({ "id": id }))) -} - -/// Method mapped to `DELETE /api/v1/library/` is used to delete a library from the database. -/// It deletes the database based on the parameter `id`, then dispatches a event notifying all -/// clients that the database with this id has been removed. Method can only be accessed by -/// authenticated users. -/// -/// # Arguments: -/// * `conn` - database connection -/// * `id` - id of the library we want to delete -/// * `event_tx` - channel over which to dispatch events -/// * `_user` - Auth middleware -// NOTE: Should we only allow the owner to add/remove libraries? -#[instrument(err, skip(conn, _user), fields(auth.user = _user.username.as_str()))] -pub async fn library_delete( - id: i64, - _user: User, - conn: DbConnection, -) -> Result { - // First we mark the library as scheduled for deletion which will make the library and all its - // content hidden. This is necessary because huge libraries take a long time to delete. - { - let mut lock = conn.writer().lock_owned().await; - let mut tx = dim_database::write_tx(&mut lock).await?; - if Library::mark_hidden(&mut tx, id).await? < 1 { - return Err(errors::DimError::LibraryNotFound); - } - tx.commit().await?; - } - - let delete_lib_fut = async move { - let inner = async { - let mut lock = conn.writer().lock_owned().await; - let mut tx = dim_database::write_tx(&mut lock).await?; - - Library::delete(&mut tx, id).await?; - Media::delete_by_lib_id(&mut tx, id).await?; - MediaFile::delete_by_lib_id(&mut tx, id).await?; - - tx.commit().await?; - - Ok::<_, dim_database::error::DatabaseError>(()) - }; - - if let Err(e) = inner.await { - error!(reason = ?e, "Failed to delete library and its content."); - } else { - info!("Deleted library"); - } - }; - - tokio::spawn(delete_lib_fut); - - Ok(StatusCode::NO_CONTENT) -} - -/// Method mapped to `GET /api/v1/library/` returns info about the library with the supplied -/// id. Method can only be accessed by authenticated users. -/// -/// # Arguments -/// * `conn` - database connection -/// * `id` - id of the library we want info of -/// * `_user` - Auth middleware -pub async fn get_self( - conn: DbConnection, - id: i64, - _user: User, -) -> Result { - let mut tx = conn.read().begin().await?; - Ok(reply::json(&Library::get_one(&mut tx, id).await?)) -} - -/// Method mapped to `GET /api/v1/library//media` returns all the movies/tv shows that belong -/// to the library with the id supplied. Method can only be accessed by authenticated users. -/// -/// # Arguments -/// * `conn` - database connection -/// * `id` - id of the library we want media of -/// * `_user` - Auth middleware -pub async fn get_all_library( - conn: DbConnection, - id: i64, - _user: User, -) -> Result { - let mut result = HashMap::new(); - let mut tx = conn.read().begin().await?; - let lib = Library::get_one(&mut tx, id).await?; - - #[derive(Serialize)] - struct Record { - id: i64, - name: String, - poster_path: Option, - } - - let mut data = sqlx::query_as!( - Record, - r#"SELECT _tblmedia.id, name, assets.local_path as poster_path FROM _tblmedia - LEFT JOIN assets ON _tblmedia.poster = assets.id - WHERE library_id = ? AND NOT media_type = "episode""#, - id - ) - .fetch_all(&mut tx) - .await - .map_err(|_| errors::DimError::NotFoundError)?; - - data.sort_by(|a, b| a.name.cmp(&b.name)); - - result.insert(lib.name, data); - - Ok(reply::json(&result)) -} - -/// Method mapped to `GET` /api/v1/library//unmatched` returns a list of all unmatched medias -/// to be displayed in the library pages. -/// -/// # Arguments -/// * `conn` - database connection -/// * `id` - id of the library -/// * `_user` - auth middleware -/// * `search` - query to fuzzy match against -// NOTE: construct_standard on a mediafile will yield buggy deltas -pub async fn get_all_unmatched_media( - conn: DbConnection, - id: i64, - _user: User, - search: Option, -) -> Result { - let mut tx = conn.read().begin().await?; - - let mut files = CompactMediafile::unmatched_for_library(&mut tx, id) - .await - .map_err(|_| errors::DimError::NotFoundError)?; - - // we want to pre-sort to ensure our tree is somewhat ordered. - files.sort_by(|a, b| a.target_file.cmp(&b.target_file)); - - if let Some(search) = search { - let matcher = SkimMatcherV2::default(); - - let mut matched_files = files - .into_iter() - .filter_map(|x| { - let file_string = x.target_file.to_string_lossy(); - - matcher - .fuzzy_match(&file_string, &search) - .map(|score| (x, score)) - }) - .collect::>(); - - matched_files.sort_by(|(_, a), (_, b)| b.cmp(&a)); - - files = matched_files.into_iter().map(|(file, _)| file).collect(); - } - - let count = files.len(); - - #[derive(Serialize)] - struct Record { - id: i64, - name: String, - duration: Option, - file: String, - } - - let entry = tree::Entry::build_with( - files, - |x| { - x.target_file - .iter() - .map(|x| x.to_string_lossy().to_string()) - .collect() - }, - |k, v| Record { - id: v.id, - name: v.name, - duration: v.duration, - file: k.to_string(), - }, - ); - - #[derive(Serialize)] - struct Response { - count: usize, - files: Vec>, - } - - let entries = match entry { - tree::Entry::Directory { files, .. } => files, - _ => unreachable!(), - }; - - Ok(reply::json(&Response { - files: entries, - count, - })) -} diff --git a/dim-core/src/routes/mediafile.rs b/dim-core/src/routes/mediafile.rs deleted file mode 100644 index 993015f76..000000000 --- a/dim-core/src/routes/mediafile.rs +++ /dev/null @@ -1,196 +0,0 @@ -use crate::core::DbConnection; -use crate::errors; -use crate::errors::ErrorStatusCode; -use crate::scanner::movie; -use crate::scanner::parse_filenames; -use crate::scanner::tv_show; -use crate::scanner::MediaMatcher; -use crate::scanner::WorkUnit; - -use super::media::MOVIES_PROVIDER; -use super::media::TV_PROVIDER; - -use dim_database::library::MediaType; -use dim_database::mediafile::MediaFile; -use dim_database::user::User; - -use dim_extern_api::ExternalQueryIntoShow; - -use serde::Serialize; -use serde_json::json; -use std::sync::Arc; - -use warp::reject::Reject; -use warp::reply; - -use http::StatusCode; -use tracing::error; -use tracing::info; - -#[derive(Clone, Debug, thiserror::Error, Serialize, displaydoc::Display)] -pub enum Error { - /// Supplied no mediafiles when rematching. - NoMediafiles, -} - -impl Reject for Error {} - -impl ErrorStatusCode for Error { - fn status_code(&self) -> StatusCode { - match self { - &Error::NoMediafiles => StatusCode::BAD_REQUEST, - } - } -} - -pub mod filters { - use dim_database::user::User; - use warp::reject; - use warp::Filter; - - use crate::routes::global_filters::with_auth; - - use super::super::global_filters::with_state; - use dim_database::DbConnection; - - use serde::Deserialize; - - pub fn get_mediafile_info( - conn: DbConnection, - ) -> impl Filter + Clone { - warp::path!("api" / "v1" / "mediafile" / i64) - .and(warp::get()) - .and(with_auth(conn.clone())) - .and(with_state::(conn)) - .and_then(|id: i64, auth: User, conn: DbConnection| async move { - super::get_mediafile_info(conn, id, auth) - .await - .map_err(|e| reject::custom(e)) - }) - } - - pub fn rematch_mediafile( - conn: DbConnection, - ) -> impl Filter + Clone { - #[derive(Deserialize)] - #[serde(deny_unknown_fields)] - struct RouteArgs { - tmdb_id: String, - media_type: String, - mediafiles: Vec, - } - - warp::path!("api" / "v1" / "mediafile" / "match") - .and(warp::patch()) - .and(with_auth(conn.clone())) - .and(with_state::(conn)) - .and(warp::body::json::()) - .and_then( - |_auth: User, - conn: DbConnection, - RouteArgs { - tmdb_id, - media_type, - mediafiles, - }: RouteArgs| async move { - super::rematch_mediafile(conn, mediafiles, tmdb_id, media_type) - .await - .map_err(reject::custom) - }, - ) - } -} - -/// Method mapped to `GET /api/v1/mediafile/` is used to get information about a mediafile by its id. -/// -/// # Arguments -/// * `id` - id of the mediafile we want info about -pub async fn get_mediafile_info( - conn: DbConnection, - id: i64, - _user: User, -) -> Result { - let mut tx = conn.read().begin().await?; - let mediafile = MediaFile::get_one(&mut tx, id) - .await - .map_err(|_| errors::DimError::NotFoundError)?; - - Ok(reply::json(&json!({ - "id": mediafile.id, - "media_id": mediafile.media_id, - "library_id": mediafile.library_id, - "raw_name": mediafile.raw_name, - }))) -} - -/// Method mapped to `PATCH /api/v1/mediafile/match` used to match a unmatched(orphan) -/// mediafile to a tmdb id. -/// -/// # Arguments -/// * `conn` - database connection -/// * `log` - logger -/// * `event_tx` - websocket channel over which we dispatch a event notifying other clients of the -/// new metadata -/// -/// * `mediafiles` - ids of the orphan mediafiles we want to rematch -/// * `tmdb_id` - the tmdb id of the proper metadata we want to fetch for the media -pub async fn rematch_mediafile( - conn: DbConnection, - mediafiles: Vec, - external_id: String, - media_type: String, -) -> Result { - if mediafiles.is_empty() { - return Err(Error::NoMediafiles.into()); - } - - let Ok(media_type): Result = media_type.to_lowercase().try_into() else { - return Err(errors::DimError::InvalidMediaType); - }; - - let mut tx = conn.read().begin().await?; - - // FIXME: impl FromStr for MediaType - let provider: Arc = match media_type { - MediaType::Movie => (*MOVIES_PROVIDER).clone(), - MediaType::Tv => (*TV_PROVIDER).clone(), - _ => return Err(errors::DimError::InvalidMediaType), - }; - - let matcher = match media_type { - MediaType::Movie => Arc::new(movie::MovieMatcher) as Arc, - MediaType::Tv => Arc::new(tv_show::TvMatcher) as Arc, - _ => unreachable!(), - }; - - info!(?media_type, mediafiles = ?&mediafiles, "Rematching mediafiles"); - - let mediafiles = MediaFile::get_many(&mut tx, &mediafiles).await?; - - provider.search_by_id(&external_id).await.map_err(|e| { - error!(?e, "Failed to search for tmdb_id when rematching."); - errors::DimError::ExternalSearchError(e) - })?; - - let mut lock = conn.writer().lock_owned().await; - let mut tx = dim_database::write_tx(&mut lock).await?; - - for mediafile in mediafiles { - let Some((_, metadata)) = parse_filenames(IntoIterator::into_iter([&mediafile.target_file])).pop() else { - continue; - }; - - matcher - .match_to_id( - &mut tx, - provider.clone(), - WorkUnit(mediafile.clone(), metadata), - &external_id, - ) - .await?; - } - - tx.commit().await?; - - Ok(StatusCode::OK) -} diff --git a/dim-core/src/routes/mod.rs b/dim-core/src/routes/mod.rs index e5323fc12..eb49eaab8 100644 --- a/dim-core/src/routes/mod.rs +++ b/dim-core/src/routes/mod.rs @@ -17,19 +17,9 @@ //! //! [`DatabaseError`]: crate::errors::DimError::DatabaseError #![warn(warnings)] -pub mod auth; -pub mod dashboard; -pub mod general; -pub mod host; -pub mod library; -pub mod media; -pub mod mediafile; -pub mod rematch_media; pub mod settings; pub mod statik; pub mod stream; -pub mod tv; -pub mod user; #[doc(hidden)] pub mod global_filters { diff --git a/dim-core/src/routes/rematch_media.rs b/dim-core/src/routes/rematch_media.rs deleted file mode 100644 index fe5152fd3..000000000 --- a/dim-core/src/routes/rematch_media.rs +++ /dev/null @@ -1,139 +0,0 @@ -use crate::core::DbConnection; -use crate::core::EventTx; -use crate::errors::*; -use crate::scanner::movie; -use crate::scanner::parse_filenames; -use crate::scanner::tv_show; -use crate::scanner::MediaMatcher; -use crate::scanner::WorkUnit; - -use super::media::MOVIES_PROVIDER; -use super::media::TV_PROVIDER; - -use std::sync::Arc; - -use dim_database::library::MediaType; -use dim_database::mediafile::MediaFile; - -use dim_extern_api::ExternalQueryIntoShow; - -use tracing::error; -use tracing::info; - -use http::status::StatusCode; - -pub mod filters { - use crate::core::EventTx; - use crate::routes::global_filters::with_auth; - use crate::routes::global_filters::with_state; - use dim_database::user::User; - use dim_database::DbConnection; - use serde::Deserialize; - - use warp::reject; - use warp::Filter; - - pub fn rematch_media_by_id( - conn: DbConnection, - event_tx: EventTx, - ) -> impl Filter + Clone { - #[derive(Deserialize)] - struct RouteArgs { - external_id: String, - media_type: String, - } - - warp::path!("api" / "v1" / "media" / i64 / "match") - .and(warp::patch()) - .and(warp::query::query::()) - .and(with_state(conn.clone())) - .and(with_state(event_tx)) - .and(with_auth(conn)) - .and_then( - |id, - RouteArgs { - external_id, - media_type, - }: RouteArgs, - conn: DbConnection, - event_tx: EventTx, - _: User| async move { - super::rematch_media(conn, event_tx, id, external_id, media_type) - .await - .map_err(|e| reject::custom(e)) - }, - ) - } -} - -/// FIXME: Merge this function into rematch_mediafile as theyre functionally the same fucking thing -/// except here we are matching whole media objects rather than mediafiles. This was a different -/// api in the past because the scanner wasnt intelligent enough to decouple and clean up stale -/// media objects but now that it can do that we can just rematch a matched mediafile and it will -/// work as it should. -/// -/// TODO: Add ability to specify overrides like episode and season ranges. -pub async fn rematch_media( - conn: DbConnection, - _event_tx: EventTx, - id: i64, - external_id: String, - media_type: String, -) -> Result { - let Ok(media_type) = media_type.to_lowercase().try_into() else { - return Err(DimError::InvalidMediaType); - }; - - let provider: Arc = match media_type { - MediaType::Movie => (*MOVIES_PROVIDER).clone(), - MediaType::Tv => (*TV_PROVIDER).clone(), - _ => return Err(DimError::InvalidMediaType), - }; - - let matcher = match media_type { - MediaType::Movie => Arc::new(movie::MovieMatcher) as Arc, - MediaType::Tv => Arc::new(tv_show::TvMatcher) as Arc, - _ => unreachable!(), - }; - - let mut tx = conn.read().begin().await?; - - let mediafiles = match media_type { - MediaType::Movie => MediaFile::get_of_media(&mut tx, id).await?, - MediaType::Tv => MediaFile::get_of_show(&mut tx, id).await?, - _ => unreachable!(), - }; - - let mediafile_ids = mediafiles.iter().map(|x| x.id).collect::>(); - - info!(?media_type, mediafiles = ?&mediafile_ids, "Rematching media"); - - provider.search_by_id(&external_id).await.map_err(|e| { - error!(?e, "Failed to search for tmdb_id when rematching."); - DimError::ExternalSearchError(e) - })?; - - drop(tx); - - let mut lock = conn.writer().lock_owned().await; - let mut tx = dim_database::write_tx(&mut lock).await?; - - for mediafile in mediafiles { - let Some((_, metadata)) = parse_filenames(IntoIterator::into_iter([&mediafile.target_file])).pop() else { - continue; - }; - - matcher - .match_to_id( - &mut tx, - provider.clone(), - WorkUnit(mediafile.clone(), metadata), - &external_id, - ) - .await?; - } - - tx.commit().await?; - - Ok(StatusCode::OK) -} diff --git a/dim-core/src/routes/settings.rs b/dim-core/src/routes/settings.rs index beb7ae229..d2a161032 100644 --- a/dim-core/src/routes/settings.rs +++ b/dim-core/src/routes/settings.rs @@ -1,14 +1,5 @@ -use crate::core::DbConnection; -use crate::errors; use crate::utils::ffpath; -use dim_database::user::UpdateableUser; -use dim_database::user::User; -use dim_database::user::UserSettings; - -use serde::Deserialize; -use serde::Serialize; - use std::error::Error; use std::fs::File; use std::fs::OpenOptions; @@ -19,7 +10,9 @@ use std::sync::Mutex; use once_cell::sync::Lazy; use once_cell::sync::OnceCell; -use warp::reply; +use serde::Deserialize; +use serde::Serialize; + #[derive(Serialize, Deserialize, Clone)] pub struct GlobalSettings { @@ -112,121 +105,4 @@ pub fn set_global_settings(settings: GlobalSettings) -> Result<(), Box impl Filter + Clone { - warp::path!("api" / "v1" / "user" / "settings") - .and(warp::get()) - .and(with_auth(conn.clone())) - .and(with_state::(conn)) - .and_then(|auth: User, conn: DbConnection| async move { - super::get_user_settings(conn, auth) - .await - .map_err(|e| reject::custom(e)) - }) - } - - pub fn post_user_settings( - conn: DbConnection, - ) -> impl Filter + Clone { - warp::path!("api" / "v1" / "user" / "settings") - .and(warp::post()) - .and(warp::body::json::()) - .and(with_auth(conn.clone())) - .and(with_state::(conn)) - .and_then( - |settings: UserSettings, auth: User, conn: DbConnection| async move { - println!("saving user settings"); - super::post_user_settings(conn, auth, settings) - .await - .map_err(|e| reject::custom(e)) - }, - ) - } - - pub fn get_global_settings( - conn: DbConnection, - ) -> impl Filter + Clone { - warp::path!("api" / "v1" / "host" / "settings") - .and(warp::get()) - .and(with_auth(conn)) - .and_then(|auth: User| async move { - super::http_get_global_settings(auth) - .await - .map_err(|e| reject::custom(e)) - }) - } - - pub fn set_global_settings( - conn: DbConnection, - ) -> impl Filter + Clone { - warp::path!("api" / "v1" / "host" / "settings") - .and(warp::post()) - .and(warp::body::json::()) - .and(with_auth(conn)) - .and_then(|settings: super::GlobalSettings, auth: User| async move { - super::http_set_global_settings(auth, settings) - .await - .map_err(|e| reject::custom(e)) - }) - } -} - -pub async fn get_user_settings( - db: DbConnection, - user: User, -) -> Result { - let mut tx = db.read().begin().await?; - Ok(reply::json(&User::get_by_id(&mut tx, user.id).await?.prefs)) -} - -pub async fn post_user_settings( - db: DbConnection, - user: User, - new_settings: UserSettings, -) -> Result { - let mut lock = db.writer().lock_owned().await; - let mut tx = dim_database::write_tx(&mut lock).await?; - let update_user = UpdateableUser { - prefs: Some(new_settings.clone()), - }; - - update_user.update(&mut tx, user.id).await?; - - tx.commit().await?; - drop(lock); - - Ok(reply::json(&new_settings)) -} - -// TODO: Hide secret key. -pub async fn http_get_global_settings(_user: User) -> Result { - Ok(reply::json(&get_global_settings())) -} - -// TODO: Disallow setting secret key over http. -pub async fn http_set_global_settings( - user: User, - new_settings: GlobalSettings, -) -> Result { - if user.has_role("owner") { - set_global_settings(new_settings).unwrap(); - return Ok(reply::json(&get_global_settings())); - } - - Err(errors::DimError::Unauthorized) -} +} \ No newline at end of file diff --git a/dim-core/src/routes/tv.rs b/dim-core/src/routes/tv.rs deleted file mode 100644 index 9cb048a9b..000000000 --- a/dim-core/src/routes/tv.rs +++ /dev/null @@ -1,268 +0,0 @@ -use crate::core::DbConnection; -use crate::errors; - -use dim_database::user::User; - -use dim_database::episode::{Episode, UpdateEpisode}; -use dim_database::season::{Season, UpdateSeason}; - -use warp::http::status::StatusCode; -use warp::reply; - -pub mod filters { - use warp::reject; - use warp::Filter; - use warp::Rejection; - - use super::super::global_filters::with_auth; - use super::super::global_filters::with_state; - use dim_database::episode::UpdateEpisode; - use dim_database::season::UpdateSeason; - use dim_database::user::User; - use dim_database::DbConnection; - - pub fn get_tv_seasons( - conn: DbConnection, - ) -> impl Filter + Clone { - warp::path!("api" / "v1" / "tv" / i64 / "season") - .and(warp::get()) - .and(with_auth(conn.clone())) - .and(with_state::(conn)) - .and_then(|id: i64, auth: User, conn: DbConnection| async move { - super::get_tv_seasons(conn, id, auth) - .await - .map_err(|e| reject::custom(e)) - }) - } - - pub fn get_season_by_id( - conn: DbConnection, - ) -> impl Filter + Clone { - warp::path!("api" / "v1" / "season" / i64) - .and(warp::get()) - .and(with_auth(conn.clone())) - .and(with_state::(conn)) - .and_then(|id: i64, auth: User, conn: DbConnection| async move { - super::get_season_by_id(conn, id, auth) - .await - .map_err(|e| reject::custom(e)) - }) - } - - pub fn patch_season_by_id( - conn: DbConnection, - ) -> impl Filter + Clone { - warp::path!("api" / "v1" / "season" / i64) - .and(warp::patch()) - .and(warp::body::json::()) - .and(with_auth(conn.clone())) - .and(with_state::(conn)) - .and_then( - |id: i64, data: UpdateSeason, auth: User, conn: DbConnection| async move { - super::patch_season_by_id(conn, id, data, auth) - .await - .map_err(|e| reject::custom(e)) - }, - ) - } - - pub fn delete_season_by_id( - conn: DbConnection, - ) -> impl Filter + Clone { - warp::path!("api" / "v1" / "season" / i64) - .and(warp::delete()) - .and(with_auth(conn.clone())) - .and(with_state::(conn)) - .and_then(|id: i64, auth: User, conn: DbConnection| async move { - super::delete_season_by_id(conn, id, auth) - .await - .map_err(|e| reject::custom(e)) - }) - } - - pub fn get_season_episodes( - conn: DbConnection, - ) -> impl Filter + Clone { - warp::path!("api" / "v1" / "season" / i64 / "episodes") - .and(warp::get()) - .and(with_auth(conn.clone())) - .and(with_state::(conn)) - .and_then(|id: i64, auth: User, conn: DbConnection| async move { - super::get_season_episodes(conn, id, auth) - .await - .map_err(reject::custom) - }) - } - - pub fn patch_episode_by_id( - conn: DbConnection, - ) -> impl Filter + Clone { - warp::path!("api" / "v1" / "episode" / i64) - .and(warp::patch()) - .and(warp::body::json::()) - .and(with_auth(conn.clone())) - .and(with_state::(conn)) - .and_then( - |id: i64, data: UpdateEpisode, auth: User, conn: DbConnection| async move { - super::patch_episode_by_id(conn, id, data, auth) - .await - .map_err(reject::custom) - }, - ) - } - - pub fn delete_episode_by_id( - conn: DbConnection, - ) -> impl Filter + Clone { - warp::path!("api" / "v1" / "episode" / i64) - .and(warp::delete()) - .and(with_auth(conn.clone())) - .and(with_state::(conn)) - .and_then(|id: i64, auth: User, conn: DbConnection| async move { - super::delete_episode_by_id(conn, id, auth) - .await - .map_err(reject::custom) - }) - } -} - -/// Method mapped to `GET /api/v1/tv//season` returns all seasons for TV Show mapped to the id -/// passed in. -/// -/// # Arguments -/// * `id` - id of the tv show we want info about -pub async fn get_tv_seasons( - conn: DbConnection, - id: i64, - _user: User, -) -> Result { - let mut tx = conn.read().begin().await?; - Ok(reply::json(&Season::get_all(&mut tx, id).await?)) -} - -/// Method mapped to `GET /api/v1/tv//season/` returns info about the season -/// for tv show by -/// -/// # Arguments -/// * `id` - id of the tv show we want info about -/// * `season_num` - the season we want info about -pub async fn get_season_by_id( - conn: DbConnection, - id: i64, - _user: User, -) -> Result { - let mut tx = conn.read().begin().await?; - Ok(reply::json(&Season::get_by_id(&mut tx, id).await?)) -} - -/// Method mapped to `PATCH /api/v1/tv//season/` allows you to patch in info about -/// the season . -/// -/// # Route Arguments -/// * `id` - the id of the tv show. -/// * `season_num` - the season we want to edit. -/// -/// # Data -/// This route additionally requires you to pass in a json object by the format of -/// `dim_database::season::UpdateSeason`. -pub async fn patch_season_by_id( - conn: DbConnection, - id: i64, - data: UpdateSeason, - _user: User, -) -> Result { - let mut lock = conn.writer().lock_owned().await; - let mut tx = dim_database::write_tx(&mut lock).await?; - data.update(&mut tx, id).await?; - tx.commit().await?; - Ok(StatusCode::NO_CONTENT) -} - -/// Method mapped to `DELETE /api/v1/tv//season/` allows you to delete a season for -/// a particular tv show. -/// -/// # Arguments -/// * `id` - id of the tv show. -/// * `season_num` - the season we want to remove -pub async fn delete_season_by_id( - conn: DbConnection, - id: i64, - _user: User, -) -> Result { - let mut lock = conn.writer().lock_owned().await; - let mut tx = dim_database::write_tx(&mut lock).await?; - Season::delete_by_id(&mut tx, id).await?; - tx.commit().await?; - Ok(StatusCode::OK) -} - -/// Method mapped to `GET /api/v1/episode/` returns information -/// about a episode for a season. -/// -/// # Arguments -/// * `id` - id of the episode. -pub async fn get_season_episodes( - conn: DbConnection, - season_id: i64, - _user: User, -) -> Result { - let mut tx = conn.read().begin().await?; - #[derive(serde::Serialize)] - pub struct Record { - pub id: i64, - pub name: String, - pub thumbnail_url: Option, - pub episode: i64, - } - - let result = sqlx::query_as!(Record, - r#"SELECT episode.id as "id!", _tblmedia.name, assets.local_path as thumbnail_url, episode.episode_ as "episode!" - FROM episode - INNER JOIN _tblmedia on _tblmedia.id = episode.id - LEFT JOIN assets ON assets.id = _tblmedia.backdrop - WHERE episode.seasonid = ?"#, - season_id - ).fetch_all(&mut tx).await?; - - Ok(reply::json(&result)) -} - -/// TODO: Move all of these into a unified update interface for media items -/// Method mapped to `PATCH /api/v1/episode/` lets you patch -/// information about a episode. -/// -/// # Arguments -/// * `id` - id of a episode. -/// -/// # Data -/// This route additionally requires you to pass in a json object by the format of -/// `dim_database::episode::UpdateEpisode`. -pub async fn patch_episode_by_id( - conn: DbConnection, - id: i64, - episode: UpdateEpisode, - _user: User, -) -> Result { - let mut lock = conn.writer().lock_owned().await; - let mut tx = dim_database::write_tx(&mut lock).await?; - episode.update(&mut tx, id).await?; - tx.commit().await?; - Ok(StatusCode::NO_CONTENT) -} - -/// Method mapped to `DELETE /api/v1/episode/` allows you to -/// delete a episode belonging to some season. -/// -/// # Arguments -/// * `id` - id an episode to delete -pub async fn delete_episode_by_id( - conn: DbConnection, - id: i64, - _user: User, -) -> Result { - let mut lock = conn.writer().lock_owned().await; - let mut tx = dim_database::write_tx(&mut lock).await?; - Episode::delete(&mut tx, id).await?; - tx.commit().await?; - Ok(StatusCode::OK) -} diff --git a/dim-core/src/routes/user.rs b/dim-core/src/routes/user.rs deleted file mode 100644 index 8369ef8fe..000000000 --- a/dim-core/src/routes/user.rs +++ /dev/null @@ -1,343 +0,0 @@ -//! This module contains all docs and APIs related to users and user metadata. -use crate::core::DbConnection; -use crate::errors; -use bytes::BufMut; - -use dim_database::asset::Asset; -use dim_database::asset::InsertableAsset; -use dim_database::user::User; - -use http::StatusCode; - -use futures::TryStreamExt; -use uuid::Uuid; - -/// # POST `/api/v1/user/password` -/// Method changes the password for a logged in account. -/// -/// # Request -/// This method accepts a JSON body with the following schema: -/// ```no_compile -/// { -/// "old_password": String, -/// "new_password": String, -/// } -/// ``` -/// The `old_password` field in the JSON payload must be the currently registered password for this -/// user. The `new_password` field is the new password that we want to set. -/// -/// ## Example -/// ```text -/// curl -X POST http://127.0.0.1:8000/api/v1/user/password -H "Content-type: application/json" -/// -H "Authroization: ..." -d '{"old_password": "testPass", "new_password": "newTestPass"}' -/// ``` -/// -/// # Response -/// If the password is successfully changed, the method will simply return `200 0K`. -/// -/// # Errors -/// * [`InvalidCredentials`] - The provided `old_password` is incorrect or the authentication token -/// is invalid. -/// -/// [`InvalidCredentials`]: crate::errors::DimError::InvalidCredentials -pub async fn change_password( - conn: DbConnection, - user: User, - old_password: String, - new_password: String, -) -> Result { - let mut lock = conn.writer().lock_owned().await; - let mut tx = dim_database::write_tx(&mut lock).await?; - - let user = User::authenticate(&mut tx, user.username, old_password) - .await - .map_err(|_| errors::DimError::InvalidCredentials)?; - - user.set_password(&mut tx, new_password).await?; - - tx.commit().await?; - - Ok(StatusCode::OK) -} - -/// # DELETE `/api/v1/user` -/// Method deletes the currently logged in account. -/// -/// # Request -/// This method accepts a JSON body with the following schema: -/// ```no_compile -/// { -/// "password": String, -/// } -/// ``` -/// The `password` field in the JSON payload must be the currently registered password for this -/// user. This is required as a safety mechanism to avoid accidental account deletion. -/// -/// ## Example -/// ```text -/// curl -X DELETE http://127.0.0.1:8000/api/v1/user -H "Content-type: application/json" -H "Authroization: ..." -/// -d '{"password": "testPass"}' -/// ``` -/// -/// # Response -/// If the account is successfully deleted, the method will simply return `200 0K`. -/// -/// # SAFETY and caveats -/// Because Dim uses JWTs for authorization, deleting an account doesnt mean the authorization -/// token is also revoked as JWTs are stateless by design. Because of this, users must ensure that -/// the token is cleared from memory and is not *EVER* reused. -/// -/// # Errors -/// * [`InvalidCredentials`] - The provided `old_password` is incorrect or the authentication token -/// is invalid. -/// -/// [`InvalidCredentials`]: crate::errors::DimError::InvalidCredentials -pub async fn delete( - conn: DbConnection, - user: User, - password: String, -) -> Result { - let mut lock = conn.writer().lock_owned().await; - let mut tx = dim_database::write_tx(&mut lock).await?; - - let user = User::authenticate(&mut tx, user.username, password) - .await - .map_err(|_| errors::DimError::InvalidCredentials)?; - - User::delete(&mut tx, user.id).await?; - - tx.commit().await?; - - Ok(StatusCode::OK) -} - -/// # POST `/api/v1/user/username` -/// Method changes the username of the current account. -/// -/// # Request -/// This method accepts a JSON payload with the following schema: -/// ```no_compile -/// { -/// "new_username": String -/// } -/// ``` -/// -/// ## Example -/// ```text -/// curl -X POST http://127.0.0.1:8000/api/v1/user/username -H "Content-type: application/json" -H -/// "Authorization: ..." -d '{"new_username": "testUsername"}' -/// ``` -/// -/// # Response -/// If the username is successfully changed this method will simply return `200 OK`. -/// -/// # Errors -/// * [`UsernameNotAvailable`] - THe provided username has already been claimed by another user. -/// -/// [`UsernameNotAvailable`]: crate::errors::DimError::UsernameNotAvailable -pub async fn change_username( - conn: DbConnection, - user: User, - new_username: String, -) -> Result { - let mut lock = conn.writer().lock_owned().await; - let mut tx = dim_database::write_tx(&mut lock).await?; - if User::get(&mut tx, &new_username).await.is_ok() { - return Err(errors::DimError::UsernameNotAvailable); - } - - User::set_username(&mut tx, user.username.clone(), new_username).await?; - tx.commit().await?; - - Ok(StatusCode::OK) -} - -/// # POST `/api/v1/user/avatar` -/// This method can be used to set a new avatar for a user. -/// -/// # Request -/// This method accepts a multipart file upload. Only `jpg` and `png` files are supported. -/// -/// ## Example -/// ```text -/// curl -X POST http://127.0.0.1:8000/api/v1/user/avatar -H "Authorization: ..." --form -/// file='@newAvatar.png' -/// ``` -/// -/// # Response -/// If the avatar is successfully uploaded, this route will return `200 OK`. -/// -/// # Errors -/// * [`UploadFailed`] - No file has been uploaded correctly or the `file` form field has not been -/// * [`UnsupportedFile`] - The file uploaded is not supported. -/// found. -/// -/// [`UploadFailed`]: crate::errors::DimError::UploadFailed -/// [`UnsupportedFile`]: crate::errors::DimError::UnsupportedFile -pub async fn upload_avatar( - conn: DbConnection, - user: User, - form: warp::multipart::FormData, -) -> Result { - let parts: Vec = form - .try_collect() - .await - .map_err(|_e| errors::DimError::UploadFailed)?; - - let mut lock = conn.writer().lock_owned().await; - let mut tx = dim_database::write_tx(&mut lock).await?; - let asset = if let Some(p) = parts.into_iter().filter(|x| x.name() == "file").next() { - process_part(&mut tx, p).await - } else { - Err(errors::DimError::UploadFailed) - }; - - User::set_picture(&mut tx, user.id, asset?.id).await?; - tx.commit().await?; - - Ok(StatusCode::OK) -} - -#[doc(hidden)] -pub async fn process_part( - conn: &mut dim_database::Transaction<'_>, - p: warp::multipart::Part, -) -> Result { - if p.name() != "file" { - return Err(errors::DimError::UploadFailed); - } - - let file_ext = match p.content_type() { - Some("image/jpeg" | "image/jpg") => "jpg", - Some("image/png") => "png", - _ => return Err(errors::DimError::UnsupportedFile), - }; - - let contents = p - .stream() - .try_fold(Vec::new(), |mut vec, data| { - vec.put(data); - async move { Ok(vec) } - }) - .await - .map_err(|_| errors::DimError::UploadFailed)?; - - let local_file = format!("{}.{}", Uuid::new_v4().to_string(), file_ext); - let local_path = format!( - "{}/{}", - crate::core::METADATA_PATH.get().unwrap(), - &local_file - ); - - tokio::fs::write(&local_path, contents) - .await - .map_err(|_| errors::DimError::UploadFailed)?; - - Ok(InsertableAsset { - local_path: local_file, - file_ext: file_ext.into(), - ..Default::default() - } - .insert_local_asset(conn) - .await?) -} - -#[doc(hidden)] -pub mod filters { - use crate::core::DbConnection; - use serde::Deserialize; - - use dim_database::user::User; - - use warp::reject; - use warp::Filter; - - use super::super::global_filters::with_auth; - use super::super::global_filters::with_state; - - pub fn change_password( - conn: DbConnection, - ) -> impl Filter + Clone { - #[derive(Deserialize)] - pub struct Params { - old_password: String, - new_password: String, - } - - warp::path!("api" / "v1" / "user" / "password") - .and(warp::patch()) - .and(with_auth(conn.clone())) - .and(warp::body::json::()) - .and(with_state(conn)) - .and_then( - |user: User, - Params { - old_password, - new_password, - }: Params, - conn: DbConnection| async move { - super::change_password(conn, user, old_password, new_password) - .await - .map_err(|e| reject::custom(e)) - }, - ) - } - - pub fn delete( - conn: DbConnection, - ) -> impl Filter + Clone { - #[derive(Deserialize)] - pub struct Params { - password: String, - } - - warp::path!("api" / "v1" / "user" / "delete") - .and(warp::delete()) - .and(with_auth(conn.clone())) - .and(warp::body::json::()) - .and(with_state(conn)) - .and_then( - |auth: User, Params { password }: Params, conn: DbConnection| async move { - super::delete(conn, auth, password) - .await - .map_err(|e| reject::custom(e)) - }, - ) - } - - pub fn change_username( - conn: DbConnection, - ) -> impl Filter + Clone { - #[derive(Deserialize)] - pub struct Params { - new_username: String, - } - warp::path!("api" / "v1" / "user" / "username") - .or(warp::path!("api" / "v1" / "auth" / "username")) - .unify() - .and(warp::patch()) - .and(with_auth(conn.clone())) - .and(warp::body::json::()) - .and(with_state(conn)) - .and_then(|user, Params { new_username }: Params, conn| async move { - super::change_username(conn, user, new_username) - .await - .map_err(|e| reject::custom(e)) - }) - } - - pub fn upload_avatar( - conn: DbConnection, - ) -> impl Filter + Clone { - warp::path!("api" / "v1" / "user" / "avatar") - .and(warp::post()) - .and(with_auth(conn.clone())) - .and(warp::multipart::form().max_length(5_000_000)) - .and(with_state(conn)) - .and_then(|user, form, conn| async move { - super::upload_avatar(conn, user, form) - .await - .map_err(|e| reject::custom(e)) - }) - } -} diff --git a/dim-web/Cargo.toml b/dim-web/Cargo.toml index b08bd2577..add1f974c 100644 --- a/dim-web/Cargo.toml +++ b/dim-web/Cargo.toml @@ -15,7 +15,8 @@ nightfall = { git = "https://github.com/Dusk-Labs/nightfall", tag = "0.3.12-rc4" "ssa_transmux", ] } -axum = { version = "0.6.12", features = ["ws", "http2", "macros"] } +axum = { version = "0.6.20", features = ["ws", "http2", "macros", "multipart"] } +cfg-if = "1.0.0" chrono = { version = "0.4.19", features = ["serde"] } displaydoc = "0.2.3" futures = "0.3.14" diff --git a/dim-web/src/error.rs b/dim-web/src/error.rs index 255c6c681..8f0c87fad 100644 --- a/dim-web/src/error.rs +++ b/dim-web/src/error.rs @@ -1,5 +1,6 @@ -use axum::response::{IntoResponse, Response}; -use dim_core::errors::{DimError, ErrorStatusCode}; +use axum::response::IntoResponse; +use axum::response::Response; +use dim_core::errors::DimError; use dim_database::DatabaseError; use http::StatusCode; @@ -44,7 +45,6 @@ impl IntoResponse for DimErrorWrapper { E::UnsupportedFile | E::InvalidMediaType | E::MissingFieldInBody { .. } => { StatusCode::NOT_ACCEPTABLE } - E::MediafileRouteError(ref e) => e.status_code(), }; let resp = serde_json::json!({ diff --git a/dim-web/src/lib.rs b/dim-web/src/lib.rs index 39d78d18c..5356a00e8 100644 --- a/dim-web/src/lib.rs +++ b/dim-web/src/lib.rs @@ -1,5 +1,3 @@ -#![deny(warnings)] - use std::future::IntoFuture; use std::net::SocketAddr; @@ -7,13 +5,17 @@ pub mod routes; pub mod tree; pub use axum; -use axum::extract::{ConnectInfo, State}; +use axum::extract::ConnectInfo; +use axum::extract::DefaultBodyLimit; +use axum::extract::State; use axum::response::Response; -use axum::routing::{get, post, delete}; +use axum::routing::delete; +use axum::routing::get; +use axum::routing::patch; +use axum::routing::post; use axum::Router; use dim_core::core::EventTx; -use dim_core::routes::dashboard; use dim_core::stream_tracking::StreamTracking; use dim_database::DbConnection; @@ -73,33 +75,52 @@ fn auth_routes(AppState { conn, .. }: AppState) -> Router { ) } -fn media_routes(AppState { conn, event_tx, .. }: AppState) -> Router { - Router::new().route_service( - "/api/v1/media/*path", - warp::service({ - dim_core::routes::media::filters::get_media_by_id(conn.clone()) - .or(dim_core::routes::media::filters::get_media_files( - conn.clone(), - )) - .or(dim_core::routes::media::filters::update_media_by_id( - conn.clone(), - )) - .or(dim_core::routes::media::filters::delete_media_by_id( - conn.clone(), - )) - .or(dim_core::routes::media::filters::tmdb_search(conn.clone())) - .or(dim_core::routes::media::filters::map_progress(conn.clone())) - .or(dim_core::routes::media::filters::get_mediafile_tree( - conn.clone(), - )) - .or( - dim_core::routes::rematch_media::filters::rematch_media_by_id( - conn.clone(), - event_tx.clone(), - ), - ) - }), - ) +fn dashboard_routes(AppState { conn, .. }: AppState) -> Router { + Router::new() + .route( + "/api/v1/dashboard", + get(routes::dashboard::dashboard).with_state(conn.clone()), + ) + .route( + "/api/v1/dashboard/banner", + get(routes::dashboard::banners).with_state(conn.clone()), + ) +} + +fn media_routes(AppState { conn, .. }: AppState) -> Router { + Router::new() + .route( + "/api/v1/media/:id", + get(routes::media::get_media_by_id).with_state(conn.clone()), + ) + .route( + "/api/v1/media/:id/files", + get(routes::media::get_media_files).with_state(conn.clone()), + ) + .route( + "/api/v1/media/:id/tree", + get(routes::media::get_mediafile_tree).with_state(conn.clone()), + ) + .route( + "/api/v1/media/:id", + patch(routes::media::update_media_by_id).with_state(conn.clone()), + ) + .route( + "/api/v1/media/:id", + delete(routes::media::delete_media_by_id).with_state(conn.clone()), + ) + .route( + "/api/v1/media/tmdb_search", + get(routes::media::tmdb_search).with_state(conn.clone()), + ) + .route( + "/api/v1/media/:id/progress", + post(routes::media::map_progress).with_state(conn.clone()), + ) + .route( + "/api/v1/media/:id/match", + patch(routes::media::rematch_media_by_id).with_state(conn.clone()), + ) } fn stream_routes( @@ -149,46 +170,108 @@ fn stream_routes( ) } +fn episode_routes(AppState { conn, .. }: AppState) -> Router { + Router::new() + .route( + "/api/v1/episode/:id", + patch(routes::tv::patch_episode_by_id).with_state(conn.clone()), + ) + .route( + "/api/v1/episode/:id", + delete(routes::tv::delete_episode_by_id).with_state(conn.clone()), + ) +} + fn season_routes(AppState { conn, .. }: AppState) -> Router { - Router::new().route_service( - "/api/v1/season/*path", - warp::service({ - dim_core::routes::tv::filters::patch_episode_by_id(conn.clone()) - .or(dim_core::routes::tv::filters::delete_season_by_id( - conn.clone(), - )) - .or(dim_core::routes::tv::filters::get_season_episodes( - conn.clone(), - )) - .or(dim_core::routes::tv::filters::patch_episode_by_id( - conn.clone(), - )) - .or(dim_core::routes::tv::filters::delete_episode_by_id( - conn.clone(), - )) - }), - ) + Router::new() + .route( + "/api/v1/season/:id", + get(routes::tv::get_season_by_id).with_state(conn.clone()), + ) + .route( + "/api/v1/season/:id", + patch(routes::tv::patch_season_by_id).with_state(conn.clone()), + ) + .route( + "/api/v1/season/:id", + delete(routes::tv::delete_season_by_id).with_state(conn.clone()), + ) + .route( + "/api/v1/season/:id/episodes", + get(routes::tv::get_season_episodes).with_state(conn.clone()), + ) +} + +fn tv_routes(AppState { conn, .. }: AppState) -> Router { + Router::new() + .route( + "/api/v1/tv/:id/season", + get(routes::tv::get_tv_seasons).with_state(conn.clone()), + ) +} + +fn filebrowser_routes(AppState { .. }: AppState) -> Router { + Router::new() + .route( + "/api/v1/filebrowser/", + get(routes::filebrowser::get_directory_structure), + ) + .route( + "/api/v1/filebrowser/*path", + get(routes::filebrowser::get_directory_structure), + ) +} + +fn mediafile_routes(AppState { conn, .. }: AppState) -> Router { + Router::new() + .route( + "/api/v1/mediafile/:id", + get(routes::mediafile::get_mediafile_info).with_state(conn.clone()), + ) + .route( + "/api/v1/mediafile/match", + patch(routes::mediafile::rematch_mediafile).with_state(conn.clone()), + ) } fn settings_routes(AppState { conn, .. }: AppState) -> Router { - Router::new().route_service( - "/api/v1/user/settings", - warp::service({ - dim_core::routes::settings::filters::get_user_settings(conn.clone()) - .or(dim_core::routes::settings::filters::get_user_settings( - conn.clone(), - )) - .or(dim_core::routes::settings::filters::post_user_settings( - conn.clone(), - )) - .or(dim_core::routes::settings::filters::get_global_settings( - conn.clone(), - )) - .or(dim_core::routes::settings::filters::set_global_settings( - conn.clone(), - )) - }), - ) + Router::new() + .route( + "/api/v1/user/settings", + get(routes::settings::get_user_settings).with_state(conn.clone()), + ) + .route( + "/api/v1/user/settings", + post(routes::settings::post_user_settings).with_state(conn.clone()), + ) + .route( + "/api/v1/host/settings", + get(routes::settings::http_get_global_settings).with_state(conn.clone()), + ) + .route( + "/api/v1/host/settings", + post(routes::settings::http_set_global_settings).with_state(conn.clone()), + ) +} + +fn user_routes(AppState { conn, .. }: AppState) -> Router { + Router::new() + .route( + "/api/v1/user/password", + patch(routes::user::change_password).with_state(conn.clone()), + ) + .route( + "/api/v1/user/delete", + delete(routes::user::delete).with_state(conn.clone()), + ) + .route( + "/api/v1/user/username", + patch(routes::user::change_username).with_state(conn.clone()), + ) + .route( + "/api/v1/user/avatar", + post(routes::user::upload_avatar).with_state(conn.clone()), + ).layer(DefaultBodyLimit::max(5_000_000)) } pub async fn start_webserver( @@ -265,77 +348,31 @@ pub async fn start_webserver( "/api/v1/auth/token/:token", delete(routes::auth::delete_token).with_state(conn.clone()), ) + .merge(dashboard_routes(app.clone())) + .merge(episode_routes(app.clone())) + .merge(library_routes(app.clone())) + .merge(media_routes(app.clone())) + .merge(mediafile_routes(app.clone())) + .merge(season_routes(app.clone())) + .merge(tv_routes(app.clone())) + .merge(filebrowser_routes(app.clone())) + .merge(user_routes(app.clone())) + .route( + "/api/v1/search", + get(routes::search::search).with_state(conn.clone()), + ) + .merge(settings_routes(app.clone())) .route_layer(axum::middleware::from_fn_with_state( conn.clone(), verify_cookie_token, )) // --- End of routes authenticated by Axum middleware --- .merge(auth_routes(app.clone())) - .merge(library_routes(app.clone())) - .route_service("/api/v1/dashboard", warp!(dashboard::filters::dashboard)) - .route_service( - "/api/v1/dashboard/banner", - warp!(dashboard::filters::banners), - ) - .route_service( - "/api/v1/search", - warp!(dim_core::routes::general::filters::search), - ) - .route_service( - "/api/v1/filebrowser/", - warp!(dim_core::routes::general::filters::get_directory_structure), - ) - .route_service( - "/api/v1/filebrowser/*path", - warp!(dim_core::routes::general::filters::get_directory_structure), - ) .route_service( "/images/*path", warp!(dim_core::routes::statik::filters::get_image), ) - .merge(media_routes(app.clone())) .merge(stream_routes(app.clone())) - .route_service( - "/api/v1/mediafile/*path", - warp::service({ - dim_core::routes::mediafile::filters::get_mediafile_info(conn.clone()).or( - dim_core::routes::mediafile::filters::rematch_mediafile(conn.clone()), - ) - }), - ) - .route_service( - "/api/v1/tv/*path", - warp!(dim_core::routes::tv::filters::get_tv_seasons), - ) - .merge(season_routes(app.clone())) - .route_service( - "/api/v1/episode/*path", - warp::service({ - dim_core::routes::tv::filters::patch_episode_by_id(conn.clone()).or( - dim_core::routes::tv::filters::delete_episode_by_id(conn.clone()), - ) - }), - ) - .merge(settings_routes(app.clone())) - .route_service( - "/api/v1/host/settings", - warp::service({ - dim_core::routes::settings::filters::get_global_settings(conn.clone()).or( - dim_core::routes::settings::filters::set_global_settings(conn.clone()), - ) - }), - ) - .route_service( - "/api/v1/user/*path", - warp::service({ - dim_core::routes::user::filters::change_password(conn.clone()) - .or(dim_core::routes::user::filters::delete(conn.clone())) - .or(dim_core::routes::user::filters::change_username( - conn.clone(), - )) - .or(dim_core::routes::user::filters::upload_avatar(conn.clone())) - }), - ) .route_service( "/", warp::service(dim_core::routes::statik::filters::react_routes()), diff --git a/dim-web/src/routes/auth.rs b/dim-web/src/routes/auth.rs index 3664105a8..491d778a0 100644 --- a/dim-web/src/routes/auth.rs +++ b/dim-web/src/routes/auth.rs @@ -19,8 +19,12 @@ //! [`Unauthenticated`]: crate::errors::DimError::Unauthenticated //! [`login`]: fn@login -use axum::response::{IntoResponse, Json, Response}; -use axum::{extract, Extension}; +use axum::response::IntoResponse; +use axum::response::Response; +use axum::extract::Json; +use axum::extract::Path; +use axum::extract::State; +use axum::Extension; use dim_database::asset::Asset; use dim_database::progress::Progress; @@ -33,13 +37,14 @@ use dim_database::DbConnection; use http::StatusCode; use serde_json::json; +use displaydoc::Display; use thiserror::Error; -#[derive(Debug, Error)] +#[derive(Debug, Display, Error)] pub enum AuthError { - #[error("Not logged in.")] + /// Not logged in. InvalidCredentials, - #[error("database: {0}")] + /// database: {0} Database(#[from] DatabaseError), } @@ -103,7 +108,7 @@ impl IntoResponse for AuthError { /// [`Unauthorized`]: crate::errors::DimError::Unauthorized pub async fn get_all_invites( Extension(user): Extension, - extract::State(conn): extract::State, + State(conn): State, ) -> Result { let mut tx = conn.read().begin().await.map_err(DatabaseError::from)?; if user.has_role("owner") { @@ -181,7 +186,7 @@ pub async fn get_all_invites( /// [`Unauthorized`]: crate::errors::DimError::Unauthorized pub async fn generate_invite( Extension(user): Extension, - extract::State(conn): extract::State, + State(conn): State, ) -> Result { if !user.has_role("owner") { return Err(AuthError::InvalidCredentials); @@ -221,8 +226,8 @@ pub async fn generate_invite( /// [`Unauthorized`]: crate::errors::DimError::Unauthorized pub async fn delete_token( Extension(user): Extension, - extract::State(conn): extract::State, - extract::Path(token): extract::Path, + State(conn): State, + Path(token): Path, ) -> Result { if !user.has_role("owner") { return Err(AuthError::InvalidCredentials); @@ -274,11 +279,11 @@ pub async fn delete_token( #[axum::debug_handler] pub async fn whoami( Extension(user): Extension, - extract::State(conn): extract::State, + State(conn): State, ) -> Result { let mut tx = conn.read().begin().await.map_err(DatabaseError::from)?; - Ok(Json(json!({ + Ok(axum::response::Json(json!({ "picture": Asset::get_of_user(&mut tx, user.id).await.ok().map(|x| format!("/images/{}", x.local_path)), "spentWatching": Progress::get_total_time_spent_watching(&mut tx, user.id) .await @@ -289,11 +294,11 @@ pub async fn whoami( .into_response()) } -#[derive(Debug, Error)] +#[derive(Debug, Display, Error)] pub enum LoginError { - #[error("The provided username or password is incorrect.")] + /// The provided username or password is incorrect. InvalidCredentials, - #[error("database: {0}")] + /// database: {0} Database(#[from] DatabaseError), } @@ -339,8 +344,8 @@ impl IntoResponse for LoginError { /// [`Login`]: dim_database::user::Login #[axum::debug_handler] pub async fn login( - extract::State(conn): extract::State, - extract::Json(new_login): extract::Json, + State(conn): State, + Json(new_login): Json, ) -> Result { let mut tx = conn.read().begin().await.map_err(DatabaseError::from)?; let user = User::get(&mut tx, &new_login.username) @@ -350,7 +355,7 @@ pub async fn login( if verify(user.username, pass, new_login.password) { let token = dim_database::user::Login::create_cookie(user.id); - return Ok(Json(json!({ + return Ok(axum::response::Json(json!({ "token": token, })) .into_response()); @@ -359,20 +364,20 @@ pub async fn login( Err(LoginError::InvalidCredentials) } -pub async fn admin_exists(conn: extract::State) -> Result { +pub async fn admin_exists(conn: State) -> Result { let mut tx = conn.read().begin().await.map_err(DatabaseError::from)?; let exists = dbg!(User::get_all(&mut tx).await.map_err(LoginError::Database)?).is_empty(); let value = json!({ "exists": !exists }); - Ok(Json(value).into_response()) + Ok(axum::response::Json(value).into_response()) } -#[derive(Debug, Error)] +#[derive(Debug, Display, Error)] pub enum RegisterError { - #[error("the request does not contain a valid invite token")] + /// the request does not contain a valid invite token NoToken, - #[error("database: {0}")] + /// database: {0} Database(#[from] DatabaseError), } @@ -423,8 +428,8 @@ impl IntoResponse for RegisterError { /// [`Login`]: dim_database::user::Login #[axum::debug_handler] pub async fn register( - extract::State(conn): extract::State, - extract::Json(new_user): extract::Json, + State(conn): State, + Json(new_user): Json, ) -> Result { // FIXME: Return INTERNAL SERVER ERROR maybe with a traceback? let mut lock = conn.writer().lock_owned().await; @@ -466,5 +471,5 @@ pub async fn register( // FIXME: Return internal server error. tx.commit().await.map_err(DatabaseError::from)?; - Ok(Json(json!({ "username": res.username })).into_response()) + Ok(axum::response::Json(json!({ "username": res.username })).into_response()) } diff --git a/dim-core/src/routes/dashboard.rs b/dim-web/src/routes/dashboard.rs similarity index 77% rename from dim-core/src/routes/dashboard.rs rename to dim-web/src/routes/dashboard.rs index 7e6dac22f..503cf238f 100644 --- a/dim-core/src/routes/dashboard.rs +++ b/dim-web/src/routes/dashboard.rs @@ -1,137 +1,29 @@ -use crate::core::DbConnection; -use crate::errors; -use crate::json; +use axum::response::IntoResponse; +use axum::response::Json; +use axum::response::Response; +use axum::extract::State; +use axum::Extension; use dim_database::episode::Episode; -use dim_database::genre::*; +use dim_database::genre::Genre; use dim_database::library::MediaType; use dim_database::media::Media; use dim_database::mediafile::MediaFile; use dim_database::progress::Progress; - use dim_database::user::User; -use serde_json::Value; - -use warp::reply; - -pub mod filters { - use dim_database::DbConnection; - - use dim_database::user::User; - use warp::reject; - use warp::Filter; - - use crate::routes::global_filters::with_auth; - - use super::super::global_filters::with_state; - - pub fn dashboard( - conn: DbConnection, - ) -> impl Filter + Clone { - warp::path!("api" / "v1" / "dashboard") - .and(warp::get()) - .and(with_auth(conn.clone())) - .and(with_state::(conn)) - .and_then(|user: User, conn: DbConnection| async move { - super::dashboard(conn, user, tokio::runtime::Handle::current()) - .await - .map_err(|e| reject::custom(e)) - }) - } - - pub fn banners( - conn: DbConnection, - ) -> impl Filter + Clone { - warp::path!("api" / "v1" / "dashboard" / "banner") - .and(warp::get()) - .and(with_auth(conn.clone())) - .and(with_state::(conn)) - .and_then(|user: User, conn: DbConnection| async move { - super::banners(conn, user) - .await - .map_err(|e| reject::custom(e)) - }) - } -} - -pub async fn dashboard( - conn: DbConnection, - user: User, - _rt: tokio::runtime::Handle, -) -> Result { - let mut tx = conn.read().begin().await?; - - let mut top_rated = Vec::new(); - for media in Media::get_top_rated(&mut tx, 10).await? { - let item = match sqlx::query!( - "SELECT _tblmedia.name, assets.local_path FROM _tblmedia LEFT JOIN assets ON assets.id = _tblmedia.poster - WHERE _tblmedia.id = ?", - media - ).fetch_one(&mut tx).await { - Ok(x) => x, - Err(_) => continue, - }; - - top_rated.push(json!({ - "id": media, - "poster_path": item.local_path, - "name": item.name - })); - } - - let mut recently_added = Vec::new(); - for media in Media::get_recently_added(&mut tx, 10).await? { - let item = match sqlx::query!( - "SELECT _tblmedia.name, assets.local_path FROM _tblmedia LEFT JOIN assets ON assets.id = _tblmedia.poster - WHERE _tblmedia.id = ?", - media - ).fetch_one(&mut tx).await { - Ok(x) => x, - Err(_) => continue, - }; +use dim_database::DatabaseError; +use dim_database::DbConnection; - recently_added.push(json!({ - "id": media, - "poster_path": item.local_path, - "name": item.name - })); - } +use super::auth::AuthError; - let mut continue_watching = Vec::new(); - for media in Progress::get_continue_watching(&mut tx, user.id, 10).await? { - let item = match sqlx::query!( - "SELECT _tblmedia.name, assets.local_path FROM _tblmedia LEFT JOIN assets ON assets.id = _tblmedia.poster - WHERE _tblmedia.id = ?", - media - ).fetch_one(&mut tx).await { - Ok(x) => x, - Err(_) => continue, - }; - - continue_watching.push(json!({ - "id": media, - "poster_path": item.local_path, - "name": item.name - })); - } - - let continue_watching = if !continue_watching.is_empty() { - Some(json!({ - "CONTINUE WATCHING": continue_watching, - })) - } else { - None - }; - - Ok(reply::json(&json!({ - ..?continue_watching, - "TOP RATED": top_rated, - "FRESHLY ADDED": recently_added, - }))) -} +use dim_utils::json; +use serde_json::Value; -pub async fn banners(conn: DbConnection, user: User) -> Result { - let mut tx = conn.read().begin().await?; +pub async fn banners( + Extension(user): Extension, + State(conn): State, +) -> Result { + let mut tx = conn.read().begin().await.map_err(DatabaseError::from)?; let mut banners = Vec::new(); for media in Media::get_random_with(&mut tx, 10).await? { if let Ok(x) = match media.media_type { @@ -143,14 +35,14 @@ pub async fn banners(conn: DbConnection, user: User) -> Result>())) + Ok(Json(&banners.iter().take(3).collect::>()).into_response()) } async fn banner_for_movie( conn: &mut dim_database::Transaction<'_>, user: &User, media: &Media, -) -> Result { +) -> Result { let progress = Progress::get_for_media_user(&mut *conn, user.id, media.id) .await .map(|x| x.delta) @@ -196,7 +88,7 @@ async fn banner_for_show( conn: &mut dim_database::Transaction<'_>, user: &User, media: &Media, -) -> Result { +) -> Result { let episode = if let Ok(Some(ep)) = Episode::get_last_watched_episode(&mut *conn, media.id, user.id).await { @@ -260,3 +152,77 @@ async fn banner_for_show( })).collect::>(), })) } + +pub async fn dashboard( + Extension(user): Extension, + State(conn): State, +) -> Result { + let mut tx = conn.read().begin().await.map_err(DatabaseError::from)?; + let mut top_rated = Vec::new(); + for media in Media::get_top_rated(&mut tx, 10).await? { + let item = match sqlx::query!( + "SELECT _tblmedia.name, assets.local_path FROM _tblmedia LEFT JOIN assets ON assets.id = _tblmedia.poster + WHERE _tblmedia.id = ?", + media + ).fetch_one(&mut tx).await { + Ok(x) => x, + Err(_) => continue, + }; + + top_rated.push(json!({ + "id": media, + "poster_path": item.local_path, + "name": item.name + })); + } + + let mut recently_added = Vec::new(); + for media in Media::get_recently_added(&mut tx, 10).await? { + let item = match sqlx::query!( + "SELECT _tblmedia.name, assets.local_path FROM _tblmedia LEFT JOIN assets ON assets.id = _tblmedia.poster + WHERE _tblmedia.id = ?", + media + ).fetch_one(&mut tx).await { + Ok(x) => x, + Err(_) => continue, + }; + + recently_added.push(json!({ + "id": media, + "poster_path": item.local_path, + "name": item.name + })); + } + + let mut continue_watching = Vec::new(); + for media in Progress::get_continue_watching(&mut tx, user.id, 10).await? { + let item = match sqlx::query!( + "SELECT _tblmedia.name, assets.local_path FROM _tblmedia LEFT JOIN assets ON assets.id = _tblmedia.poster + WHERE _tblmedia.id = ?", + media + ).fetch_one(&mut tx).await { + Ok(x) => x, + Err(_) => continue, + }; + + continue_watching.push(json!({ + "id": media, + "poster_path": item.local_path, + "name": item.name + })); + } + + let continue_watching = if !continue_watching.is_empty() { + Some(json!({ + "CONTINUE WATCHING": continue_watching, + })) + } else { + None + }; + + Ok(Json(&json!({ + ..?continue_watching, + "TOP RATED": top_rated, + "FRESHLY ADDED": recently_added, + })).into_response()) +} \ No newline at end of file diff --git a/dim-web/src/routes/filebrowser.rs b/dim-web/src/routes/filebrowser.rs new file mode 100644 index 000000000..2ac962a0f --- /dev/null +++ b/dim-web/src/routes/filebrowser.rs @@ -0,0 +1,101 @@ +use axum::response::IntoResponse; +use axum::response::Response; +use axum::extract::Path; + +use http::StatusCode; + +use tokio::task::spawn_blocking; + +use std::fs; +use std::io; +use std::path::PathBuf; + +use displaydoc::Display; +use thiserror::Error; + +#[derive(Debug, Display, Error)] +pub enum AuthError { + /// IO Error. + IOError, + /// Not logged in. + InvalidCredentials, +} + +impl From for AuthError { + fn from(_: std::io::Error) -> Self { + Self::IOError + } +} + +impl IntoResponse for AuthError { + fn into_response(self) -> Response { + match self { + Self::InvalidCredentials => { + (StatusCode::UNAUTHORIZED, self.to_string()).into_response() + } + Self::IOError => { + (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()).into_response() + } + } + } +} + +pub fn enumerate_directory>(path: T) -> io::Result> { + let mut dirs: Vec = fs::read_dir(path)? + .into_iter() + .filter_map(|x| x.ok()) + .filter(|x| { + !x.file_name() + .to_str() + .map(|s| s.starts_with('.')) + .unwrap_or(false) + && !x.path().is_file() + }) + .map(|x| { + let path = x.path().to_string_lossy().to_string().replace("\\", "/"); + if cfg!(windows) { + path.replace("C:", "") + } else { + path + } + }) + .collect::>(); + + dirs.sort(); + Ok(dirs) +} + +pub async fn get_directory_structure( + path: Option>, +) -> Result { + cfg_if::cfg_if! { + if #[cfg(target_os = "windows")] { + let path_prefix = "C:/"; + } else { + let path_prefix = "/"; + } + } + + let path: PathBuf = match path { + Some(Path(p)) => { + let path = if p.starts_with(path_prefix) { + PathBuf::from(p) + } else { + let mut new_path = PathBuf::new(); + new_path.push(path_prefix); + new_path.push(p); + new_path + }; + path + } + None => { + PathBuf::from(path_prefix) + } + }; + + Ok(axum::response::Json( + &spawn_blocking(|| enumerate_directory(path)) + .await + .unwrap()?, + ).into_response()) +} diff --git a/dim-core/src/routes/media.rs b/dim-web/src/routes/media.rs similarity index 63% rename from dim-core/src/routes/media.rs rename to dim-web/src/routes/media.rs index f6f3f011c..09205325f 100644 --- a/dim-core/src/routes/media.rs +++ b/dim-web/src/routes/media.rs @@ -1,11 +1,17 @@ -use crate::core::DbConnection; -use crate::errors; -use crate::json; -use crate::tree; -use crate::utils::secs_to_pretty; +use axum::response::{IntoResponse, Response, Json}; +use axum::{extract, Extension}; use chrono::Datelike; +use dim_core::tree; +use dim_core::scanner::movie; +use dim_core::scanner::parse_filenames; +use dim_core::scanner::tv_show; +use dim_core::scanner::MediaMatcher; +use dim_core::scanner::WorkUnit; + +use dim_database::DatabaseError; +use dim_database::DbConnection; use dim_database::compact_mediafile::CompactMediafile; use dim_database::episode::Episode; use dim_database::genre::Genre; @@ -19,14 +25,58 @@ use dim_database::user::User; use dim_extern_api::tmdb::TMDBMetadataProvider; use dim_extern_api::ExternalQueryIntoShow; -use warp::http::status::StatusCode; -use warp::reply; +use dim_utils::json; +use dim_utils::secs_to_pretty; + +use http::StatusCode; use std::collections::HashMap; use std::sync::Arc; use once_cell::sync::Lazy; -use serde::Serialize; +use serde::{Serialize, Deserialize}; + +use tracing::error; +use tracing::info; + +use displaydoc::Display; +use thiserror::Error; + +#[derive(Debug, Display, Error)] +pub enum Error { + /// Not Found. + NotFoundError, + /// Invalid media type. + InvalidMediaType, + /// Not logged in. + InvalidCredentials, + /// database: {0} + Database(#[from] DatabaseError), + /// Failed to search for tmdb_id when rematching: {0} + ExternalSearchError(String), +} + +impl IntoResponse for Error { + fn into_response(self) -> Response { + match self { + Self::NotFoundError => { + (StatusCode::NOT_FOUND, self.to_string()).into_response() + } + Self::ExternalSearchError(_) => { + (StatusCode::NOT_FOUND, self.to_string()).into_response() + } + Self::InvalidMediaType => { + (StatusCode::NOT_ACCEPTABLE, self.to_string()).into_response() + } + Self::InvalidCredentials => { + (StatusCode::UNAUTHORIZED, self.to_string()).into_response() + } + Self::Database(_) => { + (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()).into_response() + } + } + } +} pub const API_KEY: &str = "38c372f5bc572c8aadde7a802638534e"; pub const MOVIES_PROVIDER: Lazy> = @@ -34,139 +84,6 @@ pub const MOVIES_PROVIDER: Lazy> = pub const TV_PROVIDER: Lazy> = Lazy::new(|| Arc::new(TMDBMetadataProvider::new(&API_KEY).tv_shows())); -pub mod filters { - use dim_database::user::User; - use warp::reject; - use warp::Filter; - - use crate::routes::global_filters::with_auth; - - use super::super::global_filters::with_state; - use serde::Deserialize; - - use dim_database::media::UpdateMedia; - use dim_database::DbConnection; - - pub fn get_media_by_id( - conn: DbConnection, - ) -> impl Filter + Clone { - warp::path!("api" / "v1" / "media" / i64) - .and(warp::get()) - .and(with_state::(conn.clone())) - .and(with_auth(conn)) - .and_then(|id: i64, conn: DbConnection, user: User| async move { - super::get_media_by_id(conn, id, user) - .await - .map_err(|e| reject::custom(e)) - }) - } - - pub fn get_media_files( - conn: DbConnection, - ) -> impl Filter + Clone { - warp::path!("api" / "v1" / "media" / i64 / "files") - .and(warp::get()) - .and(with_state::(conn.clone())) - .and(with_auth(conn)) - .and_then(|id: i64, conn: DbConnection, _user: User| async move { - super::get_media_files(conn, id) - .await - .map_err(|e| reject::custom(e)) - }) - } - - pub fn get_mediafile_tree( - conn: DbConnection, - ) -> impl Filter + Clone { - warp::path!("api" / "v1" / "media" / i64 / "tree") - .and(warp::get()) - .and(with_state::(conn.clone())) - .and(with_auth(conn)) - .and_then(|id, conn, _user| async move { - super::get_mediafile_tree(conn, id) - .await - .map_err(reject::custom) - }) - } - - pub fn update_media_by_id( - conn: DbConnection, - ) -> impl Filter + Clone { - warp::path!("api" / "v1" / "media" / i64) - .and(warp::patch()) - .and(warp::body::json::()) - .and(with_auth(conn.clone())) - .and(with_state::(conn)) - .and_then(|id, body, auth, conn| async move { - super::update_media_by_id(id, body, auth, conn) - .await - .map_err(|e| reject::custom(e)) - }) - } - - pub fn delete_media_by_id( - conn: DbConnection, - ) -> impl Filter + Clone { - warp::path!("api" / "v1" / "media" / i64) - .and(warp::delete()) - .and(with_auth(conn.clone())) - .and(with_state::(conn)) - .and_then(|id: i64, auth: User, conn: DbConnection| async move { - super::delete_media_by_id(conn, id, auth) - .await - .map_err(|e| reject::custom(e)) - }) - } - - pub fn tmdb_search( - conn: DbConnection, - ) -> impl Filter + Clone { - #[derive(Deserialize)] - struct RouteArgs { - query: String, - year: Option, - media_type: String, - } - - warp::path!("api" / "v1" / "media" / "tmdb_search") - .and(warp::get()) - .and(warp::query::query::()) - .and(with_auth(conn)) - .and_then( - |RouteArgs { - query, - year, - media_type, - }: RouteArgs, - auth: User| async move { - super::tmdb_search(query, year, media_type, auth) - .await - .map_err(|e| reject::custom(e)) - }, - ) - } - - pub fn map_progress( - conn: DbConnection, - ) -> impl Filter + Clone { - #[derive(Deserialize)] - struct RouteArgs { - offset: i64, - } - - warp::path!("api" / "v1" / "media" / i64 / "progress") - .and(warp::post()) - .and(warp::query::query::()) - .and(with_state::(conn.clone())) - .and(with_auth(conn)) - .and_then(|id: i64, RouteArgs { offset }: RouteArgs, conn: DbConnection, auth: User| async move { - super::map_progress(conn, id, offset, auth) - .await - .map_err(|e| reject::custom(e)) - }) - } -} - /// Method mapped to `GET /api/v1/media/` returns info about a media based on the id queried. /// This method can only be accessed by authenticated users. /// @@ -197,11 +114,11 @@ pub mod filters { /// # Additional types /// [`MediaType`](`dim_database::library::MediaType`) pub async fn get_media_by_id( - conn: DbConnection, - id: i64, - user: User, -) -> Result { - let mut tx = conn.read().begin().await?; + extract::Path(id): extract::Path, + Extension(user): Extension, + extract::State(conn): extract::State, +) -> Result { + let mut tx = conn.read().begin().await.map_err(DatabaseError::from)?; let media = Media::get(&mut tx, id).await?; let media_id = match media.media_type { @@ -281,12 +198,12 @@ pub async fn get_media_by_id( .as_ref() .map(|x| format!("{}p", x)) .unwrap_or("Unknown".into()), - crate::utils::codec_pretty(x.codec.as_deref().unwrap_or("Unknown")) + dim_core::utils::codec_pretty(x.codec.as_deref().unwrap_or("Unknown")) ); let audio_lang = x.audio_language.as_deref().unwrap_or("Unknown"); - let audio_codec = crate::utils::codec_pretty(x.audio.as_deref().unwrap_or("Unknown")); - let audio_ch = crate::utils::channels_pretty(x.channels.unwrap_or(2)); + let audio_codec = dim_core::utils::codec_pretty(x.audio.as_deref().unwrap_or("Unknown")); + let audio_ch = dim_core::utils::channels_pretty(x.channels.unwrap_or(2)); let audio_tag = format!("{} ({} {})", audio_lang, audio_codec, audio_ch); @@ -360,7 +277,7 @@ pub async fn get_media_by_id( }; // FIXME: Remove the duration tag once the UI transitioned to using duration_pretty - Ok(reply::json(&json!({ + Ok(Json(&json!({ "id": media.id, "library_id": media.library_id, "name": media.name, @@ -377,14 +294,14 @@ pub async fn get_media_by_id( ..?next_episode_id, ..?season_episode_tag, ..?progress - }))) + })).into_response()) } pub async fn get_media_files( - conn: DbConnection, - id: i64, -) -> Result { - let mut tx = conn.read().begin().await?; + extract::Path(id): extract::Path, + extract::State(conn): extract::State, +) -> Result { + let mut tx = conn.read().begin().await.map_err(DatabaseError::from)?; let media_type = Media::media_mediatype(&mut tx, id).await?; let mediafiles = match media_type { @@ -392,7 +309,7 @@ pub async fn get_media_files( MediaType::Episode | MediaType::Movie => MediaFile::get_of_media(&mut tx, id).await?, }; - Ok(reply::json(&mediafiles)) + Ok(Json(json!(&mediafiles)).into_response()) } /// # GET `/api/v1/media//tree` @@ -401,10 +318,10 @@ pub async fn get_media_files( /// # Authentication /// Method requires standard authentication. pub async fn get_mediafile_tree( - conn: DbConnection, - id: i64, -) -> Result { - let mut tx = conn.read().begin().await?; + extract::Path(id): extract::Path, + extract::State(conn): extract::State, +) -> Result { + let mut tx = conn.read().begin().await.map_err(DatabaseError::from)?; let media_type = Media::media_mediatype(&mut tx, id).await?; let mut mediafiles = match media_type { @@ -444,7 +361,7 @@ pub async fn get_mediafile_tree( ); #[derive(Serialize)] - struct Response { + struct TreeResponse { count: usize, files: Vec>, } @@ -454,10 +371,10 @@ pub async fn get_mediafile_tree( _ => unreachable!(), }; - Ok(reply::json(&Response { + Ok(Json(json!(&TreeResponse { files: entries, count, - })) + })).into_response()) } /// Method mapped to `PATCH /api/v1/media/` is used to edit information about a media entry @@ -469,20 +386,19 @@ pub async fn get_mediafile_tree( /// * `data` - the info that we changed about the media entry /// * `_user` - Auth middleware pub async fn update_media_by_id( - id: i64, - data: UpdateMedia, - _user: User, - conn: DbConnection, -) -> Result { + extract::State(conn): extract::State, + extract::Path(id): extract::Path, + extract::Json(data): extract::Json, +) -> Result { let mut lock = conn.writer().lock_owned().await; - let mut tx = dim_database::write_tx(&mut lock).await?; + let mut tx = dim_database::write_tx(&mut lock).await.map_err(DatabaseError::from)?; let status = if data.update(&mut tx, id).await.is_ok() { StatusCode::NO_CONTENT } else { StatusCode::NOT_MODIFIED }; - tx.commit().await?; + tx.commit().await.map_err(DatabaseError::from)?; Ok(status) } @@ -495,17 +411,23 @@ pub async fn update_media_by_id( /// * `id` - id of the media we want to delete /// * `_user` - auth middleware pub async fn delete_media_by_id( - conn: DbConnection, - id: i64, - _user: User, -) -> Result { + extract::State(conn): extract::State, + extract::Path(id): extract::Path, +) -> Result { let mut lock = conn.writer().lock_owned().await; - let mut tx = dim_database::write_tx(&mut lock).await?; + let mut tx = dim_database::write_tx(&mut lock).await.map_err(DatabaseError::from)?; Media::delete(&mut tx, id).await?; - tx.commit().await?; + tx.commit().await.map_err(DatabaseError::from)?; Ok(StatusCode::OK) } +#[derive(Deserialize)] +pub struct TmdbSearchParams { + query: String, + year: Option, + media_type: String, +} + /// Method mapped to `GET /api/v1/media/tmdb_search` is used to quickly search TMDB based on 3 /// params, one of which is optional. This is used client side in the rematch utility /// @@ -514,28 +436,25 @@ pub async fn delete_media_by_id( /// * `year` - optional parameter specifying the release year of the media we want to look up /// * `media_type` - parameter that tells us what media type we are querying, ie movie or tv show pub async fn tmdb_search( - query: String, - year: Option, - media_type: String, - _user: User, -) -> Result { - let Ok(media_type) = media_type.to_lowercase().try_into() else { - return Err(errors::DimError::InvalidMediaType); + extract::Query(params): extract::Query, +) -> Result { + let Ok(media_type) = params.media_type.to_lowercase().try_into() else { + return Err(Error::InvalidMediaType); }; let provider = match media_type { MediaType::Movie => (*MOVIES_PROVIDER).clone(), MediaType::Tv => (*TV_PROVIDER).clone(), - _ => return Err(errors::DimError::InvalidMediaType), + _ => return Err(Error::InvalidMediaType), }; let results = provider - .search(&query, year) + .search(¶ms.query, params.year) .await - .map_err(|_| errors::DimError::NotFoundError)?; + .map_err(|_| Error::NotFoundError)?; if results.is_empty() { - return Err(errors::DimError::NotFoundError); + return Err(Error::NotFoundError); } let resp = results @@ -554,7 +473,12 @@ pub async fn tmdb_search( }) .collect::>(); - Ok(reply::json(&resp)) + Ok(Json(json!(&resp)).into_response()) +} + +#[derive(Deserialize)] +pub struct ProgressParams { + offset: i64, } /// Method mapped to `POST /api/v1/media//progress` is used to map progress for a certain media @@ -566,14 +490,94 @@ pub async fn tmdb_search( /// # Query params /// * `offset` - offset in seconds pub async fn map_progress( - conn: DbConnection, - id: i64, - offset: i64, - user: User, -) -> Result { + extract::State(conn): extract::State, + extract::Path(id): extract::Path, + extract::Query(params): extract::Query, + Extension(user): Extension, +) -> Result { + let mut lock = conn.writer().lock_owned().await; + let mut tx = dim_database::write_tx(&mut lock).await.map_err(DatabaseError::from)?; + Progress::set(&mut tx, params.offset, user.id, id).await?; + tx.commit().await.map_err(DatabaseError::from)?; + Ok(StatusCode::OK) +} + +#[derive(Deserialize)] +pub struct RematchMediaParams { + external_id: String, + media_type: String, +} + +/// FIXME: Merge this function into rematch_mediafile as theyre functionally the same fucking thing +/// except here we are matching whole media objects rather than mediafiles. This was a different +/// api in the past because the scanner wasnt intelligent enough to decouple and clean up stale +/// media objects but now that it can do that we can just rematch a matched mediafile and it will +/// work as it should. +/// +/// TODO: Add ability to specify overrides like episode and season ranges. +pub async fn rematch_media_by_id( + extract::State(conn): extract::State, + extract::Path(id): extract::Path, + extract::Json(params): extract::Json, +) -> Result { + let Ok(media_type) = params.media_type.to_lowercase().try_into() else { + return Err(Error::InvalidMediaType); + }; + + let provider: Arc = match media_type { + MediaType::Movie => (*MOVIES_PROVIDER).clone(), + MediaType::Tv => (*TV_PROVIDER).clone(), + _ => return Err(Error::InvalidMediaType), + }; + + let matcher = match media_type { + MediaType::Movie => Arc::new(movie::MovieMatcher) as Arc, + MediaType::Tv => Arc::new(tv_show::TvMatcher) as Arc, + _ => unreachable!(), + }; + + let mut tx = conn.read().begin().await.map_err(DatabaseError::from)?; + + let mediafiles = match media_type { + MediaType::Movie => MediaFile::get_of_media(&mut tx, id).await?, + MediaType::Tv => MediaFile::get_of_show(&mut tx, id).await?, + _ => unreachable!(), + }; + + let mediafile_ids = mediafiles.iter().map(|x| x.id).collect::>(); + + info!(?media_type, mediafiles = ?&mediafile_ids, "Rematching media"); + + provider.search_by_id(¶ms.external_id).await.map_err(|e| { + error!(?e, "Failed to search for tmdb_id when rematching."); + Error::ExternalSearchError(e.to_string()) + })?; + + drop(tx); + let mut lock = conn.writer().lock_owned().await; - let mut tx = dim_database::write_tx(&mut lock).await?; - Progress::set(&mut tx, offset, user.id, id).await?; - tx.commit().await?; + let mut tx = dim_database::write_tx(&mut lock).await.map_err(DatabaseError::from)?; + + for mediafile in mediafiles { + let Some((_, metadata)) = parse_filenames(IntoIterator::into_iter([&mediafile.target_file])).pop() else { + continue; + }; + + matcher + .match_to_id( + &mut tx, + provider.clone(), + WorkUnit(mediafile.clone(), metadata), + ¶ms.external_id, + ) + .await + .map_err(|e| { + error!(?e, "Failed to match tmdb_id."); + Error::ExternalSearchError(e.to_string()) + })?; + } + + tx.commit().await.map_err(DatabaseError::from)?; + Ok(StatusCode::OK) } diff --git a/dim-web/src/routes/mediafile.rs b/dim-web/src/routes/mediafile.rs new file mode 100644 index 000000000..749af545e --- /dev/null +++ b/dim-web/src/routes/mediafile.rs @@ -0,0 +1,176 @@ +use axum::response::IntoResponse; +use axum::response::Json; +use axum::response::Response; +use axum::extract::Path; +use axum::extract::State; + +use dim_core::scanner::movie; +use dim_core::scanner::parse_filenames; +use dim_core::scanner::tv_show; +use dim_core::scanner::MediaMatcher; +use dim_core::scanner::WorkUnit; + +use super::media::MOVIES_PROVIDER; +use super::media::TV_PROVIDER; + +use dim_database::DbConnection; +use dim_database::DatabaseError; +use dim_database::library::MediaType; +use dim_database::mediafile::MediaFile; + +use dim_extern_api::ExternalQueryIntoShow; + +use serde::Deserialize; +use serde_json::json; +use std::sync::Arc; + +use http::StatusCode; +use tracing::error; +use tracing::info; + +use displaydoc::Display; +use thiserror::Error; + +#[derive(Debug, Display, Error)] +pub enum Error { + /// No mediafiles. + NoMediafiles, + /// Invalid media type. + InvalidMediaType, + /// Not logged in. + InvalidCredentials, + /// database: {0} + Database(#[from] DatabaseError), + /// Failed to search for tmdb_id when rematching: {0} + ExternalSearchError(String), +} + +impl From for Error { + fn from(e: dim_core::scanner::error::Error) -> Self { + Self::ExternalSearchError(format!("{:?}", e)) + } +} + +impl IntoResponse for Error { + fn into_response(self) -> Response { + match self { + Self::ExternalSearchError(_) => { + (StatusCode::NOT_FOUND, self.to_string()).into_response() + } + Self::InvalidMediaType => { + (StatusCode::NOT_ACCEPTABLE, self.to_string()).into_response() + } + Self::NoMediafiles => { + (StatusCode::BAD_REQUEST, self.to_string()).into_response() + } + Self::InvalidCredentials => { + (StatusCode::UNAUTHORIZED, self.to_string()).into_response() + } + Self::Database(_) => { + (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()).into_response() + } + } + } +} + +#[derive(Deserialize)] +pub struct RouteArgs { + tmdb_id: String, + media_type: String, + mediafiles: Vec, +} + +/// Method mapped to `GET /api/v1/mediafile/` is used to get information about a mediafile by its id. +/// +/// # Arguments +/// * `id` - id of the mediafile we want info about +pub async fn get_mediafile_info( + State(conn): State, + Path(id): Path, +) -> Result { + let mut tx = conn.read().begin().await.map_err(DatabaseError::from)?; + let mediafile = MediaFile::get_one(&mut tx, id) + .await + .map_err(DatabaseError::from)?; + + Ok(Json(&json!({ + "id": mediafile.id, + "media_id": mediafile.media_id, + "library_id": mediafile.library_id, + "raw_name": mediafile.raw_name, + })).into_response()) +} + +/// Method mapped to `PATCH /api/v1/mediafile/match` used to match a unmatched(orphan) +/// mediafile to a tmdb id. +/// +/// # Arguments +/// * `conn` - database connection +/// * `log` - logger +/// * `event_tx` - websocket channel over which we dispatch a event notifying other clients of the +/// new metadata +/// +/// * `mediafiles` - ids of the orphan mediafiles we want to rematch +/// * `tmdb_id` - the tmdb id of the proper metadata we want to fetch for the media +pub async fn rematch_mediafile( + State(conn): State, + Json(route_args): Json, +) -> Result { + if route_args.mediafiles.is_empty() { + return Err(Error::NoMediafiles.into()); + } + + let Ok(media_type): Result = route_args.media_type.to_lowercase().try_into() else { + return Err(Error::InvalidMediaType); + }; + + let mut tx = conn.read().begin().await.map_err(DatabaseError::from)?; + + // FIXME: impl FromStr for MediaType + let provider: Arc = match media_type { + MediaType::Movie => (*MOVIES_PROVIDER).clone(), + MediaType::Tv => (*TV_PROVIDER).clone(), + _ => return Err(Error::InvalidMediaType), + }; + + let matcher = match media_type { + MediaType::Movie => Arc::new(movie::MovieMatcher) as Arc, + MediaType::Tv => Arc::new(tv_show::TvMatcher) as Arc, + _ => unreachable!(), + }; + + info!(?media_type, route_args.mediafiles = ?&route_args.mediafiles, "Rematching mediafiles"); + + let mediafiles = MediaFile::get_many(&mut tx, &route_args.mediafiles).await.map_err(DatabaseError::from)?; + + provider.search_by_id(&route_args.tmdb_id).await.map_err(|e| { + error!(?e, "Failed to search for tmdb_id when rematching."); + Error::ExternalSearchError(e.to_string()) + })?; + + let mut lock = conn.writer().lock_owned().await; + let mut tx = dim_database::write_tx(&mut lock).await.map_err(DatabaseError::from)?; + + for mediafile in mediafiles { + let Some((_, metadata)) = parse_filenames(IntoIterator::into_iter([&mediafile.target_file])).pop() else { + continue; + }; + + matcher + .match_to_id( + &mut tx, + provider.clone(), + WorkUnit(mediafile.clone(), metadata), + &route_args.tmdb_id, + ) + .await + .map_err(|e| { + error!(?e, "Failed to match tmdb_id."); + Error::ExternalSearchError(e.to_string()) + })?; + } + + tx.commit().await.map_err(DatabaseError::from)?; + + Ok(StatusCode::OK) +} diff --git a/dim-web/src/routes/mod.rs b/dim-web/src/routes/mod.rs index d6af971e6..ea3464a3d 100644 --- a/dim-web/src/routes/mod.rs +++ b/dim-web/src/routes/mod.rs @@ -1,3 +1,11 @@ pub mod auth; +pub mod dashboard; +pub mod filebrowser; pub mod library; +pub mod media; +pub mod mediafile; +pub mod search; +pub mod settings; +pub mod tv; +pub mod user; pub mod websocket; diff --git a/dim-web/src/routes/search.rs b/dim-web/src/routes/search.rs new file mode 100644 index 000000000..8067dd41a --- /dev/null +++ b/dim-web/src/routes/search.rs @@ -0,0 +1,178 @@ +use axum::response::IntoResponse; +use axum::response::Json; +use axum::response::Response; +use axum::extract::Query; +use axum::extract::State; + +use dim_database::DatabaseError; +use dim_database::DbConnection; +use dim_database::genre::*; + +use http::StatusCode; + +use serde::Serialize; +use serde::Deserialize; +use serde_json::json; +use serde_json::Value; + +use displaydoc::Display; +use thiserror::Error; + +#[derive(Debug, Display, Error)] +pub enum AuthError { + /// Not Found. + NotFoundError, + /// Not logged in. + InvalidCredentials, + /// database: {0} + Database(#[from] DatabaseError), +} + +impl IntoResponse for AuthError { + fn into_response(self) -> Response { + match self { + Self::NotFoundError => { + (StatusCode::NOT_FOUND, self.to_string()).into_response() + } + Self::InvalidCredentials => { + (StatusCode::UNAUTHORIZED, self.to_string()).into_response() + } + Self::Database(_) => { + (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()).into_response() + } + } + } +} + +#[derive(Deserialize)] +pub struct SearchArgs { + query: Option, + year: Option, + _library_id: Option, + genre: Option, + _quick: Option, +} + +pub async fn search( + State(conn): State, + Query(search_args): Query, +) -> Result { + let mut tx = conn.read().begin().await.map_err(DatabaseError::from)?; + + if let Some(query_string) = search_args.query { + let query_string = query_string + .split(' ') + .map(|x| format!("%{}%", x)) + .collect::>() + .as_slice() + .join(" "); + + if let Ok(x) = search_by_name(&mut tx, &query_string, 15).await { + return Ok(Json(x).into_response()) + } + } + + if let Some(x) = search_args.genre { + let genre_id = Genre::get_by_name(&mut tx, x).await?.id; + if let Ok(x) = search_by_genre(&mut tx, genre_id).await { + return Ok(Json(x).into_response()) + } + } + + if let Some(x) = search_args.year { + if let Ok(x) = search_by_release_year(&mut tx, x as i64).await { + return Ok(Json(x).into_response()) + } + } + + Err(AuthError::NotFoundError) +} + +async fn search_by_name( + conn: &mut dim_database::Transaction<'_>, + query: &str, + limit: i64, +) -> Result { + #[derive(Serialize)] + struct Record { + id: i64, + library_id: i64, + name: String, + poster_path: Option, + } + + let data = sqlx::query_as!( + Record, + r#"SELECT _tblmedia.id, library_id, name, assets.local_path as poster_path FROM _tblmedia + LEFT JOIN assets on _tblmedia.poster = assets.id + WHERE NOT media_type = "episode" + AND UPPER(name) LIKE ? + LIMIT ?"#, + query, + limit + ) + .fetch_all(conn) + .await + .map_err(DatabaseError::from)?; + + Ok(json!(&data)) +} + +async fn search_by_genre( + conn: &mut dim_database::Transaction<'_>, + genre_id: i64, +) -> Result { + #[derive(Serialize)] + struct Record { + id: i64, + library_id: i64, + name: String, + poster_path: Option, + } + + let data = sqlx::query_as!( + Record, + r#"SELECT _tblmedia.id, library_id, name, assets.local_path as poster_path + FROM _tblmedia + LEFT JOIN assets on _tblmedia.poster = assets.id + INNER JOIN genre_media ON genre_media.media_id = _tblmedia.id + WHERE NOT media_type = "episode" + AND genre_media.genre_id = ? + "#, + genre_id, + ) + .fetch_all(conn) + .await + .map_err(DatabaseError::from)?; + + Ok(json!(&data)) +} + +async fn search_by_release_year( + conn: &mut dim_database::Transaction<'_>, + year: i64, +) -> Result { + #[derive(Serialize)] + struct Record { + id: i64, + library_id: i64, + name: String, + poster_path: Option, + } + + let data = sqlx::query_as!( + Record, + r#"SELECT _tblmedia.id, library_id, name, assets.local_path as poster_path + FROM _tblmedia + LEFT JOIN assets on _tblmedia.poster = assets.id + WHERE NOT media_type = "episode" + AND year = ? + "#, + year, + ) + .fetch_all(conn) + .await + .map_err(DatabaseError::from)?; + + Ok(json!(&data)) +} diff --git a/dim-web/src/routes/settings.rs b/dim-web/src/routes/settings.rs new file mode 100644 index 000000000..cc4277a6c --- /dev/null +++ b/dim-web/src/routes/settings.rs @@ -0,0 +1,60 @@ +use axum::response::IntoResponse; +use axum::response::Response; +use axum::extract::Json; +use axum::extract::State; +use axum::Extension; + +use dim_core::routes::settings::{GlobalSettings, get_global_settings, set_global_settings}; +use dim_database::DatabaseError; +use dim_database::DbConnection; +use dim_database::user::UpdateableUser; +use dim_database::user::User; +use dim_database::user::UserSettings; + +use super::auth::AuthError; + + +pub async fn get_user_settings( + Extension(user): Extension, + State(conn): State, +) -> Result { + let mut tx = conn.read().begin().await.map_err(DatabaseError::from)?; + Ok(axum::response::Json(&User::get_by_id(&mut tx, user.id).await?.prefs).into_response()) +} + +pub async fn post_user_settings( + Extension(user): Extension, + State(conn): State, + Json(new_settings): Json, +) -> Result { + let mut lock = conn.writer().lock_owned().await; + let mut tx = dim_database::write_tx(&mut lock).await.map_err(DatabaseError::from)?; + let update_user = UpdateableUser { + prefs: Some(new_settings.clone()), + }; + + update_user.update(&mut tx, user.id).await?; + + tx.commit().await.map_err(DatabaseError::from)?; + drop(lock); + + Ok(axum::response::Json(&new_settings).into_response()) +} + +// TODO: Hide secret key. +pub async fn http_get_global_settings() -> Result { + Ok(axum::response::Json(&get_global_settings()).into_response()) +} + +// TODO: Disallow setting secret key over http. +pub async fn http_set_global_settings( + Extension(user): Extension, + Json(new_settings): Json, +) -> Result { + if user.has_role("owner") { + set_global_settings(new_settings).unwrap(); + return Ok(Json(&get_global_settings()).into_response()); + } + + Err(AuthError::InvalidCredentials) +} diff --git a/dim-web/src/routes/tv.rs b/dim-web/src/routes/tv.rs new file mode 100644 index 000000000..adcfaaa71 --- /dev/null +++ b/dim-web/src/routes/tv.rs @@ -0,0 +1,148 @@ +use axum::response::IntoResponse; +use axum::extract::Json; +use axum::extract::Path; +use axum::extract::State; + +use dim_database::DbConnection; +use dim_database::DatabaseError; +use dim_database::episode::{Episode, UpdateEpisode}; +use dim_database::season::{Season, UpdateSeason}; + +use http::StatusCode; + +use serde_json::json; + +use super::auth::AuthError; + +/// Method mapped to `GET /api/v1/tv//season` returns all seasons for TV Show mapped to the id +/// passed in. +/// +/// # Arguments +/// * `id` - id of the tv show we want info about +pub async fn get_tv_seasons( + State(conn): State, + Path(id): Path, +) -> Result { + let mut tx = conn.read().begin().await.map_err(DatabaseError::from)?; + Ok(axum::response::Json(json!(&Season::get_all(&mut tx, id).await?)).into_response()) +} + +/// Method mapped to `GET /api/v1/season/` returns info about the season by +/// +/// # Arguments +/// * `id` - id of the season we want info about +pub async fn get_season_by_id( + State(conn): State, + Path(id): Path, +) -> Result { + let mut tx = conn.read().begin().await.map_err(DatabaseError::from)?; + Ok(axum::response::Json(json!(&Season::get_by_id(&mut tx, id).await?)).into_response()) +} + +/// Method mapped to `PATCH /api/v1/season/` allows you to patch in info about +/// the season. +/// +/// # Route Arguments +/// * `id` - the id of the season. +/// +/// # Data +/// This route additionally requires you to pass in a json object by the format of +/// `dim_database::season::UpdateSeason`. +pub async fn patch_season_by_id( + State(conn): State, + Path(id): Path, + Json(season): Json, +) -> Result { + let mut lock = conn.writer().lock_owned().await; + let mut tx = dim_database::write_tx(&mut lock).await.map_err(DatabaseError::from)?; + season.update(&mut tx, id).await?; + tx.commit().await.map_err(DatabaseError::from)?; + + Ok(StatusCode::NO_CONTENT) +} + +/// Method mapped to `GET /api/v1/episode/` returns information +/// about a episode for a season. +/// +/// # Arguments +/// * `id` - id of the episode. +pub async fn get_season_episodes( + State(conn): State, + Path(id): Path, +) -> Result { + let mut tx = conn.read().begin().await.map_err(DatabaseError::from)?; + #[derive(serde::Serialize)] + pub struct Record { + pub id: i64, + pub name: String, + pub thumbnail_url: Option, + pub episode: i64, + } + + let result = sqlx::query_as!(Record, + r#"SELECT episode.id as "id!", _tblmedia.name, assets.local_path as thumbnail_url, episode.episode_ as "episode!" + FROM episode + INNER JOIN _tblmedia on _tblmedia.id = episode.id + LEFT JOIN assets ON assets.id = _tblmedia.backdrop + WHERE episode.seasonid = ?"#, + id + ).fetch_all(&mut tx).await.unwrap_or_default(); + + Ok(axum::response::Json(json!(&result)).into_response()) +} + +/// Method mapped to `DELETE /api/v1/season/` allows you to delete a season for +/// a particular tv show. +/// +/// # Arguments +/// * `id` - id of the season. +pub async fn delete_season_by_id( + State(conn): State, + Path(id): Path, +) -> Result { + let mut lock = conn.writer().lock_owned().await; + let mut tx = dim_database::write_tx(&mut lock).await.map_err(DatabaseError::from)?; + Season::delete_by_id(&mut tx, id).await?; + tx.commit().await.map_err(DatabaseError::from)?; + Ok(StatusCode::OK) +} + +/// TODO: Move all of these into a unified update interface for media items +/// Method mapped to `PATCH /api/v1/episode/` lets you patch +/// information about a episode. +/// +/// # Arguments +/// * `id` - id of a episode. +/// +/// # Data +/// This route additionally requires you to pass in a json object by the format of +/// `dim_database::episode::UpdateEpisode`. +pub async fn patch_episode_by_id( + State(conn): State, + Path(id): Path, + Json(episode): Json, +) -> Result { + let mut lock = conn.writer().lock_owned().await; + let mut tx = dim_database::write_tx(&mut lock).await.map_err(DatabaseError::from)?; + episode.update(&mut tx, id).await?; + tx.commit().await.map_err(DatabaseError::from)?; + + Ok(StatusCode::NO_CONTENT) +} + +/// Method mapped to `DELETE /api/v1/episode/` allows you to +/// delete a episode belonging to some season. +/// +/// # Arguments +/// * `id` - id an episode to delete +pub async fn delete_episode_by_id( + State(conn): State, + Path(id): Path, +) -> Result { + let mut lock = conn.writer().lock_owned().await; + let mut tx = dim_database::write_tx(&mut lock).await.map_err(DatabaseError::from)?; + Episode::delete(&mut tx, id).await?; + tx.commit().await.map_err(DatabaseError::from)?; + + Ok(StatusCode::OK) +} diff --git a/dim-web/src/routes/user.rs b/dim-web/src/routes/user.rs new file mode 100644 index 000000000..3df74f196 --- /dev/null +++ b/dim-web/src/routes/user.rs @@ -0,0 +1,299 @@ +//! This module contains all docs and APIs related to users and user metadata. +use axum::response::IntoResponse; +use axum::response::Response; +use axum::extract::Json; +use axum::extract::Multipart; +use axum::extract::multipart::Field; +use axum::extract::State; +use axum::Extension; + +use dim_database::DbConnection; +use dim_database::DatabaseError; +use dim_database::asset::Asset; +use dim_database::asset::InsertableAsset; +use dim_database::user::User; + +use http::StatusCode; +use serde::Deserialize; +use uuid::Uuid; +use displaydoc::Display; +use thiserror::Error; + +#[derive(Debug, Display, Error)] +pub enum AuthError { + /// Username not available. + UsernameNotAvailable, + /// Upload failed. + UploadFailed, + /// Unsupported file. + UnsupportedFile, + /// Not logged in. + InvalidCredentials, + /// database: {0} + Database(#[from] DatabaseError), +} + +impl IntoResponse for AuthError { + fn into_response(self) -> Response { + match self { + Self::UsernameNotAvailable => { + (StatusCode::BAD_REQUEST, self.to_string()).into_response() + } + Self::UnsupportedFile => { + (StatusCode::NOT_ACCEPTABLE, self.to_string()).into_response() + } + Self::InvalidCredentials => { + (StatusCode::UNAUTHORIZED, self.to_string()).into_response() + } + Self::UploadFailed | Self::Database(_) => { + (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()).into_response() + } + } + } +} + + +#[derive(Deserialize)] +pub struct ChangePasswordParams { + old_password: String, + new_password: String, +} + +/// # POST `/api/v1/user/password` +/// Method changes the password for a logged in account. +/// +/// # Request +/// This method accepts a JSON body with the following schema: +/// ```no_compile +/// { +/// "old_password": String, +/// "new_password": String, +/// } +/// ``` +/// The `old_password` field in the JSON payload must be the currently registered password for this +/// user. The `new_password` field is the new password that we want to set. +/// +/// ## Example +/// ```text +/// curl -X POST http://127.0.0.1:8000/api/v1/user/password -H "Content-type: application/json" +/// -H "Authroization: ..." -d '{"old_password": "testPass", "new_password": "newTestPass"}' +/// ``` +/// +/// # Response +/// If the password is successfully changed, the method will simply return `200 0K`. +/// +/// # Errors +/// * [`InvalidCredentials`] - The provided `old_password` is incorrect or the authentication token +/// is invalid. +/// +/// [`InvalidCredentials`]: AuthError::InvalidCredentials +pub async fn change_password( + Extension(user): Extension, + State(conn): State, + Json(params): Json, +) -> Result { + let mut lock = conn.writer().lock_owned().await; + let mut tx = dim_database::write_tx(&mut lock).await.map_err(DatabaseError::from)?; + + let user = User::authenticate(&mut tx, user.username, params.old_password) + .await + .map_err(|_| AuthError::InvalidCredentials)?; + + user.set_password(&mut tx, params.new_password).await?; + + tx.commit().await.map_err(DatabaseError::from)?; + + Ok(StatusCode::OK) +} + +#[derive(Deserialize)] +pub struct DeleteParams { + password: String, +} + +/// # DELETE `/api/v1/user` +/// Method deletes the currently logged in account. +/// +/// # Request +/// This method accepts a JSON body with the following schema: +/// ```no_compile +/// { +/// "password": String, +/// } +/// ``` +/// The `password` field in the JSON payload must be the currently registered password for this +/// user. This is required as a safety mechanism to avoid accidental account deletion. +/// +/// ## Example +/// ```text +/// curl -X DELETE http://127.0.0.1:8000/api/v1/user -H "Content-type: application/json" -H "Authroization: ..." +/// -d '{"password": "testPass"}' +/// ``` +/// +/// # Response +/// If the account is successfully deleted, the method will simply return `200 0K`. +/// +/// # SAFETY and caveats +/// Because Dim uses JWTs for authorization, deleting an account doesnt mean the authorization +/// token is also revoked as JWTs are stateless by design. Because of this, users must ensure that +/// the token is cleared from memory and is not *EVER* reused. +/// +/// # Errors +/// * [`InvalidCredentials`] - The provided `old_password` is incorrect or the authentication token +/// is invalid. +/// +/// [`InvalidCredentials`]: AuthError::InvalidCredentials +pub async fn delete( + Extension(user): Extension, + State(conn): State, + Json(params): Json, +) -> Result { + let mut lock = conn.writer().lock_owned().await; + let mut tx = dim_database::write_tx(&mut lock).await.map_err(DatabaseError::from)?; + + let user = User::authenticate(&mut tx, user.username, params.password) + .await + .map_err(|_| AuthError::InvalidCredentials)?; + + User::delete(&mut tx, user.id).await?; + + tx.commit().await.map_err(DatabaseError::from)?; + + Ok(StatusCode::OK) +} + +#[derive(Deserialize)] +pub struct ChangeUsernameParams { + new_username: String, +} + +/// # POST `/api/v1/user/username` +/// Method changes the username of the current account. +/// +/// # Request +/// This method accepts a JSON payload with the following schema: +/// ```no_compile +/// { +/// "new_username": String +/// } +/// ``` +/// +/// ## Example +/// ```text +/// curl -X POST http://127.0.0.1:8000/api/v1/user/username -H "Content-type: application/json" -H +/// "Authorization: ..." -d '{"new_username": "testUsername"}' +/// ``` +/// +/// # Response +/// If the username is successfully changed this method will simply return `200 OK`. +/// +/// # Errors +/// * [`UsernameNotAvailable`] - THe provided username has already been claimed by another user. +/// +/// [`UsernameNotAvailable`]: AuthError::UsernameNotAvailable +pub async fn change_username( + Extension(user): Extension, + State(conn): State, + Json(params): Json, +) -> Result { + let mut lock = conn.writer().lock_owned().await; + let mut tx = dim_database::write_tx(&mut lock).await.map_err(DatabaseError::from)?; + if User::get(&mut tx, ¶ms.new_username).await.is_ok() { + return Err(AuthError::UsernameNotAvailable); + } + + User::set_username(&mut tx, user.username.clone(), params.new_username).await?; + tx.commit().await.map_err(DatabaseError::from)?; + + Ok(StatusCode::OK) +} + +/// # POST `/api/v1/user/avatar` +/// This method can be used to set a new avatar for a user. +/// +/// # Request +/// This method accepts a multipart file upload. Only `jpg` and `png` files are supported. +/// +/// ## Example +/// ```text +/// curl -X POST http://127.0.0.1:8000/api/v1/user/avatar -H "Authorization: ..." --form +/// file='@newAvatar.png' +/// ``` +/// +/// # Response +/// If the avatar is successfully uploaded, this route will return `200 OK`. +/// +/// # Errors +/// * [`UploadFailed`] - No file has been uploaded correctly or the `file` form field has not been +/// * [`UnsupportedFile`] - The file uploaded is not supported. +/// found. +/// +/// [`UploadFailed`]: AuthError::UploadFailed +/// [`UnsupportedFile`]: AuthError::UnsupportedFile +pub async fn upload_avatar( + Extension(user): Extension, + State(conn): State, + mut multipart: Multipart, +) -> Result { + let mut lock = conn.writer().lock_owned().await; + let mut tx = dim_database::write_tx(&mut lock).await.map_err(DatabaseError::from)?; + + let mut asset: Option = None; + + while let Some(field) = multipart.next_field().await.unwrap_or(None) { + let name = field.name().unwrap().to_string(); + if name == "file" { + asset = Some(process_part(&mut tx, field).await?) + } + } + + match asset { + Some(asset) => { + User::set_picture(&mut tx, user.id, asset.id).await?; + tx.commit().await.map_err(DatabaseError::from)?; + + Ok(StatusCode::OK) + } + None => Err(AuthError::UploadFailed) + } +} + +#[doc(hidden)] +pub async fn process_part( + conn: &mut dim_database::Transaction<'_>, + p: Field<'_>, +) -> Result { + if p.name().unwrap() != "file" { + return Err(AuthError::UploadFailed); + } + + let file_ext = match p.content_type() { + Some("image/jpeg" | "image/jpg") => "jpg", + Some("image/png") => "png", + _ => return Err(AuthError::UnsupportedFile), + }; + + let contents = p + .bytes() + .await + .map_err(|_| AuthError::UploadFailed)?; + + let local_file = format!("{}.{}", Uuid::new_v4().to_string(), file_ext); + let local_path = format!( + "{}/{}", + dim_core::core::METADATA_PATH.get().unwrap(), + &local_file + ); + + tokio::fs::write(&local_path, contents) + .await + .map_err(|_| AuthError::UploadFailed)?; + + Ok(InsertableAsset { + local_path: local_file, + file_ext: file_ext.into(), + ..Default::default() + } + .insert_local_asset(conn) + .await?) +} diff --git a/ui/src/actions/auth.js b/ui/src/actions/auth.js index 8cdb2883d..957dcc493 100644 --- a/ui/src/actions/auth.js +++ b/ui/src/actions/auth.js @@ -151,7 +151,7 @@ export const changePassword = }; try { - const res = await fetch("/api/v1/auth/password", config); + const res = await fetch("/api/v1/user/password", config); if (res.status !== 200) { dispatch( @@ -222,7 +222,7 @@ export const delAccount = (password) => async (dispatch, getState) => { export const checkAdminExists = () => async (dispatch) => { try { - const res = await fetch("/api/v1/host/admin_exists"); + const res = await fetch("/api/v1/auth/admin_exists"); if (res.status !== 200) { return dispatch({ diff --git a/ui/src/actions/user.js b/ui/src/actions/user.js index f4081a8ed..aea845927 100644 --- a/ui/src/actions/user.js +++ b/ui/src/actions/user.js @@ -71,7 +71,7 @@ export const changeUsername = }; try { - const res = await fetch("/api/v1/auth/username", config); + const res = await fetch("/api/v1/user/username", config); if (res.status !== 200) { dispatch({