mirror of
https://github.com/agersant/polaris
synced 2024-11-10 02:04:13 +00:00
Replace rocket with actix-web (#116)
* Adds actix dependency * Failed attempt at test harness using actix * Fixed test panic * Simplified tests * Run web server in tests * Send json payloads * Static file serving * Default shutdown timeout * Implement version endpoint * Implements #[get("/initial_setup")] * WIP put_settings endpoint * Adds AdminRights extractor * Fixed a bug where AdminRights extractor always failed * Implements collection endpoints * Re-use system runnner between calls * Preserve client headers between API calls (tentative) * Fixed test interferences * Implemented more endpoints * Implemented audio file serving * Fixed sketchy responses * Implements thumbnail endpoint * Login endpoint WIP * Implement login endpoint * Auth support * When using HTTP headers to authenticate, response now includes expected cookies * Tentative fix for server not responding within docker * Adds logging middleware + browse troubleshooting * Tentative fix for path decoding issues * Tentative fix for broken path decoding * Fix routing issues w/ paths * Fixed a bug where auth cookies were sent in every response * More lenient test timeouts * Fixed a bug where recent/random endpoints required trailing slashes * Compilation fix for rocket branch * More useful test matrix * Signed session cookies (#106) * Isolate conflicting dependencies between rocket and actix versions * Removed macOS from test matrix * Glorious test harness simplification * Removed RequestBuilder * Shutdown on ctrl+c * Pin to stable * Drop rocket * Simplify dependencies * Removed stray rocket dependency * Better test matrix * Skip windows build without bundled sqlite * Offload thumbnail creation to a thread pool * Compress responses when possible * Removed unused manage state * Fixed a bug where large playlists could not be saved * Return HTTP 401 for last fm requests without authentication * Web block (#115) * web::block around DB operations * web::block during auth utils hitting DB * Fixed incorrect http response code for missing thumbnail * Removed unecessary unwrap * Eliminated unecessary unwrap
This commit is contained in:
parent
6be6d2a7dc
commit
c2807b60de
30 changed files with 2263 additions and 1693 deletions
8
.github/workflows/build.yml
vendored
8
.github/workflows/build.yml
vendored
|
@ -10,15 +10,15 @@ jobs:
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
features: [--all-features, --features default, --features "service-rocket" --no-default-features]
|
os: [ubuntu-latest, windows-latest]
|
||||||
os: [ubuntu-latest, windows-latest, macOS-latest]
|
features: [--no-default-features, --features bundle-sqlite, --features ui]
|
||||||
exclude:
|
exclude:
|
||||||
- os: windows-latest
|
- os: windows-latest
|
||||||
features: --features "service-rocket" --no-default-features
|
features: --no-default-features
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Install libsqlite3-dev
|
- name: Install libsqlite3-dev
|
||||||
if: contains(matrix.os, 'ubuntu')
|
if: contains(matrix.os, 'ubuntu') && !contains(matrix.features, 'bundle-sqlite')
|
||||||
run: sudo apt-get update && sudo apt-get install libsqlite3-dev
|
run: sudo apt-get update && sudo apt-get install libsqlite3-dev
|
||||||
- uses: actions/checkout@v1
|
- uses: actions/checkout@v1
|
||||||
- uses: actions-rs/toolchain@v1
|
- uses: actions-rs/toolchain@v1
|
||||||
|
|
1530
Cargo.lock
generated
1530
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
26
Cargo.toml
26
Cargo.toml
|
@ -5,21 +5,25 @@ authors = ["Antoine Gersant <antoine.gersant@lesforges.org>"]
|
||||||
edition = "2018"
|
edition = "2018"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["service-rocket", "bundle-sqlite"]
|
default = ["bundle-sqlite"]
|
||||||
ui = ["uuid", "winapi"]
|
|
||||||
service-rocket = ["rocket", "rocket_contrib"]
|
|
||||||
bundle-sqlite = ["libsqlite3-sys"]
|
bundle-sqlite = ["libsqlite3-sys"]
|
||||||
|
ui = ["uuid", "winapi"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
actix-files = { version = "0.4" }
|
||||||
|
actix-web = { version = "3" }
|
||||||
|
actix-web-httpauth = { version = "0.5.0" }
|
||||||
anyhow = "1.0.35"
|
anyhow = "1.0.35"
|
||||||
ape = "0.3.0"
|
ape = "0.3.0"
|
||||||
base64 = "0.13"
|
base64 = "0.13"
|
||||||
|
cookie = { version = "0.14", features = ["signed", "key-expansion"] }
|
||||||
crossbeam-channel = "0.5"
|
crossbeam-channel = "0.5"
|
||||||
diesel_migrations = { version = "1.4", features = ["sqlite"] }
|
diesel_migrations = { version = "1.4", features = ["sqlite"] }
|
||||||
|
futures-util = { version = "0.3" }
|
||||||
getopts = "0.2.15"
|
getopts = "0.2.15"
|
||||||
|
http = "0.2.2"
|
||||||
id3 = "0.5.1"
|
id3 = "0.5.1"
|
||||||
libsqlite3-sys = { version = "0.18", features = ["bundled", "bundled-windows"], optional = true }
|
libsqlite3-sys = { version = "0.18", features = ["bundled", "bundled-windows"], optional = true }
|
||||||
rustfm-scrobble = "1.1"
|
|
||||||
lewton = "0.10.1"
|
lewton = "0.10.1"
|
||||||
log = "0.4.5"
|
log = "0.4.5"
|
||||||
metaflac = "0.2.3"
|
metaflac = "0.2.3"
|
||||||
|
@ -27,17 +31,18 @@ mp3-duration = "0.1.9"
|
||||||
mp4ameta = "0.7.1"
|
mp4ameta = "0.7.1"
|
||||||
num_cpus = "1.13.0"
|
num_cpus = "1.13.0"
|
||||||
opus_headers = "0.1.2"
|
opus_headers = "0.1.2"
|
||||||
|
percent-encoding = "2.1"
|
||||||
pbkdf2 = "0.6"
|
pbkdf2 = "0.6"
|
||||||
rand = "0.7"
|
rand = "0.7"
|
||||||
rayon = "1.3"
|
rayon = "1.3"
|
||||||
regex = "1.3.9"
|
regex = "1.3.9"
|
||||||
rocket = { version = "0.4.5", optional = true }
|
rustfm-scrobble = "1.1"
|
||||||
serde = { version = "1.0.111", features = ["derive"] }
|
serde = { version = "1.0.111", features = ["derive"] }
|
||||||
serde_derive = "1.0.111"
|
serde_derive = "1.0.111"
|
||||||
serde_json = "1.0.53"
|
serde_json = "1.0.53"
|
||||||
simplelog = "0.8.0"
|
simplelog = "0.8.0"
|
||||||
thiserror = "1.0.19"
|
thiserror = "1.0.19"
|
||||||
time = "0.1"
|
time = "0.2"
|
||||||
toml = "0.5"
|
toml = "0.5"
|
||||||
ureq = "1.5"
|
ureq = "1.5"
|
||||||
url = "2.1"
|
url = "2.1"
|
||||||
|
@ -52,12 +57,6 @@ version = "0.23.12"
|
||||||
default_features = false
|
default_features = false
|
||||||
features = ["bmp", "gif", "jpeg", "png"]
|
features = ["bmp", "gif", "jpeg", "png"]
|
||||||
|
|
||||||
[dependencies.rocket_contrib]
|
|
||||||
version = "0.4.5"
|
|
||||||
default_features = false
|
|
||||||
features = ["json", "serve"]
|
|
||||||
optional = true
|
|
||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
[target.'cfg(windows)'.dependencies]
|
||||||
uuid = { version="0.8", optional = true }
|
uuid = { version="0.8", optional = true }
|
||||||
winapi = { version = "0.3.3", features = ["winuser", "libloaderapi", "shellapi", "errhandlingapi"], optional = true }
|
winapi = { version = "0.3.3", features = ["winuser", "libloaderapi", "shellapi", "errhandlingapi"], optional = true }
|
||||||
|
@ -67,7 +66,4 @@ sd-notify = "0.1.0"
|
||||||
unix-daemonize = "0.1.2"
|
unix-daemonize = "0.1.2"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
percent-encoding = "2.1"
|
|
||||||
cookie = "0.14.0"
|
|
||||||
http = "0.2.1"
|
|
||||||
headers = "0.3"
|
headers = "0.3"
|
||||||
|
|
|
@ -1 +1 @@
|
||||||
nightly-2020-12-08
|
stable
|
|
@ -39,6 +39,7 @@ struct AuthResponse {
|
||||||
pub session: AuthResponseSession,
|
pub session: AuthResponseSession,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
pub struct Manager {
|
pub struct Manager {
|
||||||
index: Index,
|
index: Index,
|
||||||
user_manager: user::Manager,
|
user_manager: user::Manager,
|
||||||
|
|
|
@ -11,6 +11,7 @@ use crate::app::index::Song;
|
||||||
use crate::app::vfs;
|
use crate::app::vfs;
|
||||||
use crate::db::{playlist_songs, playlists, users, DB};
|
use crate::db::{playlist_songs, playlists, users, DB};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
pub struct Manager {
|
pub struct Manager {
|
||||||
db: DB,
|
db: DB,
|
||||||
vfs_manager: vfs::Manager,
|
vfs_manager: vfs::Manager,
|
||||||
|
|
|
@ -7,6 +7,7 @@ use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use crate::app::thumbnail::*;
|
use crate::app::thumbnail::*;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
pub struct Manager {
|
pub struct Manager {
|
||||||
thumbnails_dir_path: PathBuf,
|
thumbnails_dir_path: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
#![recursion_limit = "256"]
|
#![recursion_limit = "256"]
|
||||||
#![feature(proc_macro_hygiene, decl_macro)]
|
|
||||||
|
|
||||||
#[macro_use]
|
#[macro_use]
|
||||||
extern crate diesel;
|
extern crate diesel;
|
||||||
|
|
690
src/service/actix/api.rs
Normal file
690
src/service/actix/api.rs
Normal file
|
@ -0,0 +1,690 @@
|
||||||
|
use actix_files::NamedFile;
|
||||||
|
use actix_web::{
|
||||||
|
client::HttpError,
|
||||||
|
delete,
|
||||||
|
dev::{MessageBody, Payload, Service, ServiceRequest, ServiceResponse},
|
||||||
|
error::{BlockingError, ErrorForbidden, ErrorInternalServerError, ErrorUnauthorized},
|
||||||
|
get,
|
||||||
|
http::StatusCode,
|
||||||
|
post, put,
|
||||||
|
web::{self, Data, Json, JsonConfig, ServiceConfig},
|
||||||
|
FromRequest, HttpMessage, HttpRequest, HttpResponse, ResponseError,
|
||||||
|
};
|
||||||
|
use actix_web_httpauth::extractors::basic::BasicAuth;
|
||||||
|
use cookie::{self, *};
|
||||||
|
use futures_util::future::{err, ok};
|
||||||
|
use percent_encoding::percent_decode_str;
|
||||||
|
use std::future::Future;
|
||||||
|
use std::ops::Deref;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::pin::Pin;
|
||||||
|
use std::str;
|
||||||
|
use time::Duration;
|
||||||
|
|
||||||
|
use crate::app::{
|
||||||
|
config,
|
||||||
|
index::{self, Index},
|
||||||
|
lastfm, playlist, thumbnail, user, vfs,
|
||||||
|
};
|
||||||
|
use crate::service::{dto, error::*};
|
||||||
|
|
||||||
|
pub fn make_config() -> impl FnOnce(&mut ServiceConfig) + Clone {
|
||||||
|
move |cfg: &mut ServiceConfig| {
|
||||||
|
let megabyte = 1024 * 1024;
|
||||||
|
cfg.app_data(JsonConfig::default().limit(4 * megabyte)) // 4MB
|
||||||
|
.service(version)
|
||||||
|
.service(initial_setup)
|
||||||
|
.service(get_settings)
|
||||||
|
.service(put_settings)
|
||||||
|
.service(get_preferences)
|
||||||
|
.service(put_preferences)
|
||||||
|
.service(trigger_index)
|
||||||
|
.service(login)
|
||||||
|
.service(browse_root)
|
||||||
|
.service(browse)
|
||||||
|
.service(flatten_root)
|
||||||
|
.service(flatten)
|
||||||
|
.service(random)
|
||||||
|
.service(recent)
|
||||||
|
.service(search_root)
|
||||||
|
.service(search)
|
||||||
|
.service(get_audio)
|
||||||
|
.service(get_thumbnail)
|
||||||
|
.service(list_playlists)
|
||||||
|
.service(save_playlist)
|
||||||
|
.service(read_playlist)
|
||||||
|
.service(delete_playlist)
|
||||||
|
.service(lastfm_now_playing)
|
||||||
|
.service(lastfm_scrobble)
|
||||||
|
.service(lastfm_link)
|
||||||
|
.service(lastfm_unlink);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResponseError for APIError {
|
||||||
|
fn status_code(&self) -> StatusCode {
|
||||||
|
match self {
|
||||||
|
APIError::IncorrectCredentials => StatusCode::UNAUTHORIZED,
|
||||||
|
APIError::OwnAdminPrivilegeRemoval => StatusCode::CONFLICT,
|
||||||
|
APIError::AudioFileIOError => StatusCode::NOT_FOUND,
|
||||||
|
APIError::ThumbnailFileIOError => StatusCode::NOT_FOUND,
|
||||||
|
APIError::LastFMAccountNotLinked => StatusCode::UNAUTHORIZED,
|
||||||
|
APIError::LastFMLinkContentBase64DecodeError => StatusCode::BAD_REQUEST,
|
||||||
|
APIError::LastFMLinkContentEncodingError => StatusCode::BAD_REQUEST,
|
||||||
|
APIError::UserNotFound => StatusCode::NOT_FOUND,
|
||||||
|
APIError::PlaylistNotFound => StatusCode::NOT_FOUND,
|
||||||
|
APIError::VFSPathNotFound => StatusCode::NOT_FOUND,
|
||||||
|
APIError::Unspecified => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct Cookies {
|
||||||
|
jar: CookieJar,
|
||||||
|
key: Key,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Cookies {
|
||||||
|
fn new(key: Key) -> Self {
|
||||||
|
let jar = CookieJar::new();
|
||||||
|
Self { jar, key }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_original(&mut self, cookie: Cookie<'static>) {
|
||||||
|
self.jar.add_original(cookie);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add(&mut self, cookie: Cookie<'static>) {
|
||||||
|
self.jar.add(cookie);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_signed(&mut self, cookie: Cookie<'static>) {
|
||||||
|
self.jar.signed(&self.key).add(cookie);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn get(&self, name: &str) -> Option<&Cookie> {
|
||||||
|
self.jar.get(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_signed(&mut self, name: &str) -> Option<Cookie> {
|
||||||
|
self.jar.signed(&self.key).get(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromRequest for Cookies {
|
||||||
|
type Error = actix_web::Error;
|
||||||
|
type Future = Pin<Box<dyn Future<Output = Result<Self, Self::Error>>>>;
|
||||||
|
type Config = ();
|
||||||
|
|
||||||
|
fn from_request(request: &HttpRequest, _payload: &mut Payload) -> Self::Future {
|
||||||
|
let request_cookies = match request.cookies() {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(_) => return Box::pin(err(ErrorInternalServerError(APIError::Unspecified))),
|
||||||
|
};
|
||||||
|
|
||||||
|
let key = match request.app_data::<Data<Key>>() {
|
||||||
|
Some(k) => k.as_ref(),
|
||||||
|
None => return Box::pin(err(ErrorInternalServerError(APIError::Unspecified))),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut cookies = Cookies::new(key.clone());
|
||||||
|
for cookie in request_cookies.deref() {
|
||||||
|
cookies.add_original(cookie.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
Box::pin(ok(cookies))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
enum AuthSource {
|
||||||
|
AuthorizationHeader,
|
||||||
|
Cookie,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct Auth {
|
||||||
|
username: String,
|
||||||
|
source: AuthSource,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromRequest for Auth {
|
||||||
|
type Error = actix_web::Error;
|
||||||
|
type Future = Pin<Box<dyn Future<Output = Result<Self, Self::Error>>>>;
|
||||||
|
type Config = ();
|
||||||
|
|
||||||
|
fn from_request(request: &HttpRequest, payload: &mut Payload) -> Self::Future {
|
||||||
|
let user_manager = match request.app_data::<Data<user::Manager>>() {
|
||||||
|
Some(m) => m.clone(),
|
||||||
|
None => return Box::pin(err(ErrorInternalServerError(APIError::Unspecified))),
|
||||||
|
};
|
||||||
|
|
||||||
|
let cookies_future = Cookies::from_request(request, payload);
|
||||||
|
let http_auth_future = BasicAuth::from_request(request, payload);
|
||||||
|
|
||||||
|
Box::pin(async move {
|
||||||
|
// Auth via session cookie
|
||||||
|
{
|
||||||
|
let mut cookies = cookies_future.await?;
|
||||||
|
if let Some(session_cookie) = cookies.get_signed(dto::COOKIE_SESSION) {
|
||||||
|
let username = session_cookie.value().to_string();
|
||||||
|
let exists = block(move || user_manager.exists(&username)).await?;
|
||||||
|
if !exists {
|
||||||
|
return Err(ErrorUnauthorized(APIError::Unspecified));
|
||||||
|
}
|
||||||
|
return Ok(Auth {
|
||||||
|
username: session_cookie.value().to_string(),
|
||||||
|
source: AuthSource::Cookie,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth via HTTP header
|
||||||
|
{
|
||||||
|
let auth = http_auth_future.await?;
|
||||||
|
let username = auth.user_id().to_string();
|
||||||
|
let password = auth
|
||||||
|
.password()
|
||||||
|
.map(|s| s.as_ref())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
let auth_result = block(move || user_manager.auth(&username, &password)).await?;
|
||||||
|
if auth_result {
|
||||||
|
Ok(Auth {
|
||||||
|
username: auth.user_id().to_string(),
|
||||||
|
source: AuthSource::AuthorizationHeader,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
Err(ErrorUnauthorized(APIError::Unspecified))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct AdminRights {
|
||||||
|
auth: Option<Auth>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromRequest for AdminRights {
|
||||||
|
type Error = actix_web::Error;
|
||||||
|
type Future = Pin<Box<dyn Future<Output = Result<Self, Self::Error>>>>;
|
||||||
|
type Config = ();
|
||||||
|
|
||||||
|
fn from_request(request: &HttpRequest, payload: &mut Payload) -> Self::Future {
|
||||||
|
let user_manager = match request.app_data::<Data<user::Manager>>() {
|
||||||
|
Some(m) => m.clone(),
|
||||||
|
None => return Box::pin(err(ErrorInternalServerError(APIError::Unspecified))),
|
||||||
|
};
|
||||||
|
|
||||||
|
let auth_future = Auth::from_request(request, payload);
|
||||||
|
|
||||||
|
Box::pin(async move {
|
||||||
|
let user_manager_count = user_manager.clone();
|
||||||
|
let user_count = block(move || user_manager_count.count()).await;
|
||||||
|
match user_count {
|
||||||
|
Err(_) => return Err(ErrorInternalServerError(APIError::Unspecified)),
|
||||||
|
Ok(0) => return Ok(AdminRights { auth: None }),
|
||||||
|
_ => (),
|
||||||
|
};
|
||||||
|
|
||||||
|
let auth = auth_future.await?;
|
||||||
|
let username = auth.username.clone();
|
||||||
|
let is_admin = block(move || user_manager.is_admin(&username)).await?;
|
||||||
|
if is_admin {
|
||||||
|
Ok(AdminRights { auth: Some(auth) })
|
||||||
|
} else {
|
||||||
|
Err(ErrorForbidden(APIError::Unspecified))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn http_auth_middleware<
|
||||||
|
B: MessageBody + 'static,
|
||||||
|
S: Service<Response = ServiceResponse<B>, Request = ServiceRequest, Error = actix_web::Error>
|
||||||
|
+ 'static,
|
||||||
|
>(
|
||||||
|
request: ServiceRequest,
|
||||||
|
service: &mut S,
|
||||||
|
) -> Pin<Box<dyn Future<Output = Result<ServiceResponse<B>, actix_web::Error>>>> {
|
||||||
|
let user_manager = match request.app_data::<Data<user::Manager>>() {
|
||||||
|
Some(m) => m.clone(),
|
||||||
|
None => return Box::pin(err(ErrorInternalServerError(APIError::Unspecified))),
|
||||||
|
};
|
||||||
|
|
||||||
|
let (request, mut payload) = request.into_parts();
|
||||||
|
let auth_future = Auth::from_request(&request, &mut payload);
|
||||||
|
let cookies_future = Cookies::from_request(&request, &mut payload);
|
||||||
|
let request = match ServiceRequest::from_parts(request, payload) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(_) => return Box::pin(err(ErrorInternalServerError(APIError::Unspecified))),
|
||||||
|
};
|
||||||
|
|
||||||
|
let response_future = service.call(request);
|
||||||
|
Box::pin(async move {
|
||||||
|
let mut response = response_future.await?;
|
||||||
|
if let Ok(auth) = auth_future.await {
|
||||||
|
let set_cookies = match auth.source {
|
||||||
|
AuthSource::AuthorizationHeader => true,
|
||||||
|
AuthSource::Cookie => false,
|
||||||
|
};
|
||||||
|
if set_cookies {
|
||||||
|
let cookies = cookies_future.await?;
|
||||||
|
let username = auth.username.clone();
|
||||||
|
let is_admin = block(move || {
|
||||||
|
user_manager
|
||||||
|
.is_admin(&auth.username)
|
||||||
|
.map_err(|_| APIError::Unspecified)
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
add_auth_cookies(response.response_mut(), &cookies, &username, is_admin)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(response)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_auth_cookies<T>(
|
||||||
|
response: &mut HttpResponse<T>,
|
||||||
|
cookies: &Cookies,
|
||||||
|
username: &str,
|
||||||
|
is_admin: bool,
|
||||||
|
) -> Result<(), HttpError> {
|
||||||
|
let duration = Duration::days(1);
|
||||||
|
|
||||||
|
let mut cookies = cookies.clone();
|
||||||
|
|
||||||
|
cookies.add_signed(
|
||||||
|
Cookie::build(dto::COOKIE_SESSION, username.to_owned())
|
||||||
|
.same_site(cookie::SameSite::Lax)
|
||||||
|
.http_only(true)
|
||||||
|
.max_age(duration)
|
||||||
|
.finish(),
|
||||||
|
);
|
||||||
|
|
||||||
|
cookies.add(
|
||||||
|
Cookie::build(dto::COOKIE_USERNAME, username.to_owned())
|
||||||
|
.same_site(cookie::SameSite::Lax)
|
||||||
|
.http_only(false)
|
||||||
|
.max_age(duration)
|
||||||
|
.path("/")
|
||||||
|
.finish(),
|
||||||
|
);
|
||||||
|
|
||||||
|
cookies.add(
|
||||||
|
Cookie::build(dto::COOKIE_ADMIN, format!("{}", is_admin))
|
||||||
|
.same_site(cookie::SameSite::Lax)
|
||||||
|
.http_only(false)
|
||||||
|
.max_age(duration)
|
||||||
|
.path("/")
|
||||||
|
.finish(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let headers = response.headers_mut();
|
||||||
|
for cookie in cookies.jar.delta() {
|
||||||
|
http::HeaderValue::from_str(&cookie.to_string()).map(|c| {
|
||||||
|
headers.append(http::header::SET_COOKIE, c);
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn block<F, I, E>(f: F) -> Result<I, APIError>
|
||||||
|
where
|
||||||
|
F: FnOnce() -> Result<I, E> + Send + 'static,
|
||||||
|
I: Send + 'static,
|
||||||
|
E: Send + std::fmt::Debug + 'static + Into<APIError>,
|
||||||
|
{
|
||||||
|
actix_web::web::block(f).await.map_err(|e| match e {
|
||||||
|
BlockingError::Error(e) => e.into(),
|
||||||
|
BlockingError::Canceled => APIError::Unspecified,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/version")]
|
||||||
|
async fn version() -> Json<dto::Version> {
|
||||||
|
let current_version = dto::Version {
|
||||||
|
major: dto::API_MAJOR_VERSION,
|
||||||
|
minor: dto::API_MINOR_VERSION,
|
||||||
|
};
|
||||||
|
Json(current_version)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/initial_setup")]
|
||||||
|
async fn initial_setup(
|
||||||
|
user_manager: Data<user::Manager>,
|
||||||
|
) -> Result<Json<dto::InitialSetup>, APIError> {
|
||||||
|
let initial_setup = block(move || -> Result<dto::InitialSetup, APIError> {
|
||||||
|
let user_count = user_manager.count()?;
|
||||||
|
Ok(dto::InitialSetup {
|
||||||
|
has_any_users: user_count > 0,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
Ok(Json(initial_setup))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/settings")]
|
||||||
|
async fn get_settings(
|
||||||
|
config_manager: Data<config::Manager>,
|
||||||
|
_admin_rights: AdminRights,
|
||||||
|
) -> Result<Json<config::Config>, APIError> {
|
||||||
|
let config = block(move || config_manager.read()).await?;
|
||||||
|
Ok(Json(config))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[put("/settings")]
|
||||||
|
async fn put_settings(
|
||||||
|
admin_rights: AdminRights,
|
||||||
|
config_manager: Data<config::Manager>,
|
||||||
|
config: Json<config::Config>,
|
||||||
|
) -> Result<HttpResponse, APIError> {
|
||||||
|
// Do not let users remove their own admin rights
|
||||||
|
if let Some(auth) = &admin_rights.auth {
|
||||||
|
if let Some(users) = &config.users {
|
||||||
|
for user in users {
|
||||||
|
if auth.username == user.name && !user.admin {
|
||||||
|
return Err(APIError::OwnAdminPrivilegeRemoval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
block(move || config_manager.amend(&config)).await?;
|
||||||
|
Ok(HttpResponse::new(StatusCode::OK))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/preferences")]
|
||||||
|
async fn get_preferences(
|
||||||
|
user_manager: Data<user::Manager>,
|
||||||
|
auth: Auth,
|
||||||
|
) -> Result<Json<user::Preferences>, APIError> {
|
||||||
|
let preferences = block(move || user_manager.read_preferences(&auth.username)).await?;
|
||||||
|
Ok(Json(preferences))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[put("/preferences")]
|
||||||
|
async fn put_preferences(
|
||||||
|
user_manager: Data<user::Manager>,
|
||||||
|
auth: Auth,
|
||||||
|
preferences: Json<user::Preferences>,
|
||||||
|
) -> Result<HttpResponse, APIError> {
|
||||||
|
block(move || user_manager.write_preferences(&auth.username, &preferences)).await?;
|
||||||
|
Ok(HttpResponse::new(StatusCode::OK))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/trigger_index")]
|
||||||
|
async fn trigger_index(
|
||||||
|
index: Data<Index>,
|
||||||
|
_admin_rights: AdminRights,
|
||||||
|
) -> Result<HttpResponse, APIError> {
|
||||||
|
index.trigger_reindex();
|
||||||
|
Ok(HttpResponse::new(StatusCode::OK))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/auth")]
|
||||||
|
async fn login(
|
||||||
|
user_manager: Data<user::Manager>,
|
||||||
|
credentials: Json<dto::AuthCredentials>,
|
||||||
|
cookies: Cookies,
|
||||||
|
) -> Result<HttpResponse, APIError> {
|
||||||
|
let username = credentials.username.clone();
|
||||||
|
let is_admin = block(move || {
|
||||||
|
if !user_manager.auth(&credentials.username, &credentials.password)? {
|
||||||
|
return Err(APIError::IncorrectCredentials);
|
||||||
|
}
|
||||||
|
user_manager
|
||||||
|
.is_admin(&credentials.username)
|
||||||
|
.map_err(|_| APIError::Unspecified)
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
let mut response = HttpResponse::Ok().finish();
|
||||||
|
add_auth_cookies(&mut response, &cookies, &username, is_admin)
|
||||||
|
.map_err(|_| APIError::Unspecified)?;
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/browse")]
|
||||||
|
async fn browse_root(
|
||||||
|
index: Data<Index>,
|
||||||
|
_auth: Auth,
|
||||||
|
) -> Result<Json<Vec<index::CollectionFile>>, APIError> {
|
||||||
|
let result = block(move || index.browse(Path::new(""))).await?;
|
||||||
|
Ok(Json(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/browse/{path:.*}")]
|
||||||
|
async fn browse(
|
||||||
|
index: Data<Index>,
|
||||||
|
_auth: Auth,
|
||||||
|
path: web::Path<String>,
|
||||||
|
) -> Result<Json<Vec<index::CollectionFile>>, APIError> {
|
||||||
|
let result = block(move || {
|
||||||
|
let path = percent_decode_str(&(path.0)).decode_utf8_lossy();
|
||||||
|
index.browse(Path::new(path.as_ref()))
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
Ok(Json(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/flatten")]
|
||||||
|
async fn flatten_root(index: Data<Index>, _auth: Auth) -> Result<Json<Vec<index::Song>>, APIError> {
|
||||||
|
let songs = block(move || index.flatten(Path::new(""))).await?;
|
||||||
|
Ok(Json(songs))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/flatten/{path:.*}")]
|
||||||
|
async fn flatten(
|
||||||
|
index: Data<Index>,
|
||||||
|
_auth: Auth,
|
||||||
|
path: web::Path<String>,
|
||||||
|
) -> Result<Json<Vec<index::Song>>, APIError> {
|
||||||
|
let songs = block(move || {
|
||||||
|
let path = percent_decode_str(&(path.0)).decode_utf8_lossy();
|
||||||
|
index.flatten(Path::new(path.as_ref()))
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
Ok(Json(songs))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/random")]
|
||||||
|
async fn random(index: Data<Index>, _auth: Auth) -> Result<Json<Vec<index::Directory>>, APIError> {
|
||||||
|
let result = block(move || index.get_random_albums(20)).await?;
|
||||||
|
Ok(Json(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/recent")]
|
||||||
|
async fn recent(index: Data<Index>, _auth: Auth) -> Result<Json<Vec<index::Directory>>, APIError> {
|
||||||
|
let result = block(move || index.get_recent_albums(20)).await?;
|
||||||
|
Ok(Json(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/search")]
|
||||||
|
async fn search_root(
|
||||||
|
index: Data<Index>,
|
||||||
|
_auth: Auth,
|
||||||
|
) -> Result<Json<Vec<index::CollectionFile>>, APIError> {
|
||||||
|
let result = block(move || index.search("")).await?;
|
||||||
|
Ok(Json(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/search/{query:.*}")]
|
||||||
|
async fn search(
|
||||||
|
index: Data<Index>,
|
||||||
|
_auth: Auth,
|
||||||
|
query: web::Path<String>,
|
||||||
|
) -> Result<Json<Vec<index::CollectionFile>>, APIError> {
|
||||||
|
let result = block(move || index.search(&query)).await?;
|
||||||
|
Ok(Json(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/audio/{path:.*}")]
|
||||||
|
async fn get_audio(
|
||||||
|
vfs_manager: Data<vfs::Manager>,
|
||||||
|
_auth: Auth,
|
||||||
|
path: web::Path<String>,
|
||||||
|
) -> Result<NamedFile, APIError> {
|
||||||
|
let audio_path = block(move || {
|
||||||
|
let vfs = vfs_manager.get_vfs()?;
|
||||||
|
let path = percent_decode_str(&(path.0)).decode_utf8_lossy();
|
||||||
|
vfs.virtual_to_real(Path::new(path.as_ref()))
|
||||||
|
.map_err(|_| APIError::VFSPathNotFound)
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let named_file = NamedFile::open(&audio_path).map_err(|_| APIError::AudioFileIOError)?;
|
||||||
|
Ok(named_file)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/thumbnail/{path:.*}")]
|
||||||
|
async fn get_thumbnail(
|
||||||
|
vfs_manager: Data<vfs::Manager>,
|
||||||
|
thumbnails_manager: Data<thumbnail::Manager>,
|
||||||
|
_auth: Auth,
|
||||||
|
path: web::Path<String>,
|
||||||
|
options_input: web::Query<dto::ThumbnailOptions>,
|
||||||
|
) -> Result<NamedFile, APIError> {
|
||||||
|
let mut options = thumbnail::Options::default();
|
||||||
|
options.pad_to_square = options_input.pad.unwrap_or(options.pad_to_square);
|
||||||
|
|
||||||
|
let thumbnail_path = block(move || {
|
||||||
|
let vfs = vfs_manager.get_vfs()?;
|
||||||
|
let path = percent_decode_str(&(path.0)).decode_utf8_lossy();
|
||||||
|
let image_path = vfs
|
||||||
|
.virtual_to_real(Path::new(path.as_ref()))
|
||||||
|
.map_err(|_| APIError::VFSPathNotFound)?;
|
||||||
|
thumbnails_manager
|
||||||
|
.get_thumbnail(&image_path, &options)
|
||||||
|
.map_err(|_| APIError::Unspecified)
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let named_file =
|
||||||
|
NamedFile::open(&thumbnail_path).map_err(|_| APIError::ThumbnailFileIOError)?;
|
||||||
|
|
||||||
|
Ok(named_file)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/playlists")]
|
||||||
|
async fn list_playlists(
|
||||||
|
playlist_manager: Data<playlist::Manager>,
|
||||||
|
auth: Auth,
|
||||||
|
) -> Result<Json<Vec<dto::ListPlaylistsEntry>>, APIError> {
|
||||||
|
let playlist_names = block(move || playlist_manager.list_playlists(&auth.username)).await?;
|
||||||
|
let playlists: Vec<dto::ListPlaylistsEntry> = playlist_names
|
||||||
|
.into_iter()
|
||||||
|
.map(|p| dto::ListPlaylistsEntry { name: p })
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(playlists))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[put("/playlist/{name}")]
|
||||||
|
async fn save_playlist(
|
||||||
|
playlist_manager: Data<playlist::Manager>,
|
||||||
|
auth: Auth,
|
||||||
|
name: web::Path<String>,
|
||||||
|
playlist: Json<dto::SavePlaylistInput>,
|
||||||
|
) -> Result<HttpResponse, APIError> {
|
||||||
|
block(move || playlist_manager.save_playlist(&name, &auth.username, &playlist.tracks)).await?;
|
||||||
|
Ok(HttpResponse::new(StatusCode::OK))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/playlist/{name}")]
|
||||||
|
async fn read_playlist(
|
||||||
|
playlist_manager: Data<playlist::Manager>,
|
||||||
|
auth: Auth,
|
||||||
|
name: web::Path<String>,
|
||||||
|
) -> Result<Json<Vec<index::Song>>, APIError> {
|
||||||
|
let songs = block(move || playlist_manager.read_playlist(&name, &auth.username)).await?;
|
||||||
|
Ok(Json(songs))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[delete("/playlist/{name}")]
|
||||||
|
async fn delete_playlist(
|
||||||
|
playlist_manager: Data<playlist::Manager>,
|
||||||
|
auth: Auth,
|
||||||
|
name: web::Path<String>,
|
||||||
|
) -> Result<HttpResponse, APIError> {
|
||||||
|
block(move || playlist_manager.delete_playlist(&name, &auth.username)).await?;
|
||||||
|
Ok(HttpResponse::new(StatusCode::OK))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[put("/lastfm/now_playing/{path:.*}")]
|
||||||
|
async fn lastfm_now_playing(
|
||||||
|
lastfm_manager: Data<lastfm::Manager>,
|
||||||
|
user_manager: Data<user::Manager>,
|
||||||
|
auth: Auth,
|
||||||
|
path: web::Path<String>,
|
||||||
|
) -> Result<HttpResponse, APIError> {
|
||||||
|
block(move || -> Result<(), APIError> {
|
||||||
|
if !user_manager.is_lastfm_linked(&auth.username) {
|
||||||
|
return Err(APIError::LastFMAccountNotLinked);
|
||||||
|
}
|
||||||
|
let path = percent_decode_str(&(path.0)).decode_utf8_lossy();
|
||||||
|
lastfm_manager.now_playing(&auth.username, Path::new(path.as_ref()))?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
Ok(HttpResponse::new(StatusCode::OK))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/lastfm/scrobble/{path:.*}")]
|
||||||
|
async fn lastfm_scrobble(
|
||||||
|
lastfm_manager: Data<lastfm::Manager>,
|
||||||
|
user_manager: Data<user::Manager>,
|
||||||
|
auth: Auth,
|
||||||
|
path: web::Path<String>,
|
||||||
|
) -> Result<HttpResponse, APIError> {
|
||||||
|
block(move || -> Result<(), APIError> {
|
||||||
|
if !user_manager.is_lastfm_linked(&auth.username) {
|
||||||
|
return Err(APIError::LastFMAccountNotLinked);
|
||||||
|
}
|
||||||
|
let path = percent_decode_str(&(path.0)).decode_utf8_lossy();
|
||||||
|
lastfm_manager.scrobble(&auth.username, Path::new(path.as_ref()))?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
Ok(HttpResponse::new(StatusCode::OK))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/lastfm/link")]
|
||||||
|
async fn lastfm_link(
|
||||||
|
lastfm_manager: Data<lastfm::Manager>,
|
||||||
|
auth: Auth,
|
||||||
|
payload: web::Query<dto::LastFMLink>,
|
||||||
|
) -> Result<HttpResponse, APIError> {
|
||||||
|
let popup_content_string = block(move || {
|
||||||
|
lastfm_manager.link(&auth.username, &payload.token)?;
|
||||||
|
// Percent decode
|
||||||
|
let base64_content = percent_decode_str(&payload.content).decode_utf8_lossy();
|
||||||
|
|
||||||
|
// Base64 decode
|
||||||
|
let popup_content = base64::decode(base64_content.as_bytes())
|
||||||
|
.map_err(|_| APIError::LastFMLinkContentBase64DecodeError)?;
|
||||||
|
|
||||||
|
// UTF-8 decode
|
||||||
|
str::from_utf8(&popup_content)
|
||||||
|
.map_err(|_| APIError::LastFMLinkContentEncodingError)
|
||||||
|
.map(|s| s.to_owned())
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(HttpResponse::build(StatusCode::OK)
|
||||||
|
.content_type("text/html; charset=utf-8")
|
||||||
|
.body(popup_content_string))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[delete("/lastfm/link")]
|
||||||
|
async fn lastfm_unlink(
|
||||||
|
lastfm_manager: Data<lastfm::Manager>,
|
||||||
|
auth: Auth,
|
||||||
|
) -> Result<HttpResponse, APIError> {
|
||||||
|
block(move || lastfm_manager.unlink(&auth.username)).await?;
|
||||||
|
Ok(HttpResponse::new(StatusCode::OK))
|
||||||
|
}
|
58
src/service/actix/mod.rs
Normal file
58
src/service/actix/mod.rs
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
use actix_web::{
|
||||||
|
middleware::{normalize::TrailingSlash, Compress, Logger, NormalizePath},
|
||||||
|
rt::System,
|
||||||
|
web::{self, ServiceConfig},
|
||||||
|
App, HttpServer,
|
||||||
|
};
|
||||||
|
use anyhow::*;
|
||||||
|
use log::error;
|
||||||
|
|
||||||
|
use crate::service;
|
||||||
|
|
||||||
|
mod api;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub mod test;
|
||||||
|
|
||||||
|
pub fn make_config(context: service::Context) -> impl FnOnce(&mut ServiceConfig) + Clone {
|
||||||
|
move |cfg: &mut ServiceConfig| {
|
||||||
|
let encryption_key = cookie::Key::derive_from(&context.auth_secret[..]);
|
||||||
|
cfg.app_data(web::Data::new(context.index))
|
||||||
|
.app_data(web::Data::new(context.config_manager))
|
||||||
|
.app_data(web::Data::new(context.lastfm_manager))
|
||||||
|
.app_data(web::Data::new(context.playlist_manager))
|
||||||
|
.app_data(web::Data::new(context.thumbnail_manager))
|
||||||
|
.app_data(web::Data::new(context.user_manager))
|
||||||
|
.app_data(web::Data::new(context.vfs_manager))
|
||||||
|
.app_data(web::Data::new(encryption_key))
|
||||||
|
.service(web::scope(&context.api_url).configure(api::make_config()))
|
||||||
|
.service(
|
||||||
|
actix_files::Files::new(&context.swagger_url, context.swagger_dir_path)
|
||||||
|
.index_file("index.html"),
|
||||||
|
)
|
||||||
|
.service(
|
||||||
|
actix_files::Files::new(&context.web_url, context.web_dir_path)
|
||||||
|
.index_file("index.html"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run(context: service::Context) -> Result<()> {
|
||||||
|
System::run(move || {
|
||||||
|
let address = format!("0.0.0.0:{}", context.port);
|
||||||
|
HttpServer::new(move || {
|
||||||
|
App::new()
|
||||||
|
.wrap(Logger::default())
|
||||||
|
.wrap(Compress::default())
|
||||||
|
.wrap_fn(api::http_auth_middleware)
|
||||||
|
.wrap(NormalizePath::new(TrailingSlash::Trim))
|
||||||
|
.configure(make_config(context.clone()))
|
||||||
|
})
|
||||||
|
.disable_signals()
|
||||||
|
.bind(address)
|
||||||
|
.map(|server| server.run())
|
||||||
|
.map_err(|e| error!("Error starting HTTP server: {:?}", e))
|
||||||
|
.ok();
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
|
}
|
155
src/service/actix/test.rs
Normal file
155
src/service/actix/test.rs
Normal file
|
@ -0,0 +1,155 @@
|
||||||
|
use actix_web::{
|
||||||
|
client::ClientResponse,
|
||||||
|
middleware::{normalize::TrailingSlash, Compress, Logger, NormalizePath},
|
||||||
|
rt::{System, SystemRunner},
|
||||||
|
test,
|
||||||
|
test::*,
|
||||||
|
web::Bytes,
|
||||||
|
App,
|
||||||
|
};
|
||||||
|
use cookie::Cookie;
|
||||||
|
use http::{header, response::Builder, Method, Request, Response};
|
||||||
|
use serde::de::DeserializeOwned;
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::ops::Deref;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use crate::service::actix::*;
|
||||||
|
use crate::service::test::TestService;
|
||||||
|
|
||||||
|
pub struct ActixTestService {
|
||||||
|
system_runner: SystemRunner,
|
||||||
|
cookies: HashMap<String, String>,
|
||||||
|
server: TestServer,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type ServiceType = ActixTestService;
|
||||||
|
|
||||||
|
impl ActixTestService {
|
||||||
|
fn update_cookies<T>(&mut self, actix_response: &ClientResponse<T>) {
|
||||||
|
let cookies = actix_response.headers().get_all(header::SET_COOKIE);
|
||||||
|
for raw_cookie in cookies {
|
||||||
|
let cookie = Cookie::parse(raw_cookie.to_str().unwrap()).unwrap();
|
||||||
|
self.cookies
|
||||||
|
.insert(cookie.name().to_owned(), cookie.value().to_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_internal<T: Serialize + Clone + 'static>(
|
||||||
|
&mut self,
|
||||||
|
request: &Request<T>,
|
||||||
|
) -> (Builder, Option<Bytes>) {
|
||||||
|
let url = request.uri().to_string();
|
||||||
|
let body = request.body().clone();
|
||||||
|
|
||||||
|
let mut actix_request = match *request.method() {
|
||||||
|
Method::GET => self.server.get(url),
|
||||||
|
Method::POST => self.server.post(url),
|
||||||
|
Method::PUT => self.server.put(url),
|
||||||
|
Method::DELETE => self.server.delete(url),
|
||||||
|
_ => unimplemented!(),
|
||||||
|
};
|
||||||
|
|
||||||
|
for (name, value) in request.headers() {
|
||||||
|
actix_request = actix_request.set_header(name, value.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
actix_request = {
|
||||||
|
let cookies_value = self
|
||||||
|
.cookies
|
||||||
|
.iter()
|
||||||
|
.map(|(name, value)| format!("{}={}", name, value))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("; ");
|
||||||
|
actix_request.set_header(header::COOKIE, cookies_value)
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut actix_response = self
|
||||||
|
.system_runner
|
||||||
|
.block_on(async move { actix_request.send_json(&body).await.unwrap() });
|
||||||
|
|
||||||
|
self.update_cookies(&actix_response);
|
||||||
|
|
||||||
|
let mut response_builder = Response::builder().status(actix_response.status());
|
||||||
|
let headers = response_builder.headers_mut().unwrap();
|
||||||
|
for (name, value) in actix_response.headers().iter() {
|
||||||
|
headers.append(name, value.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let is_success = actix_response.status().is_success();
|
||||||
|
let body = if is_success {
|
||||||
|
Some(
|
||||||
|
self.system_runner
|
||||||
|
.block_on(async move { actix_response.body().await.unwrap() }),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
(response_builder, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TestService for ActixTestService {
|
||||||
|
fn new(test_name: &str) -> Self {
|
||||||
|
let mut db_path: PathBuf = ["test-output", test_name].iter().collect();
|
||||||
|
fs::create_dir_all(&db_path).unwrap();
|
||||||
|
db_path.push("db.sqlite");
|
||||||
|
|
||||||
|
if db_path.exists() {
|
||||||
|
fs::remove_file(&db_path).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let context = service::ContextBuilder::new()
|
||||||
|
.port(5050)
|
||||||
|
.database_file_path(db_path)
|
||||||
|
.web_dir_path(Path::new("test-data/web").into())
|
||||||
|
.swagger_dir_path(["docs", "swagger"].iter().collect())
|
||||||
|
.cache_dir_path(["test-output", test_name].iter().collect())
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let system_runner = System::new("test");
|
||||||
|
let server = test::start(move || {
|
||||||
|
let config = make_config(context.clone());
|
||||||
|
App::new()
|
||||||
|
.wrap(Logger::default())
|
||||||
|
.wrap(Compress::default())
|
||||||
|
.wrap_fn(api::http_auth_middleware)
|
||||||
|
.wrap(NormalizePath::new(TrailingSlash::Trim))
|
||||||
|
.configure(config)
|
||||||
|
});
|
||||||
|
|
||||||
|
ActixTestService {
|
||||||
|
cookies: HashMap::new(),
|
||||||
|
system_runner,
|
||||||
|
server,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fetch<T: Serialize + Clone + 'static>(&mut self, request: &Request<T>) -> Response<()> {
|
||||||
|
let (response_builder, _body) = self.process_internal(request);
|
||||||
|
response_builder.body(()).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fetch_bytes<T: Serialize + Clone + 'static>(
|
||||||
|
&mut self,
|
||||||
|
request: &Request<T>,
|
||||||
|
) -> Response<Vec<u8>> {
|
||||||
|
let (response_builder, body) = self.process_internal(request);
|
||||||
|
response_builder
|
||||||
|
.body(body.unwrap().deref().to_owned())
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fetch_json<T: Serialize + Clone + 'static, U: DeserializeOwned>(
|
||||||
|
&mut self,
|
||||||
|
request: &Request<T>,
|
||||||
|
) -> Response<U> {
|
||||||
|
let (response_builder, body) = self.process_internal(request);
|
||||||
|
let body = serde_json::from_slice(&body.unwrap()).unwrap();
|
||||||
|
response_builder.body(body).unwrap()
|
||||||
|
}
|
||||||
|
}
|
|
@ -17,20 +17,31 @@ pub struct InitialSetup {
|
||||||
pub has_any_users: bool,
|
pub has_any_users: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
pub struct AuthCredentials {
|
pub struct AuthCredentials {
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub password: String,
|
pub password: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct ThumbnailOptions {
|
||||||
|
pub pad: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct ListPlaylistsEntry {
|
pub struct ListPlaylistsEntry {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
pub struct SavePlaylistInput {
|
pub struct SavePlaylistInput {
|
||||||
pub tracks: Vec<String>,
|
pub tracks: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct LastFMLink {
|
||||||
|
pub token: String,
|
||||||
|
pub content: String,
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: Config, Preferences, CollectionFile, Song and Directory should have dto types
|
// TODO: Config, Preferences, CollectionFile, Song and Directory should have dto types
|
||||||
|
|
|
@ -1,11 +1,24 @@
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
|
use crate::app::index::QueryError;
|
||||||
|
use crate::app::playlist;
|
||||||
|
|
||||||
#[derive(Error, Debug)]
|
#[derive(Error, Debug)]
|
||||||
pub enum APIError {
|
pub enum APIError {
|
||||||
#[error("Incorrect Credentials")]
|
#[error("Incorrect Credentials")]
|
||||||
IncorrectCredentials,
|
IncorrectCredentials,
|
||||||
#[error("Cannot remove own admin privilege")]
|
#[error("Cannot remove own admin privilege")]
|
||||||
OwnAdminPrivilegeRemoval,
|
OwnAdminPrivilegeRemoval,
|
||||||
|
#[error("Audio file could not be opened")]
|
||||||
|
AudioFileIOError,
|
||||||
|
#[error("Thumbnail file could not be opened")]
|
||||||
|
ThumbnailFileIOError,
|
||||||
|
#[error("No last.fm account has been linked")]
|
||||||
|
LastFMAccountNotLinked,
|
||||||
|
#[error("Could not decode content as base64 after linking last.fm account")]
|
||||||
|
LastFMLinkContentBase64DecodeError,
|
||||||
|
#[error("Could not decode content as UTF-8 after linking last.fm account")]
|
||||||
|
LastFMLinkContentEncodingError,
|
||||||
#[error("Path not found in virtual filesystem")]
|
#[error("Path not found in virtual filesystem")]
|
||||||
VFSPathNotFound,
|
VFSPathNotFound,
|
||||||
#[error("User not found")]
|
#[error("User not found")]
|
||||||
|
@ -21,3 +34,22 @@ impl From<anyhow::Error> for APIError {
|
||||||
APIError::Unspecified
|
APIError::Unspecified
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<playlist::Error> for APIError {
|
||||||
|
fn from(error: playlist::Error) -> APIError {
|
||||||
|
match error {
|
||||||
|
playlist::Error::PlaylistNotFound => APIError::PlaylistNotFound,
|
||||||
|
playlist::Error::UserNotFound => APIError::UserNotFound,
|
||||||
|
playlist::Error::Unspecified => APIError::Unspecified,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<QueryError> for APIError {
|
||||||
|
fn from(error: QueryError) -> APIError {
|
||||||
|
match error {
|
||||||
|
QueryError::VFSPathNotFound => APIError::VFSPathNotFound,
|
||||||
|
QueryError::Unspecified => APIError::Unspecified,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -10,11 +10,10 @@ mod error;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod test;
|
mod test;
|
||||||
|
|
||||||
#[cfg(feature = "service-rocket")]
|
mod actix;
|
||||||
mod rocket;
|
pub use actix::*;
|
||||||
#[cfg(feature = "service-rocket")]
|
|
||||||
pub use self::rocket::*;
|
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
pub struct Context {
|
pub struct Context {
|
||||||
pub port: u16,
|
pub port: u16,
|
||||||
pub auth_secret: Vec<u8>,
|
pub auth_secret: Vec<u8>,
|
||||||
|
|
|
@ -1,484 +0,0 @@
|
||||||
use anyhow::*;
|
|
||||||
use rocket::http::{Cookie, Cookies, RawStr, Status};
|
|
||||||
use rocket::request::{self, FromParam, FromRequest, Request};
|
|
||||||
use rocket::response::content::Html;
|
|
||||||
use rocket::{delete, get, post, put, routes, Outcome, State};
|
|
||||||
use rocket_contrib::json::Json;
|
|
||||||
use std::default::Default;
|
|
||||||
use std::fs::File;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::str;
|
|
||||||
use std::str::FromStr;
|
|
||||||
use time::Duration;
|
|
||||||
|
|
||||||
use super::serve;
|
|
||||||
use crate::app::index::{self, Index, QueryError};
|
|
||||||
use crate::app::{config, lastfm, playlist, thumbnail, user, vfs};
|
|
||||||
use crate::service::dto;
|
|
||||||
use crate::service::error::APIError;
|
|
||||||
|
|
||||||
pub fn get_routes() -> Vec<rocket::Route> {
|
|
||||||
routes![
|
|
||||||
version,
|
|
||||||
initial_setup,
|
|
||||||
get_settings,
|
|
||||||
put_settings,
|
|
||||||
get_preferences,
|
|
||||||
put_preferences,
|
|
||||||
trigger_index,
|
|
||||||
auth,
|
|
||||||
browse_root,
|
|
||||||
browse,
|
|
||||||
flatten_root,
|
|
||||||
flatten,
|
|
||||||
random,
|
|
||||||
recent,
|
|
||||||
search_root,
|
|
||||||
search,
|
|
||||||
audio,
|
|
||||||
thumbnail,
|
|
||||||
list_playlists,
|
|
||||||
save_playlist,
|
|
||||||
read_playlist,
|
|
||||||
delete_playlist,
|
|
||||||
lastfm_link,
|
|
||||||
lastfm_unlink,
|
|
||||||
lastfm_now_playing,
|
|
||||||
lastfm_scrobble,
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'r> rocket::response::Responder<'r> for APIError {
|
|
||||||
fn respond_to(self, _: &rocket::request::Request<'_>) -> rocket::response::Result<'r> {
|
|
||||||
let status = match self {
|
|
||||||
APIError::IncorrectCredentials => rocket::http::Status::Unauthorized,
|
|
||||||
APIError::OwnAdminPrivilegeRemoval => rocket::http::Status::Conflict,
|
|
||||||
APIError::VFSPathNotFound => rocket::http::Status::NotFound,
|
|
||||||
APIError::UserNotFound => rocket::http::Status::NotFound,
|
|
||||||
APIError::PlaylistNotFound => rocket::http::Status::NotFound,
|
|
||||||
APIError::Unspecified => rocket::http::Status::InternalServerError,
|
|
||||||
};
|
|
||||||
rocket::response::Response::build().status(status).ok()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<playlist::Error> for APIError {
|
|
||||||
fn from(error: playlist::Error) -> APIError {
|
|
||||||
match error {
|
|
||||||
playlist::Error::PlaylistNotFound => APIError::PlaylistNotFound,
|
|
||||||
playlist::Error::UserNotFound => APIError::UserNotFound,
|
|
||||||
playlist::Error::Unspecified => APIError::Unspecified,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<QueryError> for APIError {
|
|
||||||
fn from(error: QueryError) -> APIError {
|
|
||||||
match error {
|
|
||||||
QueryError::VFSPathNotFound => APIError::VFSPathNotFound,
|
|
||||||
QueryError::Unspecified => APIError::Unspecified,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Auth {
|
|
||||||
username: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn add_session_cookies(cookies: &mut Cookies, username: &str, is_admin: bool) -> () {
|
|
||||||
let duration = Duration::days(1);
|
|
||||||
|
|
||||||
let session_cookie = Cookie::build(dto::COOKIE_SESSION, username.to_owned())
|
|
||||||
.same_site(rocket::http::SameSite::Lax)
|
|
||||||
.http_only(true)
|
|
||||||
.max_age(duration)
|
|
||||||
.finish();
|
|
||||||
|
|
||||||
let username_cookie = Cookie::build(dto::COOKIE_USERNAME, username.to_owned())
|
|
||||||
.same_site(rocket::http::SameSite::Lax)
|
|
||||||
.http_only(false)
|
|
||||||
.max_age(duration)
|
|
||||||
.path("/")
|
|
||||||
.finish();
|
|
||||||
|
|
||||||
let is_admin_cookie = Cookie::build(dto::COOKIE_ADMIN, format!("{}", is_admin))
|
|
||||||
.same_site(rocket::http::SameSite::Lax)
|
|
||||||
.http_only(false)
|
|
||||||
.max_age(duration)
|
|
||||||
.path("/")
|
|
||||||
.finish();
|
|
||||||
|
|
||||||
cookies.add_private(session_cookie);
|
|
||||||
cookies.add(username_cookie);
|
|
||||||
cookies.add(is_admin_cookie);
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a, 'r> FromRequest<'a, 'r> for Auth {
|
|
||||||
type Error = ();
|
|
||||||
|
|
||||||
fn from_request(request: &'a Request<'r>) -> request::Outcome<Self, ()> {
|
|
||||||
let mut cookies = request.guard::<Cookies<'_>>().unwrap();
|
|
||||||
let user_manager = match request.guard::<State<'_, user::Manager>>() {
|
|
||||||
Outcome::Success(d) => d,
|
|
||||||
_ => return Outcome::Failure((Status::InternalServerError, ())),
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(u) = cookies.get_private(dto::COOKIE_SESSION) {
|
|
||||||
let exists = match user_manager.exists(u.value()) {
|
|
||||||
Ok(e) => e,
|
|
||||||
Err(_) => return Outcome::Failure((Status::InternalServerError, ())),
|
|
||||||
};
|
|
||||||
if !exists {
|
|
||||||
return Outcome::Failure((Status::Unauthorized, ()));
|
|
||||||
}
|
|
||||||
return Outcome::Success(Auth {
|
|
||||||
username: u.value().to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(auth_header_string) = request.headers().get_one("Authorization") {
|
|
||||||
use rocket::http::hyper::header::*;
|
|
||||||
if let Ok(Basic {
|
|
||||||
username,
|
|
||||||
password: Some(password),
|
|
||||||
}) = Basic::from_str(auth_header_string.trim_start_matches("Basic "))
|
|
||||||
{
|
|
||||||
if user_manager.auth(&username, &password).unwrap_or(false) {
|
|
||||||
let is_admin = match user_manager.is_admin(&username) {
|
|
||||||
Ok(a) => a,
|
|
||||||
Err(_) => return Outcome::Failure((Status::InternalServerError, ())),
|
|
||||||
};
|
|
||||||
add_session_cookies(&mut cookies, &username, is_admin);
|
|
||||||
return Outcome::Success(Auth {
|
|
||||||
username: username.to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Outcome::Failure((Status::Unauthorized, ()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct AdminRights {
|
|
||||||
auth: Option<Auth>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a, 'r> FromRequest<'a, 'r> for AdminRights {
|
|
||||||
type Error = ();
|
|
||||||
|
|
||||||
fn from_request(request: &'a Request<'r>) -> request::Outcome<Self, ()> {
|
|
||||||
let user_manager = request.guard::<State<'_, user::Manager>>()?;
|
|
||||||
|
|
||||||
match user_manager.count() {
|
|
||||||
Err(_) => return Outcome::Failure((Status::InternalServerError, ())),
|
|
||||||
Ok(0) => return Outcome::Success(AdminRights { auth: None }),
|
|
||||||
_ => (),
|
|
||||||
};
|
|
||||||
|
|
||||||
let auth = request.guard::<Auth>()?;
|
|
||||||
match user_manager.is_admin(&auth.username) {
|
|
||||||
Err(_) => Outcome::Failure((Status::InternalServerError, ())),
|
|
||||||
Ok(true) => Outcome::Success(AdminRights { auth: Some(auth) }),
|
|
||||||
Ok(false) => Outcome::Failure((Status::Forbidden, ())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct VFSPathBuf {
|
|
||||||
path_buf: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'r> FromParam<'r> for VFSPathBuf {
|
|
||||||
type Error = &'r RawStr;
|
|
||||||
|
|
||||||
fn from_param(param: &'r RawStr) -> Result<Self, Self::Error> {
|
|
||||||
let decoded_path = param.percent_decode_lossy();
|
|
||||||
Ok(VFSPathBuf {
|
|
||||||
path_buf: PathBuf::from(decoded_path.into_owned()),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<VFSPathBuf> for PathBuf {
|
|
||||||
fn from(vfs_path_buf: VFSPathBuf) -> Self {
|
|
||||||
vfs_path_buf.path_buf.clone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[get("/version")]
|
|
||||||
fn version() -> Json<dto::Version> {
|
|
||||||
let current_version = dto::Version {
|
|
||||||
major: dto::API_MAJOR_VERSION,
|
|
||||||
minor: dto::API_MINOR_VERSION,
|
|
||||||
};
|
|
||||||
Json(current_version)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[get("/initial_setup")]
|
|
||||||
fn initial_setup(user_manager: State<'_, user::Manager>) -> Result<Json<dto::InitialSetup>> {
|
|
||||||
let initial_setup = dto::InitialSetup {
|
|
||||||
has_any_users: user_manager.count()? > 0,
|
|
||||||
};
|
|
||||||
Ok(Json(initial_setup))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[get("/settings")]
|
|
||||||
fn get_settings(
|
|
||||||
config_manager: State<'_, config::Manager>,
|
|
||||||
_admin_rights: AdminRights,
|
|
||||||
) -> Result<Json<config::Config>> {
|
|
||||||
let config = config_manager.read()?;
|
|
||||||
Ok(Json(config))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[put("/settings", data = "<config>")]
|
|
||||||
fn put_settings(
|
|
||||||
config_manager: State<'_, config::Manager>,
|
|
||||||
admin_rights: AdminRights,
|
|
||||||
config: Json<config::Config>,
|
|
||||||
) -> Result<(), APIError> {
|
|
||||||
// Do not let users remove their own admin rights
|
|
||||||
if let Some(auth) = &admin_rights.auth {
|
|
||||||
if let Some(users) = &config.users {
|
|
||||||
for user in users {
|
|
||||||
if auth.username == user.name && !user.admin {
|
|
||||||
return Err(APIError::OwnAdminPrivilegeRemoval);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
config_manager.amend(&config)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[get("/preferences")]
|
|
||||||
fn get_preferences(
|
|
||||||
user_manager: State<'_, user::Manager>,
|
|
||||||
auth: Auth,
|
|
||||||
) -> Result<Json<user::Preferences>> {
|
|
||||||
let preferences = user_manager.read_preferences(&auth.username)?;
|
|
||||||
Ok(Json(preferences))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[put("/preferences", data = "<preferences>")]
|
|
||||||
fn put_preferences(
|
|
||||||
user_manager: State<'_, user::Manager>,
|
|
||||||
auth: Auth,
|
|
||||||
preferences: Json<user::Preferences>,
|
|
||||||
) -> Result<()> {
|
|
||||||
user_manager.write_preferences(&auth.username, &preferences)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[post("/trigger_index")]
|
|
||||||
fn trigger_index(index: State<'_, Index>, _admin_rights: AdminRights) -> Result<()> {
|
|
||||||
index.trigger_reindex();
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[post("/auth", data = "<credentials>")]
|
|
||||||
fn auth(
|
|
||||||
user_manager: State<'_, user::Manager>,
|
|
||||||
credentials: Json<dto::AuthCredentials>,
|
|
||||||
mut cookies: Cookies<'_>,
|
|
||||||
) -> std::result::Result<(), APIError> {
|
|
||||||
if !user_manager.auth(&credentials.username, &credentials.password)? {
|
|
||||||
return Err(APIError::IncorrectCredentials);
|
|
||||||
}
|
|
||||||
let is_admin = user_manager.is_admin(&credentials.username)?;
|
|
||||||
add_session_cookies(&mut cookies, &credentials.username, is_admin);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[get("/browse")]
|
|
||||||
fn browse_root(index: State<'_, Index>, _auth: Auth) -> Result<Json<Vec<index::CollectionFile>>> {
|
|
||||||
let result = index.browse(&Path::new(""))?;
|
|
||||||
Ok(Json(result))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[get("/browse/<path>")]
|
|
||||||
fn browse(
|
|
||||||
index: State<'_, Index>,
|
|
||||||
_auth: Auth,
|
|
||||||
path: VFSPathBuf,
|
|
||||||
) -> Result<Json<Vec<index::CollectionFile>>, APIError> {
|
|
||||||
let result = index.browse(&path.into() as &PathBuf)?;
|
|
||||||
Ok(Json(result))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[get("/flatten")]
|
|
||||||
fn flatten_root(index: State<'_, Index>, _auth: Auth) -> Result<Json<Vec<index::Song>>> {
|
|
||||||
let result = index.flatten(&PathBuf::new())?;
|
|
||||||
Ok(Json(result))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[get("/flatten/<path>")]
|
|
||||||
fn flatten(
|
|
||||||
index: State<'_, Index>,
|
|
||||||
_auth: Auth,
|
|
||||||
path: VFSPathBuf,
|
|
||||||
) -> Result<Json<Vec<index::Song>>, APIError> {
|
|
||||||
let result = index.flatten(&path.into() as &PathBuf)?;
|
|
||||||
Ok(Json(result))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[get("/random")]
|
|
||||||
fn random(index: State<'_, Index>, _auth: Auth) -> Result<Json<Vec<index::Directory>>> {
|
|
||||||
let result = index.get_random_albums(20)?;
|
|
||||||
Ok(Json(result))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[get("/recent")]
|
|
||||||
fn recent(index: State<'_, Index>, _auth: Auth) -> Result<Json<Vec<index::Directory>>> {
|
|
||||||
let result = index.get_recent_albums(20)?;
|
|
||||||
Ok(Json(result))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[get("/search")]
|
|
||||||
fn search_root(index: State<'_, Index>, _auth: Auth) -> Result<Json<Vec<index::CollectionFile>>> {
|
|
||||||
let result = index.search("")?;
|
|
||||||
Ok(Json(result))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[get("/search/<query>")]
|
|
||||||
fn search(
|
|
||||||
index: State<'_, Index>,
|
|
||||||
_auth: Auth,
|
|
||||||
query: String,
|
|
||||||
) -> Result<Json<Vec<index::CollectionFile>>> {
|
|
||||||
let result = index.search(&query)?;
|
|
||||||
Ok(Json(result))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[get("/audio/<path>")]
|
|
||||||
fn audio(
|
|
||||||
vfs_manager: State<'_, vfs::Manager>,
|
|
||||||
_auth: Auth,
|
|
||||||
path: VFSPathBuf,
|
|
||||||
) -> Result<serve::RangeResponder<File>, APIError> {
|
|
||||||
let vfs = vfs_manager.get_vfs()?;
|
|
||||||
let real_path = vfs
|
|
||||||
.virtual_to_real(&path.into() as &PathBuf)
|
|
||||||
.map_err(|_| APIError::VFSPathNotFound)?;
|
|
||||||
let file = File::open(&real_path).map_err(|_| APIError::Unspecified)?;
|
|
||||||
Ok(serve::RangeResponder::new(file))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[get("/thumbnail/<path>?<pad>")]
|
|
||||||
fn thumbnail(
|
|
||||||
vfs_manager: State<'_, vfs::Manager>,
|
|
||||||
thumbnail_manager: State<'_, thumbnail::Manager>,
|
|
||||||
_auth: Auth,
|
|
||||||
path: VFSPathBuf,
|
|
||||||
pad: Option<bool>,
|
|
||||||
) -> Result<File, APIError> {
|
|
||||||
let vfs = vfs_manager.get_vfs()?;
|
|
||||||
let image_path = vfs
|
|
||||||
.virtual_to_real(&path.into() as &PathBuf)
|
|
||||||
.map_err(|_| APIError::VFSPathNotFound)?;
|
|
||||||
let mut options = thumbnail::Options::default();
|
|
||||||
options.pad_to_square = pad.unwrap_or(options.pad_to_square);
|
|
||||||
let thumbnail_path = thumbnail_manager.get_thumbnail(&image_path, &options)?;
|
|
||||||
let file = File::open(thumbnail_path).map_err(|_| APIError::Unspecified)?;
|
|
||||||
Ok(file)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[get("/playlists")]
|
|
||||||
fn list_playlists(
|
|
||||||
playlist_manager: State<'_, playlist::Manager>,
|
|
||||||
auth: Auth,
|
|
||||||
) -> Result<Json<Vec<dto::ListPlaylistsEntry>>> {
|
|
||||||
let playlist_names = playlist_manager.list_playlists(&auth.username)?;
|
|
||||||
let playlists: Vec<dto::ListPlaylistsEntry> = playlist_names
|
|
||||||
.into_iter()
|
|
||||||
.map(|p| dto::ListPlaylistsEntry { name: p })
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(Json(playlists))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[put("/playlist/<name>", data = "<playlist>")]
|
|
||||||
fn save_playlist(
|
|
||||||
playlist_manager: State<'_, playlist::Manager>,
|
|
||||||
auth: Auth,
|
|
||||||
name: String,
|
|
||||||
playlist: Json<dto::SavePlaylistInput>,
|
|
||||||
) -> Result<()> {
|
|
||||||
playlist_manager.save_playlist(&name, &auth.username, &playlist.tracks)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[get("/playlist/<name>")]
|
|
||||||
fn read_playlist(
|
|
||||||
playlist_manager: State<'_, playlist::Manager>,
|
|
||||||
auth: Auth,
|
|
||||||
name: String,
|
|
||||||
) -> Result<Json<Vec<index::Song>>, APIError> {
|
|
||||||
let songs = playlist_manager.read_playlist(&name, &auth.username)?;
|
|
||||||
Ok(Json(songs))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[delete("/playlist/<name>")]
|
|
||||||
fn delete_playlist(
|
|
||||||
playlist_manager: State<'_, playlist::Manager>,
|
|
||||||
auth: Auth,
|
|
||||||
name: String,
|
|
||||||
) -> Result<(), APIError> {
|
|
||||||
playlist_manager.delete_playlist(&name, &auth.username)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[put("/lastfm/now_playing/<path>")]
|
|
||||||
fn lastfm_now_playing(
|
|
||||||
user_manager: State<'_, user::Manager>,
|
|
||||||
lastfm_manager: State<'_, lastfm::Manager>,
|
|
||||||
auth: Auth,
|
|
||||||
path: VFSPathBuf,
|
|
||||||
) -> Result<()> {
|
|
||||||
if user_manager.is_lastfm_linked(&auth.username) {
|
|
||||||
lastfm_manager.now_playing(&auth.username, &path.into() as &PathBuf)?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[post("/lastfm/scrobble/<path>")]
|
|
||||||
fn lastfm_scrobble(
|
|
||||||
user_manager: State<'_, user::Manager>,
|
|
||||||
lastfm_manager: State<'_, lastfm::Manager>,
|
|
||||||
auth: Auth,
|
|
||||||
path: VFSPathBuf,
|
|
||||||
) -> Result<()> {
|
|
||||||
if user_manager.is_lastfm_linked(&auth.username) {
|
|
||||||
lastfm_manager.scrobble(&auth.username, &path.into() as &PathBuf)?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[get("/lastfm/link?<token>&<content>")]
|
|
||||||
fn lastfm_link(
|
|
||||||
lastfm_manager: State<'_, lastfm::Manager>,
|
|
||||||
auth: Auth,
|
|
||||||
token: String,
|
|
||||||
content: String,
|
|
||||||
) -> Result<Html<String>> {
|
|
||||||
lastfm_manager.link(&auth.username, &token)?;
|
|
||||||
|
|
||||||
// Percent decode
|
|
||||||
let base64_content = RawStr::from_str(&content).percent_decode()?;
|
|
||||||
|
|
||||||
// Base64 decode
|
|
||||||
let popup_content = base64::decode(base64_content.as_bytes())?;
|
|
||||||
|
|
||||||
// UTF-8 decode
|
|
||||||
let popup_content_string = str::from_utf8(&popup_content)?;
|
|
||||||
|
|
||||||
Ok(Html(popup_content_string.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[delete("/lastfm/link")]
|
|
||||||
fn lastfm_unlink(lastfm_manager: State<'_, lastfm::Manager>, auth: Auth) -> Result<()> {
|
|
||||||
lastfm_manager.unlink(&auth.username)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
|
@ -1,53 +0,0 @@
|
||||||
use anyhow::*;
|
|
||||||
use rocket;
|
|
||||||
use rocket::config::{Environment, LoggingLevel};
|
|
||||||
use rocket_contrib::serve::{Options, StaticFiles};
|
|
||||||
|
|
||||||
use crate::service;
|
|
||||||
|
|
||||||
mod api;
|
|
||||||
mod serve;
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
pub mod test;
|
|
||||||
|
|
||||||
pub fn get_server(context: service::Context) -> Result<rocket::Rocket> {
|
|
||||||
let mut config = rocket::Config::build(Environment::Production)
|
|
||||||
.log_level(LoggingLevel::Normal)
|
|
||||||
.port(context.port)
|
|
||||||
.keep_alive(0)
|
|
||||||
.finalize()?;
|
|
||||||
|
|
||||||
let encoded = base64::encode(&context.auth_secret);
|
|
||||||
config.set_secret_key(encoded)?;
|
|
||||||
|
|
||||||
let swagger_routes_rank = 0;
|
|
||||||
let web_routes_rank = swagger_routes_rank + 1;
|
|
||||||
let static_file_options = Options::Index | Options::NormalizeDirs;
|
|
||||||
|
|
||||||
Ok(rocket::custom(config)
|
|
||||||
.manage(context.db)
|
|
||||||
.manage(context.index)
|
|
||||||
.manage(context.config_manager)
|
|
||||||
.manage(context.lastfm_manager)
|
|
||||||
.manage(context.playlist_manager)
|
|
||||||
.manage(context.thumbnail_manager)
|
|
||||||
.manage(context.user_manager)
|
|
||||||
.manage(context.vfs_manager)
|
|
||||||
.mount(&context.api_url, api::get_routes())
|
|
||||||
.mount(
|
|
||||||
&context.swagger_url,
|
|
||||||
StaticFiles::new(context.swagger_dir_path, static_file_options)
|
|
||||||
.rank(swagger_routes_rank),
|
|
||||||
)
|
|
||||||
.mount(
|
|
||||||
&context.web_url,
|
|
||||||
StaticFiles::new(context.web_dir_path, static_file_options).rank(web_routes_rank),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn run(context: service::Context) -> Result<()> {
|
|
||||||
let server = get_server(context)?;
|
|
||||||
server.launch();
|
|
||||||
Ok(())
|
|
||||||
}
|
|
|
@ -1,163 +0,0 @@
|
||||||
use log::warn;
|
|
||||||
use rocket;
|
|
||||||
use rocket::http::hyper::header::*;
|
|
||||||
use rocket::http::Status;
|
|
||||||
use rocket::response::{self, Responder};
|
|
||||||
use rocket::Response;
|
|
||||||
use std::cmp;
|
|
||||||
use std::convert::From;
|
|
||||||
use std::fs::File;
|
|
||||||
use std::io::{Read, Seek, SeekFrom};
|
|
||||||
use std::str::FromStr;
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum PartialFileRange {
|
|
||||||
AllFrom(u64),
|
|
||||||
FromTo(u64, u64),
|
|
||||||
Last(u64),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<ByteRangeSpec> for PartialFileRange {
|
|
||||||
fn from(b: ByteRangeSpec) -> PartialFileRange {
|
|
||||||
match b {
|
|
||||||
ByteRangeSpec::AllFrom(from) => PartialFileRange::AllFrom(from),
|
|
||||||
ByteRangeSpec::FromTo(from, to) => PartialFileRange::FromTo(from, to),
|
|
||||||
ByteRangeSpec::Last(last) => PartialFileRange::Last(last),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<Vec<ByteRangeSpec>> for PartialFileRange {
|
|
||||||
fn from(v: Vec<ByteRangeSpec>) -> PartialFileRange {
|
|
||||||
match v.into_iter().next() {
|
|
||||||
None => PartialFileRange::AllFrom(0),
|
|
||||||
Some(byte_range) => PartialFileRange::from(byte_range),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct RangeResponder<R> {
|
|
||||||
original: R,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'r, R: Responder<'r>> RangeResponder<R> {
|
|
||||||
pub fn new(original: R) -> RangeResponder<R> {
|
|
||||||
RangeResponder { original }
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ignore_range(
|
|
||||||
self,
|
|
||||||
request: &rocket::request::Request<'_>,
|
|
||||||
file_length: Option<u64>,
|
|
||||||
) -> response::Result<'r> {
|
|
||||||
let mut response = self.original.respond_to(request)?;
|
|
||||||
if let Some(content_length) = file_length {
|
|
||||||
response.set_header(ContentLength(content_length));
|
|
||||||
}
|
|
||||||
response.set_status(Status::Ok);
|
|
||||||
Ok(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn reject_range(self, file_length: Option<u64>) -> response::Result<'r> {
|
|
||||||
let mut response = Response::build()
|
|
||||||
.status(Status::RangeNotSatisfiable)
|
|
||||||
.finalize();
|
|
||||||
if file_length.is_some() {
|
|
||||||
let content_range = ContentRange(ContentRangeSpec::Bytes {
|
|
||||||
range: None,
|
|
||||||
instance_length: file_length,
|
|
||||||
});
|
|
||||||
response.set_header(content_range);
|
|
||||||
}
|
|
||||||
response.set_status(Status::RangeNotSatisfiable);
|
|
||||||
Ok(response)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn truncate_range(range: &PartialFileRange, file_length: &Option<u64>) -> Option<(u64, u64)> {
|
|
||||||
use self::PartialFileRange::*;
|
|
||||||
|
|
||||||
match (range, file_length) {
|
|
||||||
(FromTo(from, to), Some(file_length)) => {
|
|
||||||
if from <= to && from < file_length {
|
|
||||||
Some((*from, cmp::min(*to, file_length - 1)))
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(AllFrom(from), Some(file_length)) => {
|
|
||||||
if from < file_length {
|
|
||||||
Some((*from, file_length - 1))
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(Last(last), Some(file_length)) => {
|
|
||||||
if last < file_length {
|
|
||||||
Some((file_length - last, file_length - 1))
|
|
||||||
} else {
|
|
||||||
Some((0, file_length - 1))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(_, None) => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'r> Responder<'r> for RangeResponder<File> {
|
|
||||||
fn respond_to(mut self, request: &rocket::request::Request<'_>) -> response::Result<'r> {
|
|
||||||
let metadata: Option<_> = self.original.metadata().ok();
|
|
||||||
let file_length: Option<u64> = metadata.map(|m| m.len());
|
|
||||||
|
|
||||||
let range_header = request.headers().get_one("Range");
|
|
||||||
let range_header = match range_header {
|
|
||||||
None => return self.ignore_range(request, file_length),
|
|
||||||
Some(h) => h,
|
|
||||||
};
|
|
||||||
|
|
||||||
let vec_range = match Range::from_str(range_header) {
|
|
||||||
Ok(Range::Bytes(v)) => v,
|
|
||||||
_ => {
|
|
||||||
warn!(
|
|
||||||
"Ignoring range header that could not be parsed {:?}, file length is {:?}",
|
|
||||||
range_header, file_length
|
|
||||||
);
|
|
||||||
return self.ignore_range(request, file_length);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let partial_file_range = match vec_range.into_iter().next() {
|
|
||||||
None => PartialFileRange::AllFrom(0),
|
|
||||||
Some(byte_range) => PartialFileRange::from(byte_range),
|
|
||||||
};
|
|
||||||
|
|
||||||
let range: Option<(u64, u64)> = truncate_range(&partial_file_range, &file_length);
|
|
||||||
|
|
||||||
if let Some((from, to)) = range {
|
|
||||||
let content_range = ContentRange(ContentRangeSpec::Bytes {
|
|
||||||
range: range,
|
|
||||||
instance_length: file_length,
|
|
||||||
});
|
|
||||||
let content_len = to - from + 1;
|
|
||||||
|
|
||||||
match self.original.seek(SeekFrom::Start(from)) {
|
|
||||||
Ok(_) => (),
|
|
||||||
Err(_) => return Err(rocket::http::Status::InternalServerError),
|
|
||||||
}
|
|
||||||
let partial_original = self.original.take(content_len);
|
|
||||||
let response = Response::build()
|
|
||||||
.status(Status::PartialContent)
|
|
||||||
.header(ContentLength(content_len))
|
|
||||||
.header(content_range)
|
|
||||||
.streamed_body(partial_original)
|
|
||||||
.finalize();
|
|
||||||
|
|
||||||
Ok(response)
|
|
||||||
} else {
|
|
||||||
warn!(
|
|
||||||
"Rejecting unsatisfiable range header {:?}, file length is {:?}",
|
|
||||||
&partial_file_range, &file_length
|
|
||||||
);
|
|
||||||
self.reject_range(file_length)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,112 +0,0 @@
|
||||||
use http::{header::HeaderName, method::Method, response::Builder, HeaderValue, Request, Response};
|
|
||||||
use rocket;
|
|
||||||
use rocket::local::{Client, LocalResponse};
|
|
||||||
use serde::de::DeserializeOwned;
|
|
||||||
use serde::Serialize;
|
|
||||||
use std::fs;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
use crate::service;
|
|
||||||
use crate::service::test::{protocol, TestService};
|
|
||||||
|
|
||||||
pub struct RocketTestService {
|
|
||||||
client: Client,
|
|
||||||
request_builder: protocol::RequestBuilder,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type ServiceType = RocketTestService;
|
|
||||||
|
|
||||||
impl RocketTestService {
|
|
||||||
fn process_internal<T: Serialize>(&mut self, request: &Request<T>) -> (LocalResponse, Builder) {
|
|
||||||
let rocket_response = {
|
|
||||||
let url = request.uri().to_string();
|
|
||||||
let mut rocket_request = match *request.method() {
|
|
||||||
Method::GET => self.client.get(url),
|
|
||||||
Method::POST => self.client.post(url),
|
|
||||||
Method::PUT => self.client.put(url),
|
|
||||||
Method::DELETE => self.client.delete(url),
|
|
||||||
_ => unimplemented!(),
|
|
||||||
};
|
|
||||||
|
|
||||||
for (name, value) in request.headers() {
|
|
||||||
rocket_request.add_header(rocket::http::Header::new(
|
|
||||||
name.as_str().to_owned(),
|
|
||||||
value.to_str().unwrap().to_owned(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let payload = request.body();
|
|
||||||
let body = serde_json::to_string(payload).unwrap();
|
|
||||||
rocket_request.set_body(body);
|
|
||||||
|
|
||||||
let content_type = rocket::http::ContentType::JSON;
|
|
||||||
rocket_request.add_header(content_type);
|
|
||||||
|
|
||||||
rocket_request.dispatch()
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut builder = Response::builder().status(rocket_response.status().code);
|
|
||||||
let headers = builder.headers_mut().unwrap();
|
|
||||||
for header in rocket_response.headers().iter() {
|
|
||||||
headers.append(
|
|
||||||
HeaderName::from_bytes(header.name.as_str().as_bytes()).unwrap(),
|
|
||||||
HeaderValue::from_str(header.value.as_ref()).unwrap(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
(rocket_response, builder)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TestService for RocketTestService {
|
|
||||||
fn new(test_name: &str) -> Self {
|
|
||||||
let mut db_path: PathBuf = ["test-output", test_name].iter().collect();
|
|
||||||
fs::create_dir_all(&db_path).unwrap();
|
|
||||||
db_path.push("db.sqlite");
|
|
||||||
|
|
||||||
if db_path.exists() {
|
|
||||||
fs::remove_file(&db_path).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
let context = service::ContextBuilder::new()
|
|
||||||
.database_file_path(db_path)
|
|
||||||
.web_dir_path(Path::new("test-data/web").into())
|
|
||||||
.swagger_dir_path(["docs", "swagger"].iter().collect())
|
|
||||||
.cache_dir_path(["test-output", test_name].iter().collect())
|
|
||||||
.build()
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let server = service::get_server(context).unwrap();
|
|
||||||
let client = Client::new(server).unwrap();
|
|
||||||
let request_builder = protocol::RequestBuilder::new();
|
|
||||||
RocketTestService {
|
|
||||||
request_builder,
|
|
||||||
client,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn request_builder(&self) -> &protocol::RequestBuilder {
|
|
||||||
&self.request_builder
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fetch<T: Serialize>(&mut self, request: &Request<T>) -> Response<()> {
|
|
||||||
let (_, builder) = self.process_internal(request);
|
|
||||||
builder.body(()).unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fetch_bytes<T: Serialize>(&mut self, request: &Request<T>) -> Response<Vec<u8>> {
|
|
||||||
let (mut rocket_response, builder) = self.process_internal(request);
|
|
||||||
let body = rocket_response.body().unwrap().into_bytes().unwrap();
|
|
||||||
builder.body(body).unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fetch_json<T: Serialize, U: DeserializeOwned>(
|
|
||||||
&mut self,
|
|
||||||
request: &Request<T>,
|
|
||||||
) -> Response<U> {
|
|
||||||
let (mut rocket_response, builder) = self.process_internal(request);
|
|
||||||
let body = rocket_response.body_string().unwrap();
|
|
||||||
let body = serde_json::from_str(&body).unwrap();
|
|
||||||
builder.body(body).unwrap()
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -2,13 +2,13 @@ use http::StatusCode;
|
||||||
|
|
||||||
use crate::app::index;
|
use crate::app::index;
|
||||||
use crate::service::dto;
|
use crate::service::dto;
|
||||||
use crate::service::test::{ServiceType, TestService};
|
use crate::service::test::{protocol, ServiceType, TestService};
|
||||||
use crate::test_name;
|
use crate::test_name;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_returns_api_version() {
|
fn test_returns_api_version() {
|
||||||
let mut service = ServiceType::new(&test_name!());
|
let mut service = ServiceType::new(&test_name!());
|
||||||
let request = service.request_builder().version();
|
let request = protocol::version();
|
||||||
let response = service.fetch_json::<_, dto::Version>(&request);
|
let response = service.fetch_json::<_, dto::Version>(&request);
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
}
|
}
|
||||||
|
@ -16,7 +16,7 @@ fn test_returns_api_version() {
|
||||||
#[test]
|
#[test]
|
||||||
fn test_initial_setup_golden_path() {
|
fn test_initial_setup_golden_path() {
|
||||||
let mut service = ServiceType::new(&test_name!());
|
let mut service = ServiceType::new(&test_name!());
|
||||||
let request = service.request_builder().initial_setup();
|
let request = protocol::initial_setup();
|
||||||
{
|
{
|
||||||
let response = service.fetch_json::<_, dto::InitialSetup>(&request);
|
let response = service.fetch_json::<_, dto::InitialSetup>(&request);
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
@ -48,7 +48,7 @@ fn test_trigger_index_golden_path() {
|
||||||
service.complete_initial_setup();
|
service.complete_initial_setup();
|
||||||
service.login_admin();
|
service.login_admin();
|
||||||
|
|
||||||
let request = service.request_builder().random();
|
let request = protocol::random();
|
||||||
|
|
||||||
let response = service.fetch_json::<_, Vec<index::Directory>>(&request);
|
let response = service.fetch_json::<_, Vec<index::Directory>>(&request);
|
||||||
let entries = response.body();
|
let entries = response.body();
|
||||||
|
@ -65,7 +65,7 @@ fn test_trigger_index_golden_path() {
|
||||||
fn test_trigger_index_requires_auth() {
|
fn test_trigger_index_requires_auth() {
|
||||||
let mut service = ServiceType::new(&test_name!());
|
let mut service = ServiceType::new(&test_name!());
|
||||||
service.complete_initial_setup();
|
service.complete_initial_setup();
|
||||||
let request = service.request_builder().trigger_index();
|
let request = protocol::trigger_index();
|
||||||
let response = service.fetch(&request);
|
let response = service.fetch(&request);
|
||||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
|
@ -75,7 +75,7 @@ fn test_trigger_index_requires_admin() {
|
||||||
let mut service = ServiceType::new(&test_name!());
|
let mut service = ServiceType::new(&test_name!());
|
||||||
service.complete_initial_setup();
|
service.complete_initial_setup();
|
||||||
service.login();
|
service.login();
|
||||||
let request = service.request_builder().trigger_index();
|
let request = protocol::trigger_index();
|
||||||
let response = service.fetch(&request);
|
let response = service.fetch(&request);
|
||||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,7 @@ use headers::{self, HeaderMapExt};
|
||||||
use http::{Response, StatusCode};
|
use http::{Response, StatusCode};
|
||||||
|
|
||||||
use crate::service::dto;
|
use crate::service::dto;
|
||||||
use crate::service::test::{constants::*, ServiceType, TestService};
|
use crate::service::test::{constants::*, protocol, ServiceType, TestService};
|
||||||
use crate::test_name;
|
use crate::test_name;
|
||||||
|
|
||||||
fn validate_cookies<T>(response: &Response<T>) {
|
fn validate_cookies<T>(response: &Response<T>) {
|
||||||
|
@ -46,7 +46,7 @@ fn test_login_rejects_bad_username() {
|
||||||
let mut service = ServiceType::new(&test_name!());
|
let mut service = ServiceType::new(&test_name!());
|
||||||
service.complete_initial_setup();
|
service.complete_initial_setup();
|
||||||
|
|
||||||
let request = service.request_builder().login("garbage", TEST_PASSWORD);
|
let request = protocol::login("garbage", TEST_PASSWORD);
|
||||||
let response = service.fetch(&request);
|
let response = service.fetch(&request);
|
||||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
|
@ -56,7 +56,7 @@ fn test_login_rejects_bad_password() {
|
||||||
let mut service = ServiceType::new(&test_name!());
|
let mut service = ServiceType::new(&test_name!());
|
||||||
service.complete_initial_setup();
|
service.complete_initial_setup();
|
||||||
|
|
||||||
let request = service.request_builder().login(TEST_USERNAME, "garbage");
|
let request = protocol::login(TEST_USERNAME, "garbage");
|
||||||
let response = service.fetch(&request);
|
let response = service.fetch(&request);
|
||||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
|
@ -66,9 +66,7 @@ fn test_login_golden_path() {
|
||||||
let mut service = ServiceType::new(&test_name!());
|
let mut service = ServiceType::new(&test_name!());
|
||||||
service.complete_initial_setup();
|
service.complete_initial_setup();
|
||||||
|
|
||||||
let request = service
|
let request = protocol::login(TEST_USERNAME, TEST_PASSWORD);
|
||||||
.request_builder()
|
|
||||||
.login(TEST_USERNAME, TEST_PASSWORD);
|
|
||||||
let response = service.fetch(&request);
|
let response = service.fetch(&request);
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
@ -81,7 +79,7 @@ fn test_requests_without_auth_header_do_not_set_cookies() {
|
||||||
service.complete_initial_setup();
|
service.complete_initial_setup();
|
||||||
service.login();
|
service.login();
|
||||||
|
|
||||||
let request = service.request_builder().random();
|
let request = protocol::random();
|
||||||
let response = service.fetch(&request);
|
let response = service.fetch(&request);
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
@ -93,7 +91,7 @@ fn test_authentication_via_http_header_rejects_bad_username() {
|
||||||
let mut service = ServiceType::new(&test_name!());
|
let mut service = ServiceType::new(&test_name!());
|
||||||
service.complete_initial_setup();
|
service.complete_initial_setup();
|
||||||
|
|
||||||
let mut request = service.request_builder().random();
|
let mut request = protocol::random();
|
||||||
let basic = headers::Authorization::basic("garbage", TEST_PASSWORD);
|
let basic = headers::Authorization::basic("garbage", TEST_PASSWORD);
|
||||||
request.headers_mut().typed_insert(basic);
|
request.headers_mut().typed_insert(basic);
|
||||||
|
|
||||||
|
@ -106,7 +104,7 @@ fn test_authentication_via_http_header_rejects_bad_password() {
|
||||||
let mut service = ServiceType::new(&test_name!());
|
let mut service = ServiceType::new(&test_name!());
|
||||||
service.complete_initial_setup();
|
service.complete_initial_setup();
|
||||||
|
|
||||||
let mut request = service.request_builder().random();
|
let mut request = protocol::random();
|
||||||
let basic = headers::Authorization::basic(TEST_PASSWORD, "garbage");
|
let basic = headers::Authorization::basic(TEST_PASSWORD, "garbage");
|
||||||
request.headers_mut().typed_insert(basic);
|
request.headers_mut().typed_insert(basic);
|
||||||
|
|
||||||
|
@ -119,7 +117,7 @@ fn test_authentication_via_http_header_golden_path() {
|
||||||
let mut service = ServiceType::new(&test_name!());
|
let mut service = ServiceType::new(&test_name!());
|
||||||
service.complete_initial_setup();
|
service.complete_initial_setup();
|
||||||
|
|
||||||
let mut request = service.request_builder().random();
|
let mut request = protocol::random();
|
||||||
let basic = headers::Authorization::basic(TEST_USERNAME, TEST_PASSWORD);
|
let basic = headers::Authorization::basic(TEST_USERNAME, TEST_PASSWORD);
|
||||||
request.headers_mut().typed_insert(basic);
|
request.headers_mut().typed_insert(basic);
|
||||||
|
|
||||||
|
|
|
@ -2,13 +2,13 @@ use http::StatusCode;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use crate::app::index;
|
use crate::app::index;
|
||||||
use crate::service::test::{add_trailing_slash, constants::*, ServiceType, TestService};
|
use crate::service::test::{add_trailing_slash, constants::*, protocol, ServiceType, TestService};
|
||||||
use crate::test_name;
|
use crate::test_name;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_browse_requires_auth() {
|
fn test_browse_requires_auth() {
|
||||||
let mut service = ServiceType::new(&test_name!());
|
let mut service = ServiceType::new(&test_name!());
|
||||||
let request = service.request_builder().browse(&PathBuf::new());
|
let request = protocol::browse(&PathBuf::new());
|
||||||
let response = service.fetch(&request);
|
let response = service.fetch(&request);
|
||||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
|
@ -21,7 +21,7 @@ fn test_browse_root() {
|
||||||
service.index();
|
service.index();
|
||||||
service.login();
|
service.login();
|
||||||
|
|
||||||
let request = service.request_builder().browse(&PathBuf::new());
|
let request = protocol::browse(&PathBuf::new());
|
||||||
let response = service.fetch_json::<_, Vec<index::CollectionFile>>(&request);
|
let response = service.fetch_json::<_, Vec<index::CollectionFile>>(&request);
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
let entries = response.body();
|
let entries = response.body();
|
||||||
|
@ -37,7 +37,7 @@ fn test_browse_directory() {
|
||||||
service.login();
|
service.login();
|
||||||
|
|
||||||
let path: PathBuf = [TEST_MOUNT_NAME, "Khemmis", "Hunted"].iter().collect();
|
let path: PathBuf = [TEST_MOUNT_NAME, "Khemmis", "Hunted"].iter().collect();
|
||||||
let request = service.request_builder().browse(&path);
|
let request = protocol::browse(&path);
|
||||||
let response = service.fetch_json::<_, Vec<index::CollectionFile>>(&request);
|
let response = service.fetch_json::<_, Vec<index::CollectionFile>>(&request);
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
let entries = response.body();
|
let entries = response.body();
|
||||||
|
@ -51,7 +51,7 @@ fn test_browse_bad_directory() {
|
||||||
service.login();
|
service.login();
|
||||||
|
|
||||||
let path: PathBuf = ["not_my_collection"].iter().collect();
|
let path: PathBuf = ["not_my_collection"].iter().collect();
|
||||||
let request = service.request_builder().browse(&path);
|
let request = protocol::browse(&path);
|
||||||
let response = service.fetch(&request);
|
let response = service.fetch(&request);
|
||||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
@ -59,7 +59,7 @@ fn test_browse_bad_directory() {
|
||||||
#[test]
|
#[test]
|
||||||
fn test_flatten_requires_auth() {
|
fn test_flatten_requires_auth() {
|
||||||
let mut service = ServiceType::new(&test_name!());
|
let mut service = ServiceType::new(&test_name!());
|
||||||
let request = service.request_builder().flatten(&PathBuf::new());
|
let request = protocol::flatten(&PathBuf::new());
|
||||||
let response = service.fetch(&request);
|
let response = service.fetch(&request);
|
||||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
|
@ -72,7 +72,7 @@ fn test_flatten_root() {
|
||||||
service.index();
|
service.index();
|
||||||
service.login();
|
service.login();
|
||||||
|
|
||||||
let request = service.request_builder().flatten(&PathBuf::new());
|
let request = protocol::flatten(&PathBuf::new());
|
||||||
let response = service.fetch_json::<_, Vec<index::Song>>(&request);
|
let response = service.fetch_json::<_, Vec<index::Song>>(&request);
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
let entries = response.body();
|
let entries = response.body();
|
||||||
|
@ -87,9 +87,7 @@ fn test_flatten_directory() {
|
||||||
service.index();
|
service.index();
|
||||||
service.login();
|
service.login();
|
||||||
|
|
||||||
let request = service
|
let request = protocol::flatten(Path::new(TEST_MOUNT_NAME));
|
||||||
.request_builder()
|
|
||||||
.flatten(Path::new(TEST_MOUNT_NAME));
|
|
||||||
let response = service.fetch_json::<_, Vec<index::Song>>(&request);
|
let response = service.fetch_json::<_, Vec<index::Song>>(&request);
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
let entries = response.body();
|
let entries = response.body();
|
||||||
|
@ -103,7 +101,7 @@ fn test_flatten_bad_directory() {
|
||||||
service.login();
|
service.login();
|
||||||
|
|
||||||
let path: PathBuf = ["not_my_collection"].iter().collect();
|
let path: PathBuf = ["not_my_collection"].iter().collect();
|
||||||
let request = service.request_builder().flatten(&path);
|
let request = protocol::flatten(&path);
|
||||||
let response = service.fetch(&request);
|
let response = service.fetch(&request);
|
||||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
@ -111,7 +109,7 @@ fn test_flatten_bad_directory() {
|
||||||
#[test]
|
#[test]
|
||||||
fn test_random_requires_auth() {
|
fn test_random_requires_auth() {
|
||||||
let mut service = ServiceType::new(&test_name!());
|
let mut service = ServiceType::new(&test_name!());
|
||||||
let request = service.request_builder().random();
|
let request = protocol::random();
|
||||||
let response = service.fetch(&request);
|
let response = service.fetch(&request);
|
||||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
|
@ -124,7 +122,7 @@ fn test_random_golden_path() {
|
||||||
service.index();
|
service.index();
|
||||||
service.login();
|
service.login();
|
||||||
|
|
||||||
let request = service.request_builder().random();
|
let request = protocol::random();
|
||||||
let response = service.fetch_json::<_, Vec<index::Directory>>(&request);
|
let response = service.fetch_json::<_, Vec<index::Directory>>(&request);
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
let entries = response.body();
|
let entries = response.body();
|
||||||
|
@ -139,7 +137,7 @@ fn test_random_with_trailing_slash() {
|
||||||
service.index();
|
service.index();
|
||||||
service.login();
|
service.login();
|
||||||
|
|
||||||
let mut request = service.request_builder().random();
|
let mut request = protocol::random();
|
||||||
add_trailing_slash(&mut request);
|
add_trailing_slash(&mut request);
|
||||||
let response = service.fetch_json::<_, Vec<index::Directory>>(&request);
|
let response = service.fetch_json::<_, Vec<index::Directory>>(&request);
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
@ -150,7 +148,7 @@ fn test_random_with_trailing_slash() {
|
||||||
#[test]
|
#[test]
|
||||||
fn test_recent_requires_auth() {
|
fn test_recent_requires_auth() {
|
||||||
let mut service = ServiceType::new(&test_name!());
|
let mut service = ServiceType::new(&test_name!());
|
||||||
let request = service.request_builder().recent();
|
let request = protocol::recent();
|
||||||
let response = service.fetch(&request);
|
let response = service.fetch(&request);
|
||||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
|
@ -163,7 +161,7 @@ fn test_recent_golden_path() {
|
||||||
service.index();
|
service.index();
|
||||||
service.login();
|
service.login();
|
||||||
|
|
||||||
let request = service.request_builder().recent();
|
let request = protocol::recent();
|
||||||
let response = service.fetch_json::<_, Vec<index::Directory>>(&request);
|
let response = service.fetch_json::<_, Vec<index::Directory>>(&request);
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
let entries = response.body();
|
let entries = response.body();
|
||||||
|
@ -178,7 +176,7 @@ fn test_recent_with_trailing_slash() {
|
||||||
service.index();
|
service.index();
|
||||||
service.login();
|
service.login();
|
||||||
|
|
||||||
let mut request = service.request_builder().recent();
|
let mut request = protocol::recent();
|
||||||
add_trailing_slash(&mut request);
|
add_trailing_slash(&mut request);
|
||||||
let response = service.fetch_json::<_, Vec<index::Directory>>(&request);
|
let response = service.fetch_json::<_, Vec<index::Directory>>(&request);
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
@ -189,7 +187,7 @@ fn test_recent_with_trailing_slash() {
|
||||||
#[test]
|
#[test]
|
||||||
fn test_search_requires_auth() {
|
fn test_search_requires_auth() {
|
||||||
let mut service = ServiceType::new(&test_name!());
|
let mut service = ServiceType::new(&test_name!());
|
||||||
let request = service.request_builder().search("");
|
let request = protocol::search("");
|
||||||
let response = service.fetch(&request);
|
let response = service.fetch(&request);
|
||||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
|
@ -200,7 +198,7 @@ fn test_search_without_query() {
|
||||||
service.complete_initial_setup();
|
service.complete_initial_setup();
|
||||||
service.login();
|
service.login();
|
||||||
|
|
||||||
let request = service.request_builder().search("");
|
let request = protocol::search("");
|
||||||
let response = service.fetch_json::<_, Vec<index::CollectionFile>>(&request);
|
let response = service.fetch_json::<_, Vec<index::CollectionFile>>(&request);
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
}
|
}
|
||||||
|
@ -213,7 +211,7 @@ fn test_search_with_query() {
|
||||||
service.index();
|
service.index();
|
||||||
service.login();
|
service.login();
|
||||||
|
|
||||||
let request = service.request_builder().search("door");
|
let request = protocol::search("door");
|
||||||
let response = service.fetch_json::<_, Vec<index::CollectionFile>>(&request);
|
let response = service.fetch_json::<_, Vec<index::CollectionFile>>(&request);
|
||||||
let results = response.body();
|
let results = response.body();
|
||||||
assert_eq!(results.len(), 1);
|
assert_eq!(results.len(), 1);
|
||||||
|
|
39
src/service/test/lastfm.rs
Normal file
39
src/service/test/lastfm.rs
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
use http::StatusCode;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use crate::service::test::{constants::*, protocol, ServiceType, TestService};
|
||||||
|
use crate::test_name;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_lastfm_scrobble_rejects_unlinked_user() {
|
||||||
|
let mut service = ServiceType::new(&test_name!());
|
||||||
|
service.complete_initial_setup();
|
||||||
|
service.login_admin();
|
||||||
|
service.index();
|
||||||
|
service.login();
|
||||||
|
|
||||||
|
let path: PathBuf = [TEST_MOUNT_NAME, "Khemmis", "Hunted", "02 - Candlelight.mp3"]
|
||||||
|
.iter()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let request = protocol::lastfm_scrobble(&path);
|
||||||
|
let response = service.fetch(&request);
|
||||||
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_lastfm_now_playing_rejects_unlinked_user() {
|
||||||
|
let mut service = ServiceType::new(&test_name!());
|
||||||
|
service.complete_initial_setup();
|
||||||
|
service.login_admin();
|
||||||
|
service.index();
|
||||||
|
service.login();
|
||||||
|
|
||||||
|
let path: PathBuf = [TEST_MOUNT_NAME, "Khemmis", "Hunted", "02 - Candlelight.mp3"]
|
||||||
|
.iter()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let request = protocol::lastfm_now_playing(&path);
|
||||||
|
let response = service.fetch(&request);
|
||||||
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
|
@ -1,7 +1,7 @@
|
||||||
use http::{header, HeaderValue, StatusCode};
|
use http::{header, HeaderValue, StatusCode};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use crate::service::test::{constants::*, ServiceType, TestService};
|
use crate::service::test::{constants::*, protocol, ServiceType, TestService};
|
||||||
use crate::test_name;
|
use crate::test_name;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
@ -12,7 +12,7 @@ fn test_audio_requires_auth() {
|
||||||
.iter()
|
.iter()
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let request = service.request_builder().audio(&path);
|
let request = protocol::audio(&path);
|
||||||
let response = service.fetch(&request);
|
let response = service.fetch(&request);
|
||||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
|
@ -29,7 +29,7 @@ fn test_audio_golden_path() {
|
||||||
.iter()
|
.iter()
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let request = service.request_builder().audio(&path);
|
let request = protocol::audio(&path);
|
||||||
let response = service.fetch_bytes(&request);
|
let response = service.fetch_bytes(&request);
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
assert_eq!(response.body().len(), 24_142);
|
assert_eq!(response.body().len(), 24_142);
|
||||||
|
@ -47,7 +47,7 @@ fn test_audio_partial_content() {
|
||||||
.iter()
|
.iter()
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let mut request = service.request_builder().audio(&path);
|
let mut request = protocol::audio(&path);
|
||||||
let headers = request.headers_mut();
|
let headers = request.headers_mut();
|
||||||
headers.append(
|
headers.append(
|
||||||
header::RANGE,
|
header::RANGE,
|
||||||
|
@ -71,7 +71,7 @@ fn test_audio_bad_path_returns_not_found() {
|
||||||
|
|
||||||
let path: PathBuf = ["not_my_collection"].iter().collect();
|
let path: PathBuf = ["not_my_collection"].iter().collect();
|
||||||
|
|
||||||
let request = service.request_builder().audio(&path);
|
let request = protocol::audio(&path);
|
||||||
let response = service.fetch(&request);
|
let response = service.fetch(&request);
|
||||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
@ -85,7 +85,7 @@ fn test_thumbnail_requires_auth() {
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let pad = None;
|
let pad = None;
|
||||||
let request = service.request_builder().thumbnail(&path, pad);
|
let request = protocol::thumbnail(&path, pad);
|
||||||
let response = service.fetch(&request);
|
let response = service.fetch(&request);
|
||||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
|
@ -103,7 +103,7 @@ fn test_thumbnail_golden_path() {
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let pad = None;
|
let pad = None;
|
||||||
let request = service.request_builder().thumbnail(&path, pad);
|
let request = protocol::thumbnail(&path, pad);
|
||||||
let response = service.fetch_bytes(&request);
|
let response = service.fetch_bytes(&request);
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
}
|
}
|
||||||
|
@ -117,7 +117,7 @@ fn test_thumbnail_bad_path_returns_not_found() {
|
||||||
let path: PathBuf = ["not_my_collection"].iter().collect();
|
let path: PathBuf = ["not_my_collection"].iter().collect();
|
||||||
|
|
||||||
let pad = None;
|
let pad = None;
|
||||||
let request = service.request_builder().thumbnail(&path, pad);
|
let request = protocol::thumbnail(&path, pad);
|
||||||
let response = service.fetch(&request);
|
let response = service.fetch(&request);
|
||||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,6 +10,7 @@ pub mod protocol;
|
||||||
mod admin;
|
mod admin;
|
||||||
mod auth;
|
mod auth;
|
||||||
mod collection;
|
mod collection;
|
||||||
|
mod lastfm;
|
||||||
mod media;
|
mod media;
|
||||||
mod playlist;
|
mod playlist;
|
||||||
mod preferences;
|
mod preferences;
|
||||||
|
@ -20,15 +21,16 @@ mod web;
|
||||||
use crate::app::{config, index, vfs};
|
use crate::app::{config, index, vfs};
|
||||||
use crate::service::test::constants::*;
|
use crate::service::test::constants::*;
|
||||||
|
|
||||||
#[cfg(feature = "service-rocket")]
|
pub use crate::service::actix::test::ServiceType;
|
||||||
pub use crate::service::rocket::test::ServiceType;
|
|
||||||
|
|
||||||
pub trait TestService {
|
pub trait TestService {
|
||||||
fn new(test_name: &str) -> Self;
|
fn new(test_name: &str) -> Self;
|
||||||
fn request_builder(&self) -> &protocol::RequestBuilder;
|
fn fetch<T: Serialize + Clone + 'static>(&mut self, request: &Request<T>) -> Response<()>;
|
||||||
fn fetch<T: Serialize>(&mut self, request: &Request<T>) -> Response<()>;
|
fn fetch_bytes<T: Serialize + Clone + 'static>(
|
||||||
fn fetch_bytes<T: Serialize>(&mut self, request: &Request<T>) -> Response<Vec<u8>>;
|
&mut self,
|
||||||
fn fetch_json<T: Serialize, U: DeserializeOwned>(
|
request: &Request<T>,
|
||||||
|
) -> Response<Vec<u8>>;
|
||||||
|
fn fetch_json<T: Serialize + Clone + 'static, U: DeserializeOwned>(
|
||||||
&mut self,
|
&mut self,
|
||||||
request: &Request<T>,
|
request: &Request<T>,
|
||||||
) -> Response<U>;
|
) -> Response<U>;
|
||||||
|
@ -55,32 +57,30 @@ pub trait TestService {
|
||||||
source: TEST_MOUNT_SOURCE.into(),
|
source: TEST_MOUNT_SOURCE.into(),
|
||||||
}]),
|
}]),
|
||||||
};
|
};
|
||||||
let request = self.request_builder().put_settings(configuration);
|
let request = protocol::put_settings(configuration);
|
||||||
let response = self.fetch(&request);
|
let response = self.fetch(&request);
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn login_admin(&mut self) {
|
fn login_admin(&mut self) {
|
||||||
let request = self
|
let request = protocol::login(TEST_USERNAME_ADMIN, TEST_PASSWORD_ADMIN);
|
||||||
.request_builder()
|
|
||||||
.login(TEST_USERNAME_ADMIN, TEST_PASSWORD_ADMIN);
|
|
||||||
let response = self.fetch(&request);
|
let response = self.fetch(&request);
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn login(&mut self) {
|
fn login(&mut self) {
|
||||||
let request = self.request_builder().login(TEST_USERNAME, TEST_PASSWORD);
|
let request = protocol::login(TEST_USERNAME, TEST_PASSWORD);
|
||||||
let response = self.fetch(&request);
|
let response = self.fetch(&request);
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn index(&mut self) {
|
fn index(&mut self) {
|
||||||
let request = self.request_builder().trigger_index();
|
let request = protocol::trigger_index();
|
||||||
let response = self.fetch(&request);
|
let response = self.fetch(&request);
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let browse_request = self.request_builder().browse(Path::new(""));
|
let browse_request = protocol::browse(Path::new(""));
|
||||||
let response = self.fetch_json::<(), Vec<index::CollectionFile>>(&browse_request);
|
let response = self.fetch_json::<(), Vec<index::CollectionFile>>(&browse_request);
|
||||||
let entries = response.body();
|
let entries = response.body();
|
||||||
if entries.len() > 0 {
|
if entries.len() > 0 {
|
||||||
|
@ -90,7 +90,7 @@ pub trait TestService {
|
||||||
}
|
}
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let flatten_request = self.request_builder().flatten(Path::new(""));
|
let flatten_request = protocol::flatten(Path::new(""));
|
||||||
let response = self.fetch_json::<_, Vec<index::Song>>(&flatten_request);
|
let response = self.fetch_json::<_, Vec<index::Song>>(&flatten_request);
|
||||||
let entries = response.body();
|
let entries = response.body();
|
||||||
if entries.len() > 0 {
|
if entries.len() > 0 {
|
||||||
|
|
|
@ -2,13 +2,13 @@ use http::StatusCode;
|
||||||
|
|
||||||
use crate::app::index;
|
use crate::app::index;
|
||||||
use crate::service::dto;
|
use crate::service::dto;
|
||||||
use crate::service::test::{constants::*, ServiceType, TestService};
|
use crate::service::test::{constants::*, protocol, ServiceType, TestService};
|
||||||
use crate::test_name;
|
use crate::test_name;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_list_playlists_requires_auth() {
|
fn test_list_playlists_requires_auth() {
|
||||||
let mut service = ServiceType::new(&test_name!());
|
let mut service = ServiceType::new(&test_name!());
|
||||||
let request = service.request_builder().playlists();
|
let request = protocol::playlists();
|
||||||
let response = service.fetch(&request);
|
let response = service.fetch(&request);
|
||||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
|
@ -18,7 +18,7 @@ fn test_list_playlists_golden_path() {
|
||||||
let mut service = ServiceType::new(&test_name!());
|
let mut service = ServiceType::new(&test_name!());
|
||||||
service.complete_initial_setup();
|
service.complete_initial_setup();
|
||||||
service.login();
|
service.login();
|
||||||
let request = service.request_builder().playlists();
|
let request = protocol::playlists();
|
||||||
let response = service.fetch_json::<_, Vec<dto::ListPlaylistsEntry>>(&request);
|
let response = service.fetch_json::<_, Vec<dto::ListPlaylistsEntry>>(&request);
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
}
|
}
|
||||||
|
@ -27,9 +27,7 @@ fn test_list_playlists_golden_path() {
|
||||||
fn test_save_playlist_requires_auth() {
|
fn test_save_playlist_requires_auth() {
|
||||||
let mut service = ServiceType::new(&test_name!());
|
let mut service = ServiceType::new(&test_name!());
|
||||||
let my_playlist = dto::SavePlaylistInput { tracks: Vec::new() };
|
let my_playlist = dto::SavePlaylistInput { tracks: Vec::new() };
|
||||||
let request = service
|
let request = protocol::save_playlist(TEST_PLAYLIST_NAME, my_playlist);
|
||||||
.request_builder()
|
|
||||||
.save_playlist(TEST_PLAYLIST_NAME, my_playlist);
|
|
||||||
let response = service.fetch(&request);
|
let response = service.fetch(&request);
|
||||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
|
@ -41,9 +39,22 @@ fn test_save_playlist_golden_path() {
|
||||||
service.login();
|
service.login();
|
||||||
|
|
||||||
let my_playlist = dto::SavePlaylistInput { tracks: Vec::new() };
|
let my_playlist = dto::SavePlaylistInput { tracks: Vec::new() };
|
||||||
let request = service
|
let request = protocol::save_playlist(TEST_PLAYLIST_NAME, my_playlist);
|
||||||
.request_builder()
|
let response = service.fetch(&request);
|
||||||
.save_playlist(TEST_PLAYLIST_NAME, my_playlist);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_save_playlist_large() {
|
||||||
|
let mut service = ServiceType::new(&test_name!());
|
||||||
|
service.complete_initial_setup();
|
||||||
|
service.login();
|
||||||
|
|
||||||
|
let tracks = (0..100_000)
|
||||||
|
.map(|_| "My Super Cool Song".to_string())
|
||||||
|
.collect();
|
||||||
|
let my_playlist = dto::SavePlaylistInput { tracks };
|
||||||
|
let request = protocol::save_playlist(TEST_PLAYLIST_NAME, my_playlist);
|
||||||
let response = service.fetch(&request);
|
let response = service.fetch(&request);
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
}
|
}
|
||||||
|
@ -51,7 +62,7 @@ fn test_save_playlist_golden_path() {
|
||||||
#[test]
|
#[test]
|
||||||
fn test_get_playlist_requires_auth() {
|
fn test_get_playlist_requires_auth() {
|
||||||
let mut service = ServiceType::new(&test_name!());
|
let mut service = ServiceType::new(&test_name!());
|
||||||
let request = service.request_builder().read_playlist(TEST_PLAYLIST_NAME);
|
let request = protocol::read_playlist(TEST_PLAYLIST_NAME);
|
||||||
let response = service.fetch(&request);
|
let response = service.fetch(&request);
|
||||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
|
@ -64,14 +75,12 @@ fn test_get_playlist_golden_path() {
|
||||||
|
|
||||||
{
|
{
|
||||||
let my_playlist = dto::SavePlaylistInput { tracks: Vec::new() };
|
let my_playlist = dto::SavePlaylistInput { tracks: Vec::new() };
|
||||||
let request = service
|
let request = protocol::save_playlist(TEST_PLAYLIST_NAME, my_playlist);
|
||||||
.request_builder()
|
|
||||||
.save_playlist(TEST_PLAYLIST_NAME, my_playlist);
|
|
||||||
let response = service.fetch(&request);
|
let response = service.fetch(&request);
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
}
|
}
|
||||||
|
|
||||||
let request = service.request_builder().read_playlist(TEST_PLAYLIST_NAME);
|
let request = protocol::read_playlist(TEST_PLAYLIST_NAME);
|
||||||
let response = service.fetch_json::<_, Vec<index::Song>>(&request);
|
let response = service.fetch_json::<_, Vec<index::Song>>(&request);
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
}
|
}
|
||||||
|
@ -82,7 +91,7 @@ fn test_get_playlist_bad_name_returns_not_found() {
|
||||||
service.complete_initial_setup();
|
service.complete_initial_setup();
|
||||||
service.login();
|
service.login();
|
||||||
|
|
||||||
let request = service.request_builder().read_playlist(TEST_PLAYLIST_NAME);
|
let request = protocol::read_playlist(TEST_PLAYLIST_NAME);
|
||||||
let response = service.fetch(&request);
|
let response = service.fetch(&request);
|
||||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
@ -90,9 +99,7 @@ fn test_get_playlist_bad_name_returns_not_found() {
|
||||||
#[test]
|
#[test]
|
||||||
fn test_delete_playlist_requires_auth() {
|
fn test_delete_playlist_requires_auth() {
|
||||||
let mut service = ServiceType::new(&test_name!());
|
let mut service = ServiceType::new(&test_name!());
|
||||||
let request = service
|
let request = protocol::delete_playlist(TEST_PLAYLIST_NAME);
|
||||||
.request_builder()
|
|
||||||
.delete_playlist(TEST_PLAYLIST_NAME);
|
|
||||||
let response = service.fetch(&request);
|
let response = service.fetch(&request);
|
||||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
|
@ -105,16 +112,12 @@ fn test_delete_playlist_golden_path() {
|
||||||
|
|
||||||
{
|
{
|
||||||
let my_playlist = dto::SavePlaylistInput { tracks: Vec::new() };
|
let my_playlist = dto::SavePlaylistInput { tracks: Vec::new() };
|
||||||
let request = service
|
let request = protocol::save_playlist(TEST_PLAYLIST_NAME, my_playlist);
|
||||||
.request_builder()
|
|
||||||
.save_playlist(TEST_PLAYLIST_NAME, my_playlist);
|
|
||||||
let response = service.fetch(&request);
|
let response = service.fetch(&request);
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
}
|
}
|
||||||
|
|
||||||
let request = service
|
let request = protocol::delete_playlist(TEST_PLAYLIST_NAME);
|
||||||
.request_builder()
|
|
||||||
.delete_playlist(TEST_PLAYLIST_NAME);
|
|
||||||
let response = service.fetch(&request);
|
let response = service.fetch(&request);
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
}
|
}
|
||||||
|
@ -125,9 +128,7 @@ fn test_delete_playlist_bad_name_returns_not_found() {
|
||||||
service.complete_initial_setup();
|
service.complete_initial_setup();
|
||||||
service.login();
|
service.login();
|
||||||
|
|
||||||
let request = service
|
let request = protocol::delete_playlist(TEST_PLAYLIST_NAME);
|
||||||
.request_builder()
|
|
||||||
.delete_playlist(TEST_PLAYLIST_NAME);
|
|
||||||
let response = service.fetch(&request);
|
let response = service.fetch(&request);
|
||||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,13 +1,13 @@
|
||||||
use http::StatusCode;
|
use http::StatusCode;
|
||||||
|
|
||||||
use crate::app::user;
|
use crate::app::user;
|
||||||
use crate::service::test::{ServiceType, TestService};
|
use crate::service::test::{protocol, ServiceType, TestService};
|
||||||
use crate::test_name;
|
use crate::test_name;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_get_preferences_requires_auth() {
|
fn test_get_preferences_requires_auth() {
|
||||||
let mut service = ServiceType::new(&test_name!());
|
let mut service = ServiceType::new(&test_name!());
|
||||||
let request = service.request_builder().get_preferences();
|
let request = protocol::get_preferences();
|
||||||
let response = service.fetch(&request);
|
let response = service.fetch(&request);
|
||||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
|
@ -18,7 +18,7 @@ fn test_get_preferences_golden_path() {
|
||||||
service.complete_initial_setup();
|
service.complete_initial_setup();
|
||||||
service.login();
|
service.login();
|
||||||
|
|
||||||
let request = service.request_builder().get_preferences();
|
let request = protocol::get_preferences();
|
||||||
let response = service.fetch_json::<_, user::Preferences>(&request);
|
let response = service.fetch_json::<_, user::Preferences>(&request);
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
}
|
}
|
||||||
|
@ -26,9 +26,7 @@ fn test_get_preferences_golden_path() {
|
||||||
#[test]
|
#[test]
|
||||||
fn test_put_preferences_requires_auth() {
|
fn test_put_preferences_requires_auth() {
|
||||||
let mut service = ServiceType::new(&test_name!());
|
let mut service = ServiceType::new(&test_name!());
|
||||||
let request = service
|
let request = protocol::put_preferences(user::Preferences::default());
|
||||||
.request_builder()
|
|
||||||
.put_preferences(user::Preferences::default());
|
|
||||||
let response = service.fetch(&request);
|
let response = service.fetch(&request);
|
||||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
|
@ -39,9 +37,7 @@ fn test_put_preferences_golden_path() {
|
||||||
service.complete_initial_setup();
|
service.complete_initial_setup();
|
||||||
service.login();
|
service.login();
|
||||||
|
|
||||||
let request = service
|
let request = protocol::put_preferences(user::Preferences::default());
|
||||||
.request_builder()
|
|
||||||
.put_preferences(user::Preferences::default());
|
|
||||||
let response = service.fetch(&request);
|
let response = service.fetch(&request);
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,205 +5,216 @@ use std::path::Path;
|
||||||
use crate::app::{config, user};
|
use crate::app::{config, user};
|
||||||
use crate::service::dto;
|
use crate::service::dto;
|
||||||
|
|
||||||
pub struct RequestBuilder {}
|
pub fn web_index() -> Request<()> {
|
||||||
|
Request::builder()
|
||||||
|
.method(Method::GET)
|
||||||
|
.uri("/")
|
||||||
|
.body(())
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
impl RequestBuilder {
|
pub fn swagger_index() -> Request<()> {
|
||||||
pub fn new() -> Self {
|
Request::builder()
|
||||||
Self {}
|
.method(Method::GET)
|
||||||
}
|
.uri("/swagger")
|
||||||
|
.body(())
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn web_index(&self) -> Request<()> {
|
pub fn version() -> Request<()> {
|
||||||
Request::builder()
|
Request::builder()
|
||||||
.method(Method::GET)
|
.method(Method::GET)
|
||||||
.uri("/")
|
.uri("/api/version")
|
||||||
.body(())
|
.body(())
|
||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn swagger_index(&self) -> Request<()> {
|
pub fn initial_setup() -> Request<()> {
|
||||||
Request::builder()
|
Request::builder()
|
||||||
.method(Method::GET)
|
.method(Method::GET)
|
||||||
.uri("/swagger")
|
.uri("/api/initial_setup")
|
||||||
.body(())
|
.body(())
|
||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn version(&self) -> Request<()> {
|
pub fn login(username: &str, password: &str) -> Request<dto::AuthCredentials> {
|
||||||
Request::builder()
|
let credentials = dto::AuthCredentials {
|
||||||
.method(Method::GET)
|
username: username.into(),
|
||||||
.uri("/api/version")
|
password: password.into(),
|
||||||
.body(())
|
};
|
||||||
.unwrap()
|
Request::builder()
|
||||||
}
|
.method(Method::POST)
|
||||||
|
.uri("/api/auth")
|
||||||
|
.body(credentials)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn initial_setup(&self) -> Request<()> {
|
pub fn get_settings() -> Request<()> {
|
||||||
Request::builder()
|
Request::builder()
|
||||||
.method(Method::GET)
|
.method(Method::GET)
|
||||||
.uri("/api/initial_setup")
|
.uri("/api/settings")
|
||||||
.body(())
|
.body(())
|
||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn login(&self, username: &str, password: &str) -> Request<dto::AuthCredentials> {
|
pub fn put_settings(configuration: config::Config) -> Request<config::Config> {
|
||||||
let credentials = dto::AuthCredentials {
|
Request::builder()
|
||||||
username: username.into(),
|
.method(Method::PUT)
|
||||||
password: password.into(),
|
.uri("/api/settings")
|
||||||
};
|
.body(configuration)
|
||||||
Request::builder()
|
.unwrap()
|
||||||
.method(Method::POST)
|
}
|
||||||
.uri("/api/auth")
|
|
||||||
.body(credentials)
|
|
||||||
.unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_settings(&self) -> Request<()> {
|
pub fn get_preferences() -> Request<()> {
|
||||||
Request::builder()
|
Request::builder()
|
||||||
.method(Method::GET)
|
.method(Method::GET)
|
||||||
.uri("/api/settings")
|
.uri("/api/preferences")
|
||||||
.body(())
|
.body(())
|
||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn put_settings(&self, configuration: config::Config) -> Request<config::Config> {
|
pub fn put_preferences(preferences: user::Preferences) -> Request<user::Preferences> {
|
||||||
Request::builder()
|
Request::builder()
|
||||||
.method(Method::PUT)
|
.method(Method::PUT)
|
||||||
.uri("/api/settings")
|
.uri("/api/preferences")
|
||||||
.body(configuration)
|
.body(preferences)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_preferences(&self) -> Request<()> {
|
pub fn trigger_index() -> Request<()> {
|
||||||
Request::builder()
|
Request::builder()
|
||||||
.method(Method::GET)
|
.method(Method::POST)
|
||||||
.uri("/api/preferences")
|
.uri("/api/trigger_index")
|
||||||
.body(())
|
.body(())
|
||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn put_preferences(&self, preferences: user::Preferences) -> Request<user::Preferences> {
|
pub fn browse(path: &Path) -> Request<()> {
|
||||||
Request::builder()
|
let path = path.to_string_lossy();
|
||||||
.method(Method::PUT)
|
let endpoint = format!("/api/browse/{}", url_encode(path.as_ref()));
|
||||||
.uri("/api/preferences")
|
Request::builder()
|
||||||
.body(preferences)
|
.method(Method::GET)
|
||||||
.unwrap()
|
.uri(&endpoint)
|
||||||
}
|
.body(())
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn trigger_index(&self) -> Request<()> {
|
pub fn flatten(path: &Path) -> Request<()> {
|
||||||
Request::builder()
|
let path = path.to_string_lossy();
|
||||||
.method(Method::POST)
|
let endpoint = format!("/api/flatten/{}", url_encode(path.as_ref()));
|
||||||
.uri("/api/trigger_index")
|
Request::builder()
|
||||||
.body(())
|
.method(Method::GET)
|
||||||
.unwrap()
|
.uri(&endpoint)
|
||||||
}
|
.body(())
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn browse(&self, path: &Path) -> Request<()> {
|
pub fn random() -> Request<()> {
|
||||||
let path = path.to_string_lossy();
|
Request::builder()
|
||||||
let uri = format!("/api/browse/{}", url_encode(path.as_ref()));
|
.method(Method::GET)
|
||||||
Request::builder()
|
.uri("/api/random")
|
||||||
.method(Method::GET)
|
.body(())
|
||||||
.uri(uri)
|
.unwrap()
|
||||||
.body(())
|
}
|
||||||
.unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn flatten(&self, path: &Path) -> Request<()> {
|
pub fn recent() -> Request<()> {
|
||||||
let path = path.to_string_lossy();
|
Request::builder()
|
||||||
let uri = format!("/api/flatten/{}", url_encode(path.as_ref()));
|
.method(Method::GET)
|
||||||
Request::builder()
|
.uri("/api/recent")
|
||||||
.method(Method::GET)
|
.body(())
|
||||||
.uri(uri)
|
.unwrap()
|
||||||
.body(())
|
}
|
||||||
.unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn random(&self) -> Request<()> {
|
pub fn search(query: &str) -> Request<()> {
|
||||||
Request::builder()
|
let endpoint = format!("/api/search/{}", url_encode(query));
|
||||||
.method(Method::GET)
|
Request::builder()
|
||||||
.uri("/api/random")
|
.method(Method::GET)
|
||||||
.body(())
|
.uri(&endpoint)
|
||||||
.unwrap()
|
.body(())
|
||||||
}
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn recent(&self) -> Request<()> {
|
pub fn audio(path: &Path) -> Request<()> {
|
||||||
Request::builder()
|
let path = path.to_string_lossy();
|
||||||
.method(Method::GET)
|
let endpoint = format!("/api/audio/{}", url_encode(path.as_ref()));
|
||||||
.uri("/api/recent")
|
Request::builder()
|
||||||
.body(())
|
.method(Method::GET)
|
||||||
.unwrap()
|
.uri(&endpoint)
|
||||||
}
|
.body(())
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn search(&self, query: &str) -> Request<()> {
|
pub fn thumbnail(path: &Path, pad: Option<bool>) -> Request<()> {
|
||||||
let uri = format!("/api/search/{}", url_encode(query));
|
let path = path.to_string_lossy();
|
||||||
Request::builder()
|
let mut endpoint = format!("/api/thumbnail/{}", url_encode(path.as_ref()));
|
||||||
.method(Method::GET)
|
match pad {
|
||||||
.uri(uri)
|
Some(true) => endpoint.push_str("?pad=true"),
|
||||||
.body(())
|
Some(false) => endpoint.push_str("?pad=false"),
|
||||||
.unwrap()
|
None => (),
|
||||||
}
|
};
|
||||||
|
Request::builder()
|
||||||
|
.method(Method::GET)
|
||||||
|
.uri(&endpoint)
|
||||||
|
.body(())
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn audio(&self, path: &Path) -> Request<()> {
|
pub fn playlists() -> Request<()> {
|
||||||
let path = path.to_string_lossy();
|
Request::builder()
|
||||||
let uri = format!("/api/audio/{}", url_encode(path.as_ref()));
|
.method(Method::GET)
|
||||||
Request::builder()
|
.uri("/api/playlists")
|
||||||
.method(Method::GET)
|
.body(())
|
||||||
.uri(uri)
|
.unwrap()
|
||||||
.body(())
|
}
|
||||||
.unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn thumbnail(&self, path: &Path, pad: Option<bool>) -> Request<()> {
|
pub fn save_playlist(
|
||||||
let path = path.to_string_lossy();
|
name: &str,
|
||||||
let mut uri = format!("/api/thumbnail/{}", url_encode(path.as_ref()));
|
playlist: dto::SavePlaylistInput,
|
||||||
match pad {
|
) -> Request<dto::SavePlaylistInput> {
|
||||||
Some(true) => uri.push_str("?pad=true"),
|
let endpoint = format!("/api/playlist/{}", url_encode(name));
|
||||||
Some(false) => uri.push_str("?pad=false"),
|
Request::builder()
|
||||||
None => (),
|
.method(Method::PUT)
|
||||||
};
|
.uri(&endpoint)
|
||||||
Request::builder()
|
.body(playlist)
|
||||||
.method(Method::GET)
|
.unwrap()
|
||||||
.uri(uri)
|
}
|
||||||
.body(())
|
|
||||||
.unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn playlists(&self) -> Request<()> {
|
pub fn read_playlist(name: &str) -> Request<()> {
|
||||||
Request::builder()
|
let endpoint = format!("/api/playlist/{}", url_encode(name));
|
||||||
.method(Method::GET)
|
Request::builder()
|
||||||
.uri("/api/playlists")
|
.method(Method::GET)
|
||||||
.body(())
|
.uri(&endpoint)
|
||||||
.unwrap()
|
.body(())
|
||||||
}
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn save_playlist(
|
pub fn delete_playlist(name: &str) -> Request<()> {
|
||||||
&self,
|
let endpoint = format!("/api/playlist/{}", url_encode(name));
|
||||||
name: &str,
|
Request::builder()
|
||||||
playlist: dto::SavePlaylistInput,
|
.method(Method::DELETE)
|
||||||
) -> Request<dto::SavePlaylistInput> {
|
.uri(&endpoint)
|
||||||
let uri = format!("/api/playlist/{}", url_encode(name));
|
.body(())
|
||||||
Request::builder()
|
.unwrap()
|
||||||
.method(Method::PUT)
|
}
|
||||||
.uri(uri)
|
|
||||||
.body(playlist)
|
|
||||||
.unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn read_playlist(&self, name: &str) -> Request<()> {
|
pub fn lastfm_now_playing(path: &Path) -> Request<()> {
|
||||||
let uri = format!("/api/playlist/{}", url_encode(name));
|
let path = path.to_string_lossy();
|
||||||
Request::builder()
|
let endpoint = format!("/api/lastfm/now_playing/{}", url_encode(path.as_ref()));
|
||||||
.method(Method::GET)
|
Request::builder()
|
||||||
.uri(uri)
|
.method(Method::PUT)
|
||||||
.body(())
|
.uri(&endpoint)
|
||||||
.unwrap()
|
.body(())
|
||||||
}
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn delete_playlist(&self, name: &str) -> Request<()> {
|
pub fn lastfm_scrobble(path: &Path) -> Request<()> {
|
||||||
let uri = format!("/api/playlist/{}", url_encode(name));
|
let path = path.to_string_lossy();
|
||||||
Request::builder()
|
let endpoint = format!("/api/lastfm/scrobble/{}", url_encode(path.as_ref()));
|
||||||
.method(Method::DELETE)
|
Request::builder()
|
||||||
.uri(uri)
|
.method(Method::POST)
|
||||||
.body(())
|
.uri(&endpoint)
|
||||||
.unwrap()
|
.body(())
|
||||||
}
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn url_encode(input: &str) -> String {
|
fn url_encode(input: &str) -> String {
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
use http::StatusCode;
|
use http::StatusCode;
|
||||||
|
|
||||||
use crate::app::config;
|
use crate::app::config;
|
||||||
use crate::service::test::{constants::*, ServiceType, TestService};
|
use crate::service::test::{constants::*, protocol, ServiceType, TestService};
|
||||||
use crate::test_name;
|
use crate::test_name;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
@ -9,7 +9,7 @@ fn test_get_settings_requires_auth() {
|
||||||
let mut service = ServiceType::new(&test_name!());
|
let mut service = ServiceType::new(&test_name!());
|
||||||
service.complete_initial_setup();
|
service.complete_initial_setup();
|
||||||
|
|
||||||
let request = service.request_builder().get_settings();
|
let request = protocol::get_settings();
|
||||||
let response = service.fetch(&request);
|
let response = service.fetch(&request);
|
||||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
|
@ -20,7 +20,7 @@ fn test_get_settings_requires_admin() {
|
||||||
service.complete_initial_setup();
|
service.complete_initial_setup();
|
||||||
service.login();
|
service.login();
|
||||||
|
|
||||||
let request = service.request_builder().get_settings();
|
let request = protocol::get_settings();
|
||||||
let response = service.fetch(&request);
|
let response = service.fetch(&request);
|
||||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||||
}
|
}
|
||||||
|
@ -31,7 +31,7 @@ fn test_get_settings_golden_path() {
|
||||||
service.complete_initial_setup();
|
service.complete_initial_setup();
|
||||||
service.login_admin();
|
service.login_admin();
|
||||||
|
|
||||||
let request = service.request_builder().get_settings();
|
let request = protocol::get_settings();
|
||||||
let response = service.fetch_json::<_, config::Config>(&request);
|
let response = service.fetch_json::<_, config::Config>(&request);
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
}
|
}
|
||||||
|
@ -40,9 +40,7 @@ fn test_get_settings_golden_path() {
|
||||||
fn test_put_settings_requires_auth() {
|
fn test_put_settings_requires_auth() {
|
||||||
let mut service = ServiceType::new(&test_name!());
|
let mut service = ServiceType::new(&test_name!());
|
||||||
service.complete_initial_setup();
|
service.complete_initial_setup();
|
||||||
let request = service
|
let request = protocol::put_settings(config::Config::default());
|
||||||
.request_builder()
|
|
||||||
.put_settings(config::Config::default());
|
|
||||||
let response = service.fetch(&request);
|
let response = service.fetch(&request);
|
||||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
|
@ -52,9 +50,7 @@ fn test_put_settings_requires_admin() {
|
||||||
let mut service = ServiceType::new(&test_name!());
|
let mut service = ServiceType::new(&test_name!());
|
||||||
service.complete_initial_setup();
|
service.complete_initial_setup();
|
||||||
service.login();
|
service.login();
|
||||||
let request = service
|
let request = protocol::put_settings(config::Config::default());
|
||||||
.request_builder()
|
|
||||||
.put_settings(config::Config::default());
|
|
||||||
let response = service.fetch(&request);
|
let response = service.fetch(&request);
|
||||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||||
}
|
}
|
||||||
|
@ -65,9 +61,7 @@ fn test_put_settings_golden_path() {
|
||||||
service.complete_initial_setup();
|
service.complete_initial_setup();
|
||||||
service.login_admin();
|
service.login_admin();
|
||||||
|
|
||||||
let request = service
|
let request = protocol::put_settings(config::Config::default());
|
||||||
.request_builder()
|
|
||||||
.put_settings(config::Config::default());
|
|
||||||
let response = service.fetch(&request);
|
let response = service.fetch(&request);
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
}
|
}
|
||||||
|
@ -84,7 +78,7 @@ fn test_put_settings_cannot_unadmin_self() {
|
||||||
password: "".into(),
|
password: "".into(),
|
||||||
admin: false,
|
admin: false,
|
||||||
}]);
|
}]);
|
||||||
let request = service.request_builder().put_settings(configuration);
|
let request = protocol::put_settings(configuration);
|
||||||
let response = service.fetch(&request);
|
let response = service.fetch(&request);
|
||||||
assert_eq!(response.status(), StatusCode::CONFLICT);
|
assert_eq!(response.status(), StatusCode::CONFLICT);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
use http::StatusCode;
|
use http::StatusCode;
|
||||||
|
|
||||||
use crate::service::test::{add_trailing_slash, ServiceType, TestService};
|
use crate::service::test::{add_trailing_slash, protocol, ServiceType, TestService};
|
||||||
use crate::test_name;
|
use crate::test_name;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_swagger_can_get_index() {
|
fn test_swagger_can_get_index() {
|
||||||
let mut service = ServiceType::new(&test_name!());
|
let mut service = ServiceType::new(&test_name!());
|
||||||
let request = service.request_builder().swagger_index();
|
let request = protocol::swagger_index();
|
||||||
let response = service.fetch(&request);
|
let response = service.fetch(&request);
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
assert!(status == StatusCode::OK || status == StatusCode::PERMANENT_REDIRECT);
|
assert!(status == StatusCode::OK || status == StatusCode::PERMANENT_REDIRECT);
|
||||||
|
@ -15,7 +15,7 @@ fn test_swagger_can_get_index() {
|
||||||
#[test]
|
#[test]
|
||||||
fn test_swagger_can_get_index_with_trailing_slash() {
|
fn test_swagger_can_get_index_with_trailing_slash() {
|
||||||
let mut service = ServiceType::new(&test_name!());
|
let mut service = ServiceType::new(&test_name!());
|
||||||
let mut request = service.request_builder().swagger_index();
|
let mut request = protocol::swagger_index();
|
||||||
add_trailing_slash(&mut request);
|
add_trailing_slash(&mut request);
|
||||||
let response = service.fetch(&request);
|
let response = service.fetch(&request);
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
use http::StatusCode;
|
use http::StatusCode;
|
||||||
|
|
||||||
use crate::service::test::{ServiceType, TestService};
|
use crate::service::test::{protocol, ServiceType, TestService};
|
||||||
use crate::test_name;
|
use crate::test_name;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_serves_web_client() {
|
fn test_serves_web_client() {
|
||||||
let mut service = ServiceType::new(&test_name!());
|
let mut service = ServiceType::new(&test_name!());
|
||||||
let request = service.request_builder().web_index();
|
let request = protocol::web_index();
|
||||||
let response = service.fetch_bytes(&request);
|
let response = service.fetch_bytes(&request);
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue