Range requests
This commit is contained in:
parent
153943a3ae
commit
0e63f64513
4 changed files with 90 additions and 673 deletions
55
Cargo.lock
generated
55
Cargo.lock
generated
|
@ -147,6 +147,44 @@ dependencies = [
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "axum-extra"
|
||||||
|
version = "0.9.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0be6ea09c9b96cb5076af0de2e383bd2bc0c18f827cf1967bdd353e0b910d733"
|
||||||
|
dependencies = [
|
||||||
|
"axum",
|
||||||
|
"axum-core",
|
||||||
|
"bytes",
|
||||||
|
"futures-util",
|
||||||
|
"headers",
|
||||||
|
"http 1.1.0",
|
||||||
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"mime",
|
||||||
|
"pin-project-lite",
|
||||||
|
"serde",
|
||||||
|
"tower",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "axum-range"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b1c30398a7f716ebdd7f3c8a4f7a7a6df48a30e002007fd57b2a7a00fac864bd"
|
||||||
|
dependencies = [
|
||||||
|
"axum",
|
||||||
|
"axum-extra",
|
||||||
|
"bytes",
|
||||||
|
"futures",
|
||||||
|
"http-body",
|
||||||
|
"pin-project",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "axum-test"
|
name = "axum-test"
|
||||||
version = "15.3.0"
|
version = "15.3.0"
|
||||||
|
@ -621,6 +659,20 @@ version = "1.3.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures"
|
||||||
|
version = "0.3.28"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "23342abe12aba583913b2e62f22225ff9c950774065e4bfb61a19cd9770fec40"
|
||||||
|
dependencies = [
|
||||||
|
"futures-channel",
|
||||||
|
"futures-core",
|
||||||
|
"futures-io",
|
||||||
|
"futures-sink",
|
||||||
|
"futures-task",
|
||||||
|
"futures-util",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-channel"
|
name = "futures-channel"
|
||||||
version = "0.3.30"
|
version = "0.3.30"
|
||||||
|
@ -694,6 +746,7 @@ version = "0.3.28"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "26b01e40b772d54cf6c6d721c1d1abd0647a0106a12ecaa1c186273392a69533"
|
checksum = "26b01e40b772d54cf6c6d721c1d1abd0647a0106a12ecaa1c186273392a69533"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-io",
|
"futures-io",
|
||||||
"futures-macro",
|
"futures-macro",
|
||||||
|
@ -1511,6 +1564,8 @@ version = "0.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ape",
|
"ape",
|
||||||
"axum",
|
"axum",
|
||||||
|
"axum-extra",
|
||||||
|
"axum-range",
|
||||||
"axum-test",
|
"axum-test",
|
||||||
"base64 0.21.3",
|
"base64 0.21.3",
|
||||||
"branca",
|
"branca",
|
||||||
|
|
|
@ -10,6 +10,8 @@ ui = ["native-windows-gui", "native-windows-derive"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
ape = "0.5"
|
ape = "0.5"
|
||||||
|
axum-extra = { version = "0.9.3", features = ["typed-header"] }
|
||||||
|
axum-range = "0.4.0"
|
||||||
base64 = "0.21"
|
base64 = "0.21"
|
||||||
branca = "0.10.1"
|
branca = "0.10.1"
|
||||||
crossbeam-channel = "0.5"
|
crossbeam-channel = "0.5"
|
||||||
|
|
|
@ -1,670 +0,0 @@
|
||||||
use actix_files::NamedFile;
|
|
||||||
use actix_web::body::BoxBody;
|
|
||||||
use actix_web::http::header::ContentEncoding;
|
|
||||||
use actix_web::{
|
|
||||||
delete,
|
|
||||||
dev::Payload,
|
|
||||||
error::{ErrorForbidden, ErrorInternalServerError, ErrorUnauthorized},
|
|
||||||
get,
|
|
||||||
http::StatusCode,
|
|
||||||
post, put,
|
|
||||||
web::{self, Data, Json, JsonConfig, ServiceConfig},
|
|
||||||
FromRequest, HttpRequest, HttpResponse, Responder, ResponseError,
|
|
||||||
};
|
|
||||||
use actix_web_httpauth::extractors::bearer::BearerAuth;
|
|
||||||
use base64::prelude::*;
|
|
||||||
use futures_util::future::err;
|
|
||||||
use percent_encoding::percent_decode_str;
|
|
||||||
use std::future::Future;
|
|
||||||
use std::path::Path;
|
|
||||||
use std::pin::Pin;
|
|
||||||
use std::str;
|
|
||||||
|
|
||||||
use crate::app::{
|
|
||||||
config, ddns,
|
|
||||||
index::{self, Index},
|
|
||||||
lastfm, playlist, settings, thumbnail, user,
|
|
||||||
vfs::{self, MountDir},
|
|
||||||
};
|
|
||||||
use crate::server::{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(apply_config)
|
|
||||||
.service(get_settings)
|
|
||||||
.service(put_settings)
|
|
||||||
.service(list_mount_dirs)
|
|
||||||
.service(put_mount_dirs)
|
|
||||||
.service(get_ddns_config)
|
|
||||||
.service(put_ddns_config)
|
|
||||||
.service(list_users)
|
|
||||||
.service(create_user)
|
|
||||||
.service(update_user)
|
|
||||||
.service(delete_user)
|
|
||||||
.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_token)
|
|
||||||
.service(lastfm_link)
|
|
||||||
.service(lastfm_unlink);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ResponseError for APIError {
|
|
||||||
fn status_code(&self) -> StatusCode {
|
|
||||||
match self {
|
|
||||||
APIError::AuthorizationTokenEncoding => StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
APIError::AdminPermissionRequired => StatusCode::UNAUTHORIZED,
|
|
||||||
APIError::AudioFileIOError => StatusCode::NOT_FOUND,
|
|
||||||
APIError::AuthenticationRequired => StatusCode::UNAUTHORIZED,
|
|
||||||
APIError::BrancaTokenEncoding => StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
APIError::DdnsUpdateQueryFailed(s) => {
|
|
||||||
StatusCode::from_u16(*s).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR)
|
|
||||||
}
|
|
||||||
APIError::Database(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
APIError::DeletingOwnAccount => StatusCode::CONFLICT,
|
|
||||||
APIError::EmbeddedArtworkNotFound => StatusCode::NOT_FOUND,
|
|
||||||
APIError::EmptyPassword => StatusCode::BAD_REQUEST,
|
|
||||||
APIError::EmptyUsername => StatusCode::BAD_REQUEST,
|
|
||||||
APIError::IncorrectCredentials => StatusCode::UNAUTHORIZED,
|
|
||||||
APIError::Internal => StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
APIError::Io(_, _) => StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
APIError::LastFMAccountNotLinked => StatusCode::NO_CONTENT,
|
|
||||||
APIError::LastFMLinkContentBase64DecodeError => StatusCode::BAD_REQUEST,
|
|
||||||
APIError::LastFMLinkContentEncodingError => StatusCode::BAD_REQUEST,
|
|
||||||
APIError::LastFMNowPlaying(_) => StatusCode::FAILED_DEPENDENCY,
|
|
||||||
APIError::LastFMScrobble(_) => StatusCode::FAILED_DEPENDENCY,
|
|
||||||
APIError::LastFMScrobblerAuthentication(_) => StatusCode::FAILED_DEPENDENCY,
|
|
||||||
APIError::OwnAdminPrivilegeRemoval => StatusCode::CONFLICT,
|
|
||||||
APIError::PasswordHashing => StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
APIError::PlaylistNotFound => StatusCode::NOT_FOUND,
|
|
||||||
APIError::Settings(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
APIError::SongMetadataNotFound => StatusCode::NOT_FOUND,
|
|
||||||
APIError::ThumbnailFlacDecoding(_, _) => StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
APIError::ThumbnailFileIOError => StatusCode::NOT_FOUND,
|
|
||||||
APIError::ThumbnailId3Decoding(_, _) => StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
APIError::ThumbnailImageDecoding(_, _) => StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
APIError::ThumbnailMp4Decoding(_, _) => StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
APIError::TomlDeserialization(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
APIError::UnsupportedThumbnailFormat(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
APIError::UserNotFound => StatusCode::NOT_FOUND,
|
|
||||||
APIError::VFSPathNotFound => StatusCode::NOT_FOUND,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn error_response(&self) -> HttpResponse<BoxBody> {
|
|
||||||
HttpResponse::new(self.status_code())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct Auth {
|
|
||||||
username: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FromRequest for Auth {
|
|
||||||
type Error = actix_web::Error;
|
|
||||||
type Future = Pin<Box<dyn Future<Output = Result<Self, Self::Error>>>>;
|
|
||||||
|
|
||||||
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::Internal))),
|
|
||||||
};
|
|
||||||
|
|
||||||
let bearer_auth_future = BearerAuth::from_request(request, payload);
|
|
||||||
let query_params_future =
|
|
||||||
web::Query::<dto::AuthQueryParameters>::from_request(request, payload);
|
|
||||||
|
|
||||||
Box::pin(async move {
|
|
||||||
// Auth via bearer token in query parameter
|
|
||||||
if let Ok(query) = query_params_future.await {
|
|
||||||
let auth_token = user::AuthToken(query.auth_token.clone());
|
|
||||||
if let Ok(auth) = user_manager
|
|
||||||
.authenticate(&auth_token, user::AuthorizationScope::PolarisAuth)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
return Ok(Auth {
|
|
||||||
username: auth.username,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auth via bearer token in authorization header
|
|
||||||
if let Ok(bearer_auth) = bearer_auth_future.await {
|
|
||||||
let auth_token = user::AuthToken(bearer_auth.token().to_owned());
|
|
||||||
if let Ok(auth) = user_manager
|
|
||||||
.authenticate(&auth_token, user::AuthorizationScope::PolarisAuth)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
return Ok(Auth {
|
|
||||||
username: auth.username,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Err(ErrorUnauthorized(APIError::AuthenticationRequired))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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>>>>;
|
|
||||||
|
|
||||||
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::Internal))),
|
|
||||||
};
|
|
||||||
|
|
||||||
let auth_future = Auth::from_request(request, payload);
|
|
||||||
|
|
||||||
Box::pin(async move {
|
|
||||||
let user_count = user_manager.count().await;
|
|
||||||
match user_count {
|
|
||||||
Err(_) => return Err(ErrorInternalServerError(APIError::Internal)),
|
|
||||||
Ok(0) => return Ok(AdminRights { auth: None }),
|
|
||||||
_ => (),
|
|
||||||
};
|
|
||||||
|
|
||||||
let auth = auth_future.await?;
|
|
||||||
match user_manager.is_admin(&auth.username).await {
|
|
||||||
Ok(true) => Ok(AdminRights { auth: Some(auth) }),
|
|
||||||
Ok(false) => Err(ErrorForbidden(APIError::AdminPermissionRequired)),
|
|
||||||
Err(_) => Err(ErrorInternalServerError(APIError::Internal)),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct MediaFile {
|
|
||||||
named_file: NamedFile,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MediaFile {
|
|
||||||
fn new(named_file: NamedFile) -> Self {
|
|
||||||
Self { named_file }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Responder for MediaFile {
|
|
||||||
type Body = BoxBody;
|
|
||||||
|
|
||||||
fn respond_to(self, req: &HttpRequest) -> HttpResponse<Self::Body> {
|
|
||||||
// Intentionally turn off content encoding for media files because:
|
|
||||||
// 1. There is little value in compressing files that are already compressed (mp3, jpg, etc.)
|
|
||||||
// 2. The Content-Length header is incompatible with content encoding (other than identity), and can be valuable for clients
|
|
||||||
self.named_file
|
|
||||||
.set_content_encoding(ContentEncoding::Identity)
|
|
||||||
.into_response(req)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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 = {
|
|
||||||
let users = user_manager.list().await?;
|
|
||||||
let has_any_admin = users.iter().any(|u| u.is_admin());
|
|
||||||
dto::InitialSetup {
|
|
||||||
has_any_users: has_any_admin,
|
|
||||||
}
|
|
||||||
};
|
|
||||||
Ok(Json(initial_setup))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[put("/config")]
|
|
||||||
async fn apply_config(
|
|
||||||
_admin_rights: AdminRights,
|
|
||||||
config_manager: Data<config::Manager>,
|
|
||||||
config: Json<dto::Config>,
|
|
||||||
) -> Result<HttpResponse, APIError> {
|
|
||||||
config_manager.apply(&config.to_owned().into()).await?;
|
|
||||||
Ok(HttpResponse::new(StatusCode::OK))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[get("/settings")]
|
|
||||||
async fn get_settings(
|
|
||||||
settings_manager: Data<settings::Manager>,
|
|
||||||
_admin_rights: AdminRights,
|
|
||||||
) -> Result<Json<dto::Settings>, APIError> {
|
|
||||||
let settings = settings_manager.read().await?;
|
|
||||||
Ok(Json(settings.into()))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[put("/settings")]
|
|
||||||
async fn put_settings(
|
|
||||||
_admin_rights: AdminRights,
|
|
||||||
settings_manager: Data<settings::Manager>,
|
|
||||||
new_settings: Json<dto::NewSettings>,
|
|
||||||
) -> Result<HttpResponse, APIError> {
|
|
||||||
settings_manager
|
|
||||||
.amend(&new_settings.to_owned().into())
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::new(StatusCode::OK))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[get("/mount_dirs")]
|
|
||||||
async fn list_mount_dirs(
|
|
||||||
vfs_manager: Data<vfs::Manager>,
|
|
||||||
_admin_rights: AdminRights,
|
|
||||||
) -> Result<Json<Vec<dto::MountDir>>, APIError> {
|
|
||||||
let mount_dirs = vfs_manager.mount_dirs().await?;
|
|
||||||
let mount_dirs = mount_dirs.into_iter().map(|m| m.into()).collect();
|
|
||||||
Ok(Json(mount_dirs))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[put("/mount_dirs")]
|
|
||||||
async fn put_mount_dirs(
|
|
||||||
_admin_rights: AdminRights,
|
|
||||||
vfs_manager: Data<vfs::Manager>,
|
|
||||||
new_mount_dirs: Json<Vec<dto::MountDir>>,
|
|
||||||
) -> Result<HttpResponse, APIError> {
|
|
||||||
let new_mount_dirs: Vec<MountDir> = new_mount_dirs.iter().cloned().map(|m| m.into()).collect();
|
|
||||||
vfs_manager.set_mount_dirs(&new_mount_dirs).await?;
|
|
||||||
Ok(HttpResponse::new(StatusCode::OK))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[get("/ddns")]
|
|
||||||
async fn get_ddns_config(
|
|
||||||
ddns_manager: Data<ddns::Manager>,
|
|
||||||
_admin_rights: AdminRights,
|
|
||||||
) -> Result<Json<dto::DDNSConfig>, APIError> {
|
|
||||||
let ddns_config = ddns_manager.config().await?;
|
|
||||||
Ok(Json(ddns_config.into()))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[put("/ddns")]
|
|
||||||
async fn put_ddns_config(
|
|
||||||
_admin_rights: AdminRights,
|
|
||||||
ddns_manager: Data<ddns::Manager>,
|
|
||||||
new_ddns_config: Json<dto::DDNSConfig>,
|
|
||||||
) -> Result<HttpResponse, APIError> {
|
|
||||||
ddns_manager
|
|
||||||
.set_config(&new_ddns_config.to_owned().into())
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::new(StatusCode::OK))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[get("/users")]
|
|
||||||
async fn list_users(
|
|
||||||
user_manager: Data<user::Manager>,
|
|
||||||
_admin_rights: AdminRights,
|
|
||||||
) -> Result<Json<Vec<dto::User>>, APIError> {
|
|
||||||
let users = user_manager.list().await?;
|
|
||||||
let users = users.into_iter().map(|u| u.into()).collect();
|
|
||||||
Ok(Json(users))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[post("/user")]
|
|
||||||
async fn create_user(
|
|
||||||
user_manager: Data<user::Manager>,
|
|
||||||
_admin_rights: AdminRights,
|
|
||||||
new_user: Json<dto::NewUser>,
|
|
||||||
) -> Result<HttpResponse, APIError> {
|
|
||||||
let new_user = new_user.to_owned().into();
|
|
||||||
user_manager.create(&new_user).await?;
|
|
||||||
Ok(HttpResponse::new(StatusCode::OK))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[put("/user/{name}")]
|
|
||||||
async fn update_user(
|
|
||||||
user_manager: Data<user::Manager>,
|
|
||||||
admin_rights: AdminRights,
|
|
||||||
name: web::Path<String>,
|
|
||||||
user_update: Json<dto::UserUpdate>,
|
|
||||||
) -> Result<HttpResponse, APIError> {
|
|
||||||
if let Some(auth) = &admin_rights.auth {
|
|
||||||
if auth.username == name.as_str() && user_update.new_is_admin == Some(false) {
|
|
||||||
return Err(APIError::OwnAdminPrivilegeRemoval);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(password) = &user_update.new_password {
|
|
||||||
user_manager.set_password(&name, password).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(is_admin) = &user_update.new_is_admin {
|
|
||||||
user_manager.set_is_admin(&name, *is_admin).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(HttpResponse::new(StatusCode::OK))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[delete("/user/{name}")]
|
|
||||||
async fn delete_user(
|
|
||||||
user_manager: Data<user::Manager>,
|
|
||||||
admin_rights: AdminRights,
|
|
||||||
name: web::Path<String>,
|
|
||||||
) -> Result<HttpResponse, APIError> {
|
|
||||||
if let Some(auth) = &admin_rights.auth {
|
|
||||||
if auth.username == name.as_str() {
|
|
||||||
return Err(APIError::DeletingOwnAccount);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
user_manager.delete(&name).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 = 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> {
|
|
||||||
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::Credentials>,
|
|
||||||
) -> Result<HttpResponse, APIError> {
|
|
||||||
let username = credentials.username.clone();
|
|
||||||
|
|
||||||
let user::AuthToken(token) = user_manager
|
|
||||||
.login(&credentials.username, &credentials.password)
|
|
||||||
.await?;
|
|
||||||
let is_admin = user_manager.is_admin(&credentials.username).await?;
|
|
||||||
|
|
||||||
let authorization = dto::Authorization {
|
|
||||||
username: username.clone(),
|
|
||||||
token,
|
|
||||||
is_admin,
|
|
||||||
};
|
|
||||||
|
|
||||||
let response = HttpResponse::Ok().json(authorization);
|
|
||||||
Ok(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[get("/browse")]
|
|
||||||
async fn browse_root(
|
|
||||||
index: Data<Index>,
|
|
||||||
_auth: Auth,
|
|
||||||
) -> Result<Json<Vec<index::CollectionFile>>, APIError> {
|
|
||||||
let result = 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 path = percent_decode_str(&path).decode_utf8_lossy();
|
|
||||||
let result = 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 = 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 path = percent_decode_str(&path).decode_utf8_lossy();
|
|
||||||
let songs = 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 = 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 = 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 = 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 = 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<MediaFile, APIError> {
|
|
||||||
let vfs = vfs_manager.get_vfs().await?;
|
|
||||||
let path = percent_decode_str(&path).decode_utf8_lossy();
|
|
||||||
let audio_path = vfs.virtual_to_real(Path::new(path.as_ref()))?;
|
|
||||||
let named_file = NamedFile::open(audio_path).map_err(|_| APIError::AudioFileIOError)?;
|
|
||||||
Ok(MediaFile::new(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<MediaFile, APIError> {
|
|
||||||
let options = thumbnail::Options::from(options_input.0);
|
|
||||||
let vfs = vfs_manager.get_vfs().await?;
|
|
||||||
let path = percent_decode_str(&path).decode_utf8_lossy();
|
|
||||||
let image_path = vfs.virtual_to_real(Path::new(path.as_ref()))?;
|
|
||||||
let thumbnail_path = thumbnails_manager.get_thumbnail(&image_path, &options)?;
|
|
||||||
let named_file = NamedFile::open(thumbnail_path).map_err(|_| APIError::ThumbnailFileIOError)?;
|
|
||||||
Ok(MediaFile::new(named_file))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[get("/playlists")]
|
|
||||||
async fn list_playlists(
|
|
||||||
playlist_manager: Data<playlist::Manager>,
|
|
||||||
auth: Auth,
|
|
||||||
) -> Result<Json<Vec<dto::ListPlaylistsEntry>>, APIError> {
|
|
||||||
let playlist_names = 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> {
|
|
||||||
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 = 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> {
|
|
||||||
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> {
|
|
||||||
if !user_manager.is_lastfm_linked(&auth.username).await {
|
|
||||||
return Err(APIError::LastFMAccountNotLinked);
|
|
||||||
}
|
|
||||||
let path = percent_decode_str(&path).decode_utf8_lossy();
|
|
||||||
lastfm_manager
|
|
||||||
.now_playing(&auth.username, Path::new(path.as_ref()))
|
|
||||||
.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> {
|
|
||||||
if !user_manager.is_lastfm_linked(&auth.username).await {
|
|
||||||
return Err(APIError::LastFMAccountNotLinked);
|
|
||||||
}
|
|
||||||
let path = percent_decode_str(&path).decode_utf8_lossy();
|
|
||||||
lastfm_manager
|
|
||||||
.scrobble(&auth.username, Path::new(path.as_ref()))
|
|
||||||
.await?;
|
|
||||||
Ok(HttpResponse::new(StatusCode::OK))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[get("/lastfm/link_token")]
|
|
||||||
async fn lastfm_link_token(
|
|
||||||
lastfm_manager: Data<lastfm::Manager>,
|
|
||||||
auth: Auth,
|
|
||||||
) -> Result<Json<dto::LastFMLinkToken>, APIError> {
|
|
||||||
let user::AuthToken(value) = lastfm_manager.generate_link_token(&auth.username)?;
|
|
||||||
Ok(Json(dto::LastFMLinkToken { value }))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[get("/lastfm/link")]
|
|
||||||
async fn lastfm_link(
|
|
||||||
lastfm_manager: Data<lastfm::Manager>,
|
|
||||||
user_manager: Data<user::Manager>,
|
|
||||||
payload: web::Query<dto::LastFMLink>,
|
|
||||||
) -> Result<HttpResponse, APIError> {
|
|
||||||
let auth_token = user::AuthToken(payload.auth_token.clone());
|
|
||||||
let authorization = user_manager
|
|
||||||
.authenticate(&auth_token, user::AuthorizationScope::LastFMLink)
|
|
||||||
.await?;
|
|
||||||
let lastfm_token = &payload.token;
|
|
||||||
lastfm_manager
|
|
||||||
.link(&authorization.username, lastfm_token)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// Percent decode
|
|
||||||
let base64_content = percent_decode_str(&payload.content).decode_utf8_lossy();
|
|
||||||
|
|
||||||
// Base64 decode
|
|
||||||
let popup_content = BASE64_STANDARD_NO_PAD
|
|
||||||
.decode(base64_content.as_bytes())
|
|
||||||
.map_err(|_| APIError::LastFMLinkContentBase64DecodeError)?;
|
|
||||||
|
|
||||||
// UTF-8 decode
|
|
||||||
let popup_content_string = str::from_utf8(&popup_content)
|
|
||||||
.map_err(|_| APIError::LastFMLinkContentEncodingError)
|
|
||||||
.map(|s| s.to_owned())?;
|
|
||||||
|
|
||||||
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> {
|
|
||||||
lastfm_manager.unlink(&auth.username).await?;
|
|
||||||
Ok(HttpResponse::new(StatusCode::OK))
|
|
||||||
}
|
|
|
@ -1,13 +1,14 @@
|
||||||
use axum::{
|
use axum::{
|
||||||
body::Body,
|
|
||||||
extract::{DefaultBodyLimit, Path, Query, State},
|
extract::{DefaultBodyLimit, Path, Query, State},
|
||||||
response::{Html, IntoResponse},
|
response::{Html, IntoResponse},
|
||||||
routing::{delete, get, post, put},
|
routing::{delete, get, post, put},
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
|
use axum_extra::headers::Range;
|
||||||
|
use axum_extra::TypedHeader;
|
||||||
|
use axum_range::{KnownSize, Ranged};
|
||||||
use base64::{prelude::BASE64_STANDARD_NO_PAD, Engine};
|
use base64::{prelude::BASE64_STANDARD_NO_PAD, Engine};
|
||||||
use percent_encoding::percent_decode_str;
|
use percent_encoding::percent_decode_str;
|
||||||
use tokio_util::io::ReaderStream;
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
app::{config, ddns, index, lastfm, playlist, settings, thumbnail, user, vfs, App},
|
app::{config, ddns, index, lastfm, playlist, settings, thumbnail, user, vfs, App},
|
||||||
|
@ -47,6 +48,7 @@ pub fn router() -> Router<App> {
|
||||||
.route("/playlist/:name", put(put_playlist))
|
.route("/playlist/:name", put(put_playlist))
|
||||||
.route("/playlist/:name", get(get_playlist))
|
.route("/playlist/:name", get(get_playlist))
|
||||||
.route("/playlist/:name", delete(delete_playlist))
|
.route("/playlist/:name", delete(delete_playlist))
|
||||||
|
.route("/audio/*path", get(get_audio))
|
||||||
.route("/thumbnail/*path", get(get_thumbnail))
|
.route("/thumbnail/*path", get(get_thumbnail))
|
||||||
.route("/lastfm/now_playing/*path", put(put_lastfm_now_playing))
|
.route("/lastfm/now_playing/*path", put(put_lastfm_now_playing))
|
||||||
.route("/lastfm/scrobble/*path", post(post_lastfm_scrobble))
|
.route("/lastfm/scrobble/*path", post(post_lastfm_scrobble))
|
||||||
|
@ -366,12 +368,35 @@ async fn delete_playlist(
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_audio(
|
||||||
|
_auth: Auth,
|
||||||
|
State(vfs_manager): State<vfs::Manager>,
|
||||||
|
Path(path): Path<String>,
|
||||||
|
range: Option<TypedHeader<Range>>,
|
||||||
|
) -> Result<impl IntoResponse, APIError> {
|
||||||
|
let vfs = vfs_manager.get_vfs().await?;
|
||||||
|
let path = percent_decode_str(&path).decode_utf8_lossy();
|
||||||
|
let audio_path = vfs.virtual_to_real(std::path::Path::new(path.as_ref()))?;
|
||||||
|
|
||||||
|
let Ok(file) = tokio::fs::File::open(audio_path).await else {
|
||||||
|
return Err(APIError::AudioFileIOError);
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok(body) = KnownSize::file(file).await else {
|
||||||
|
return Err(APIError::AudioFileIOError);
|
||||||
|
};
|
||||||
|
|
||||||
|
let range = range.map(|TypedHeader(r)| r);
|
||||||
|
Ok(Ranged::new(range, body))
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_thumbnail(
|
async fn get_thumbnail(
|
||||||
_auth: Auth,
|
_auth: Auth,
|
||||||
State(vfs_manager): State<vfs::Manager>,
|
State(vfs_manager): State<vfs::Manager>,
|
||||||
State(thumbnails_manager): State<thumbnail::Manager>,
|
State(thumbnails_manager): State<thumbnail::Manager>,
|
||||||
Path(path): Path<String>,
|
Path(path): Path<String>,
|
||||||
Query(options_input): Query<dto::ThumbnailOptions>,
|
Query(options_input): Query<dto::ThumbnailOptions>,
|
||||||
|
range: Option<TypedHeader<Range>>,
|
||||||
) -> Result<impl IntoResponse, APIError> {
|
) -> Result<impl IntoResponse, APIError> {
|
||||||
let options = thumbnail::Options::from(options_input);
|
let options = thumbnail::Options::from(options_input);
|
||||||
let vfs = vfs_manager.get_vfs().await?;
|
let vfs = vfs_manager.get_vfs().await?;
|
||||||
|
@ -383,7 +408,12 @@ async fn get_thumbnail(
|
||||||
return Err(APIError::ThumbnailFileIOError);
|
return Err(APIError::ThumbnailFileIOError);
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Body::from_stream(ReaderStream::new(file)))
|
let Ok(body) = KnownSize::file(file).await else {
|
||||||
|
return Err(APIError::ThumbnailFileIOError);
|
||||||
|
};
|
||||||
|
|
||||||
|
let range = range.map(|TypedHeader(r)| r);
|
||||||
|
Ok(Ranged::new(range, body))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn put_lastfm_now_playing(
|
async fn put_lastfm_now_playing(
|
||||||
|
|
Loading…
Add table
Reference in a new issue