mirror of
https://github.com/agersant/polaris
synced 2024-11-10 10:14:12 +00:00
Merge branch 'rocket'
This commit is contained in:
commit
94602317ad
24 changed files with 2145 additions and 1909 deletions
1
.gitmodules
vendored
1
.gitmodules
vendored
|
@ -1,3 +1,4 @@
|
|||
[submodule "web"]
|
||||
path = web
|
||||
url = https://github.com/agersant/polaris-web.git
|
||||
branch = .
|
||||
|
|
|
@ -6,5 +6,5 @@ rust:
|
|||
|
||||
matrix:
|
||||
allow_failures:
|
||||
- rust: beta
|
||||
- rust: nightly
|
||||
- rust: stable
|
||||
- rust: beta
|
1739
Cargo.lock
generated
1739
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
22
Cargo.toml
22
Cargo.toml
|
@ -1,7 +1,8 @@
|
|||
[package]
|
||||
name = "polaris"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
authors = ["Antoine Gersant <antoine.gersant@lesforges.org>"]
|
||||
edition = "2018"
|
||||
|
||||
[features]
|
||||
ui = []
|
||||
|
@ -9,37 +10,34 @@ ui = []
|
|||
[dependencies]
|
||||
ape = "0.2.0"
|
||||
app_dirs = "1.1.1"
|
||||
base64 = "0.9.3"
|
||||
base64 = "0.10.0"
|
||||
diesel = { version = "1.3.3", features = ["sqlite"] }
|
||||
diesel_migrations = { version = "1.3.0", features = ["sqlite"] }
|
||||
error-chain = "0.12.0"
|
||||
getopts = "0.2.15"
|
||||
hyper = "0.12.11"
|
||||
id3 = "0.2.3"
|
||||
image = "0.20.0"
|
||||
iron = "0.6.0"
|
||||
rustfm-scrobble = { git = "https://github.com/agersant/rustfm-scrobble" }
|
||||
lewton = "0.9.1"
|
||||
log = "0.4.5"
|
||||
metaflac = "0.1.8"
|
||||
mount = "0.4.0"
|
||||
mp3-duration = "0.1.0"
|
||||
params = { git = "https://github.com/agersant/params" }
|
||||
rand = "0.5.5"
|
||||
regex = "1.0.5"
|
||||
ring = "0.13.2"
|
||||
ring = "0.13.5"
|
||||
reqwest = "0.9.2"
|
||||
router = "0.6.0"
|
||||
rocket = "0.4.0"
|
||||
rust-crypto = "0.2.36"
|
||||
secure-session = "0.3.1"
|
||||
serde = "1.0"
|
||||
serde_derive = "1.0"
|
||||
serde_json = "1.0"
|
||||
staticfile = "0.5.0"
|
||||
simplelog = "0.5.2"
|
||||
toml = "0.4.5"
|
||||
typemap = "0.3"
|
||||
url = "1.2.0"
|
||||
|
||||
[dependencies.rocket_contrib]
|
||||
version = "0.4.0"
|
||||
default_features = false
|
||||
features = ["json", "serve"]
|
||||
|
||||
[dependencies.rusqlite]
|
||||
version = "0.14.0"
|
||||
|
|
|
@ -55,7 +55,7 @@ environment:
|
|||
# or test failure in the matching channels/targets from failing the entire build.
|
||||
matrix:
|
||||
allow_failures:
|
||||
- channel: nightly
|
||||
- channel: stable
|
||||
- channel: beta
|
||||
|
||||
## Install Script ##
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<?xml version='1.0' encoding='windows-1252'?>
|
||||
<Wix xmlns='http://schemas.microsoft.com/wix/2006/wi' xmlns:util="http://schemas.microsoft.com/wix/UtilExtension">
|
||||
<Product Name='Polaris' Id='A9B98E78-65E3-4002-BF73-B026A1A5473C' UpgradeCode='FF16B075-1D36-47F4-BE37-D95BBC1A412C' Language='1033' Codepage='1252' Version='0.8.0' Manufacturer='Permafrost'>
|
||||
<Product Name='Polaris' Id='9298DCA7-8FEB-48B2-89F9-8C60BC320D07' UpgradeCode='FF16B075-1D36-47F4-BE37-D95BBC1A412C' Language='1033' Codepage='1252' Version='0.9.0' Manufacturer='Permafrost'>
|
||||
|
||||
<Package Id='*' Keywords='Installer' Platform='x64' InstallScope='perUser' Description='Polaris Installer' Manufacturer='Permafrost' Languages='1033' Compressed='yes' SummaryCodepage='1252' />
|
||||
|
||||
|
|
1075
src/api.rs
1075
src/api.rs
File diff suppressed because it is too large
Load diff
564
src/api_tests.rs
Normal file
564
src/api_tests.rs
Normal file
|
@ -0,0 +1,564 @@
|
|||
use rocket::http::hyper::header::*;
|
||||
use rocket::http::uri::Uri;
|
||||
use rocket::http::Status;
|
||||
use rocket::local::Client;
|
||||
use std::fs;
|
||||
use std::ops::Deref;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::{thread, time};
|
||||
|
||||
use crate::api;
|
||||
use crate::config;
|
||||
use crate::db;
|
||||
use crate::ddns;
|
||||
use crate::index;
|
||||
use crate::server;
|
||||
use crate::vfs;
|
||||
|
||||
const TEST_USERNAME: &str = "test_user";
|
||||
const TEST_PASSWORD: &str = "test_password";
|
||||
const TEST_MOUNT_NAME: &str = "collection";
|
||||
const TEST_MOUNT_SOURCE: &str = "test/collection";
|
||||
|
||||
struct TestEnvironment {
|
||||
pub client: Client,
|
||||
command_sender: Arc<index::CommandSender>,
|
||||
db: Arc<db::DB>,
|
||||
}
|
||||
|
||||
impl TestEnvironment {
|
||||
pub fn update_index(&self) {
|
||||
index::update(self.db.deref()).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestEnvironment {
|
||||
fn drop(&mut self) {
|
||||
self.command_sender.deref().exit().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn get_test_environment(db_name: &str) -> TestEnvironment {
|
||||
let mut db_path = PathBuf::new();
|
||||
db_path.push("test");
|
||||
db_path.push(db_name);
|
||||
if db_path.exists() {
|
||||
fs::remove_file(&db_path).unwrap();
|
||||
}
|
||||
|
||||
let db = Arc::new(db::DB::new(&db_path).unwrap());
|
||||
|
||||
let web_dir_path = PathBuf::from("web");
|
||||
let command_sender = index::init(db.clone());
|
||||
|
||||
let server = server::get_server(
|
||||
5050,
|
||||
"/",
|
||||
"/api",
|
||||
&web_dir_path,
|
||||
db.clone(),
|
||||
command_sender.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
let client = Client::new(server).unwrap();
|
||||
TestEnvironment {
|
||||
client,
|
||||
command_sender,
|
||||
db,
|
||||
}
|
||||
}
|
||||
|
||||
fn complete_initial_setup(client: &Client) {
|
||||
let configuration = config::Config {
|
||||
album_art_pattern: None,
|
||||
prefix_url: None,
|
||||
reindex_every_n_seconds: None,
|
||||
ydns: None,
|
||||
users: Some(vec![config::ConfigUser {
|
||||
name: TEST_USERNAME.into(),
|
||||
password: TEST_PASSWORD.into(),
|
||||
admin: true,
|
||||
}]),
|
||||
mount_dirs: Some(vec![vfs::MountPoint {
|
||||
name: TEST_MOUNT_NAME.into(),
|
||||
source: TEST_MOUNT_SOURCE.into(),
|
||||
}]),
|
||||
};
|
||||
let body = serde_json::to_string(&configuration).unwrap();
|
||||
let response = client.put("/api/settings").body(&body).dispatch();
|
||||
assert_eq!(response.status(), Status::Ok);
|
||||
}
|
||||
|
||||
fn do_auth(client: &Client) {
|
||||
let credentials = api::AuthCredentials {
|
||||
username: TEST_USERNAME.into(),
|
||||
password: TEST_PASSWORD.into(),
|
||||
};
|
||||
let body = serde_json::to_string(&credentials).unwrap();
|
||||
let response = client.post("/api/auth").body(body).dispatch();
|
||||
assert_eq!(response.status(), Status::Ok);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version() {
|
||||
let env = get_test_environment("api_version.sqlite");
|
||||
let client = &env.client;
|
||||
let mut response = client.get("/api/version").dispatch();
|
||||
|
||||
assert_eq!(response.status(), Status::Ok);
|
||||
|
||||
let response_body = response.body_string().unwrap();
|
||||
let response_json: api::Version = serde_json::from_str(&response_body).unwrap();
|
||||
assert_eq!(response_json, api::Version { major: 3, minor: 0 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_setup() {
|
||||
let env = get_test_environment("api_initial_setup.sqlite");
|
||||
let client = &env.client;
|
||||
|
||||
{
|
||||
let mut response = client.get("/api/initial_setup").dispatch();
|
||||
assert_eq!(response.status(), Status::Ok);
|
||||
let response_body = response.body_string().unwrap();
|
||||
let response_json: api::InitialSetup = serde_json::from_str(&response_body).unwrap();
|
||||
assert_eq!(
|
||||
response_json,
|
||||
api::InitialSetup {
|
||||
has_any_users: false
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
complete_initial_setup(client);
|
||||
|
||||
{
|
||||
let mut response = client.get("/api/initial_setup").dispatch();
|
||||
assert_eq!(response.status(), Status::Ok);
|
||||
let response_body = response.body_string().unwrap();
|
||||
let response_json: api::InitialSetup = serde_json::from_str(&response_body).unwrap();
|
||||
assert_eq!(
|
||||
response_json,
|
||||
api::InitialSetup {
|
||||
has_any_users: true
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings() {
|
||||
let env = get_test_environment("api_settings.sqlite");
|
||||
let client = &env.client;
|
||||
complete_initial_setup(client);
|
||||
|
||||
{
|
||||
let response = client.get("/api/settings").dispatch();
|
||||
assert_eq!(response.status(), Status::Unauthorized);
|
||||
}
|
||||
|
||||
do_auth(client);
|
||||
|
||||
{
|
||||
let mut response = client.get("/api/settings").dispatch();
|
||||
assert_eq!(response.status(), Status::Ok);
|
||||
let response_body = response.body_string().unwrap();
|
||||
let response_json: config::Config = serde_json::from_str(&response_body).unwrap();
|
||||
assert_eq!(
|
||||
response_json,
|
||||
config::Config {
|
||||
album_art_pattern: Some("Folder.(jpg|png)".to_string()),
|
||||
reindex_every_n_seconds: Some(1800),
|
||||
mount_dirs: Some(vec![vfs::MountPoint {
|
||||
name: TEST_MOUNT_NAME.into(),
|
||||
source: TEST_MOUNT_SOURCE.into()
|
||||
}]),
|
||||
prefix_url: None,
|
||||
users: Some(vec![config::ConfigUser {
|
||||
name: TEST_USERNAME.into(),
|
||||
password: "".into(),
|
||||
admin: true
|
||||
}]),
|
||||
ydns: Some(ddns::DDNSConfig {
|
||||
host: "".into(),
|
||||
username: "".into(),
|
||||
password: "".into()
|
||||
}),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
let mut configuration = config::Config {
|
||||
album_art_pattern: Some("my_pattern".to_owned()),
|
||||
reindex_every_n_seconds: Some(3600),
|
||||
mount_dirs: Some(vec![
|
||||
vfs::MountPoint {
|
||||
name: TEST_MOUNT_NAME.into(),
|
||||
source: TEST_MOUNT_SOURCE.into(),
|
||||
},
|
||||
vfs::MountPoint {
|
||||
name: "more_music".into(),
|
||||
source: "test/collection".into(),
|
||||
},
|
||||
]),
|
||||
prefix_url: Some("my_prefix".to_owned()),
|
||||
users: Some(vec![
|
||||
config::ConfigUser {
|
||||
name: "test_user".into(),
|
||||
password: "some_password".into(),
|
||||
admin: true,
|
||||
},
|
||||
config::ConfigUser {
|
||||
name: "other_user".into(),
|
||||
password: "some_other_password".into(),
|
||||
admin: false,
|
||||
},
|
||||
]),
|
||||
ydns: Some(ddns::DDNSConfig {
|
||||
host: "my_host".into(),
|
||||
username: "my_username".into(),
|
||||
password: "my_password".into(),
|
||||
}),
|
||||
};
|
||||
|
||||
let body = serde_json::to_string(&configuration).unwrap();
|
||||
|
||||
configuration.users = Some(vec![
|
||||
config::ConfigUser {
|
||||
name: "test_user".into(),
|
||||
password: "".into(),
|
||||
admin: true,
|
||||
},
|
||||
config::ConfigUser {
|
||||
name: "other_user".into(),
|
||||
password: "".into(),
|
||||
admin: false,
|
||||
},
|
||||
]);
|
||||
|
||||
client.put("/api/settings").body(body).dispatch();
|
||||
|
||||
{
|
||||
let mut response = client.get("/api/settings").dispatch();
|
||||
assert_eq!(response.status(), Status::Ok);
|
||||
let response_body = response.body_string().unwrap();
|
||||
let response_json: config::Config = serde_json::from_str(&response_body).unwrap();
|
||||
assert_eq!(response_json, configuration);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferences() {
|
||||
// TODO
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trigger_index() {
|
||||
let env = get_test_environment("api_trigger_index.sqlite");
|
||||
let client = &env.client;
|
||||
complete_initial_setup(client);
|
||||
do_auth(client);
|
||||
|
||||
{
|
||||
let mut response = client.get("/api/random").dispatch();
|
||||
assert_eq!(response.status(), Status::Ok);
|
||||
let response_body = response.body_string().unwrap();
|
||||
let response_json: Vec<index::Directory> = serde_json::from_str(&response_body).unwrap();
|
||||
assert_eq!(response_json.len(), 0);
|
||||
}
|
||||
|
||||
{
|
||||
let response = client.post("/api/trigger_index").dispatch();
|
||||
assert_eq!(response.status(), Status::Ok);
|
||||
}
|
||||
|
||||
let timeout = time::Duration::from_secs(5);
|
||||
thread::sleep(timeout);
|
||||
|
||||
{
|
||||
let mut response = client.get("/api/random").dispatch();
|
||||
assert_eq!(response.status(), Status::Ok);
|
||||
let response_body = response.body_string().unwrap();
|
||||
let response_json: Vec<index::Directory> = serde_json::from_str(&response_body).unwrap();
|
||||
assert_eq!(response_json.len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth() {
|
||||
let env = get_test_environment("api_auth.sqlite");
|
||||
let client = &env.client;
|
||||
complete_initial_setup(client);
|
||||
|
||||
{
|
||||
let credentials = api::AuthCredentials {
|
||||
username: "garbage".into(),
|
||||
password: "garbage".into(),
|
||||
};
|
||||
let response = client
|
||||
.post("/api/auth")
|
||||
.body(serde_json::to_string(&credentials).unwrap())
|
||||
.dispatch();
|
||||
assert_eq!(response.status(), Status::Unauthorized);
|
||||
}
|
||||
{
|
||||
let credentials = api::AuthCredentials {
|
||||
username: TEST_USERNAME.into(),
|
||||
password: "garbage".into(),
|
||||
};
|
||||
let response = client
|
||||
.post("/api/auth")
|
||||
.body(serde_json::to_string(&credentials).unwrap())
|
||||
.dispatch();
|
||||
assert_eq!(response.status(), Status::Unauthorized);
|
||||
}
|
||||
{
|
||||
let credentials = api::AuthCredentials {
|
||||
username: TEST_USERNAME.into(),
|
||||
password: TEST_PASSWORD.into(),
|
||||
};
|
||||
let response = client
|
||||
.post("/api/auth")
|
||||
.body(serde_json::to_string(&credentials).unwrap())
|
||||
.dispatch();
|
||||
assert_eq!(response.status(), Status::Ok);
|
||||
assert_eq!(response.cookies()[0].name(), "session");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browse() {
|
||||
let env = get_test_environment("api_browse.sqlite");
|
||||
let client = &env.client;
|
||||
complete_initial_setup(client);
|
||||
do_auth(client);
|
||||
env.update_index();
|
||||
|
||||
{
|
||||
let mut response = client.get("/api/browse").dispatch();
|
||||
assert_eq!(response.status(), Status::Ok);
|
||||
let response_body = response.body_string().unwrap();
|
||||
let response_json: Vec<index::CollectionFile> =
|
||||
serde_json::from_str(&response_body).unwrap();
|
||||
assert_eq!(response_json.len(), 1);
|
||||
}
|
||||
|
||||
let mut next;
|
||||
{
|
||||
let mut response = client.get("/api/browse/collection").dispatch();
|
||||
assert_eq!(response.status(), Status::Ok);
|
||||
let response_body = response.body_string().unwrap();
|
||||
let response_json: Vec<index::CollectionFile> =
|
||||
serde_json::from_str(&response_body).unwrap();
|
||||
assert_eq!(response_json.len(), 2);
|
||||
|
||||
match response_json[0] {
|
||||
index::CollectionFile::Directory(ref d) => {
|
||||
next = d.path.clone();
|
||||
}
|
||||
_ => panic!(),
|
||||
}
|
||||
}
|
||||
|
||||
// /api/browse/collection/Khemmis
|
||||
{
|
||||
let url = format!("/api/browse/{}", Uri::percent_encode(&next));
|
||||
let mut response = client.get(url).dispatch();
|
||||
assert_eq!(response.status(), Status::Ok);
|
||||
let response_body = response.body_string().unwrap();
|
||||
let response_json: Vec<index::CollectionFile> =
|
||||
serde_json::from_str(&response_body).unwrap();
|
||||
assert_eq!(response_json.len(), 1);
|
||||
match response_json[0] {
|
||||
index::CollectionFile::Directory(ref d) => {
|
||||
next = d.path.clone();
|
||||
}
|
||||
_ => panic!(),
|
||||
}
|
||||
}
|
||||
|
||||
// /api/browse/collection/Khemmis/Hunted
|
||||
{
|
||||
let url = format!("/api/browse/{}", Uri::percent_encode(&next));
|
||||
let mut response = client.get(url).dispatch();
|
||||
assert_eq!(response.status(), Status::Ok);
|
||||
let response_body = response.body_string().unwrap();
|
||||
let response_json: Vec<index::CollectionFile> =
|
||||
serde_json::from_str(&response_body).unwrap();
|
||||
assert_eq!(response_json.len(), 5);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flatten() {
|
||||
let env = get_test_environment("api_flatten.sqlite");
|
||||
let client = &env.client;
|
||||
complete_initial_setup(client);
|
||||
do_auth(client);
|
||||
env.update_index();
|
||||
|
||||
{
|
||||
let mut response = client.get("/api/flatten").dispatch();
|
||||
assert_eq!(response.status(), Status::Ok);
|
||||
let response_body = response.body_string().unwrap();
|
||||
let response_json: Vec<index::Song> = serde_json::from_str(&response_body).unwrap();
|
||||
assert_eq!(response_json.len(), 12);
|
||||
}
|
||||
|
||||
{
|
||||
let mut response = client.get("/api/flatten/collection").dispatch();
|
||||
assert_eq!(response.status(), Status::Ok);
|
||||
let response_body = response.body_string().unwrap();
|
||||
let response_json: Vec<index::Song> = serde_json::from_str(&response_body).unwrap();
|
||||
assert_eq!(response_json.len(), 12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random() {
|
||||
let env = get_test_environment("api_random.sqlite");
|
||||
let client = &env.client;
|
||||
complete_initial_setup(client);
|
||||
do_auth(client);
|
||||
env.update_index();
|
||||
|
||||
let mut response = client.get("/api/random").dispatch();
|
||||
assert_eq!(response.status(), Status::Ok);
|
||||
let response_body = response.body_string().unwrap();
|
||||
let response_json: Vec<index::Directory> = serde_json::from_str(&response_body).unwrap();
|
||||
assert_eq!(response_json.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent() {
|
||||
let env = get_test_environment("api_recent.sqlite");
|
||||
let client = &env.client;
|
||||
complete_initial_setup(client);
|
||||
do_auth(client);
|
||||
env.update_index();
|
||||
|
||||
let mut response = client.get("/api/recent").dispatch();
|
||||
assert_eq!(response.status(), Status::Ok);
|
||||
let response_body = response.body_string().unwrap();
|
||||
let response_json: Vec<index::Directory> = serde_json::from_str(&response_body).unwrap();
|
||||
assert_eq!(response_json.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search() {
|
||||
let env = get_test_environment("api_search.sqlite");
|
||||
let client = &env.client;
|
||||
complete_initial_setup(client);
|
||||
do_auth(client);
|
||||
env.update_index();
|
||||
|
||||
let mut response = client.get("/api/search/door").dispatch();
|
||||
assert_eq!(response.status(), Status::Ok);
|
||||
let response_body = response.body_string().unwrap();
|
||||
let response_json: Vec<index::CollectionFile> = serde_json::from_str(&response_body).unwrap();
|
||||
assert_eq!(response_json.len(), 1);
|
||||
match response_json[0] {
|
||||
index::CollectionFile::Song(ref s) => assert_eq!(s.title, Some("Beyond The Door".into())),
|
||||
_ => panic!(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serve() {
|
||||
let env = get_test_environment("api_serve.sqlite");
|
||||
let client = &env.client;
|
||||
complete_initial_setup(client);
|
||||
do_auth(client);
|
||||
env.update_index();
|
||||
|
||||
{
|
||||
let mut response = client.get("/api/serve/collection%2FKhemmis%2FHunted%2F02%20-%20Candlelight.mp3").dispatch();
|
||||
assert_eq!(response.status(), Status::Ok);
|
||||
let body = response.body().unwrap();
|
||||
let body = body.into_bytes().unwrap();
|
||||
assert_eq!(body.len(), 24_142);
|
||||
}
|
||||
|
||||
{
|
||||
let mut response = client.get("/api/serve/collection%2FKhemmis%2FHunted%2F02%20-%20Candlelight.mp3")
|
||||
.header(Range::bytes(100, 299))
|
||||
.dispatch();
|
||||
assert_eq!(response.status(), Status::PartialContent);
|
||||
let body = response.body().unwrap();
|
||||
let body = body.into_bytes().unwrap();
|
||||
assert_eq!(body.len(), 200);
|
||||
assert_eq!(response.headers().get_one("Content-Length").unwrap(), "200");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playlists() {
|
||||
let env = get_test_environment("api_playlists.sqlite");
|
||||
let client = &env.client;
|
||||
complete_initial_setup(client);
|
||||
do_auth(client);
|
||||
env.update_index();
|
||||
|
||||
{
|
||||
let mut response = client.get("/api/playlists").dispatch();
|
||||
assert_eq!(response.status(), Status::Ok);
|
||||
let response_body = response.body_string().unwrap();
|
||||
let response_json: Vec<api::ListPlaylistsEntry> =
|
||||
serde_json::from_str(&response_body).unwrap();
|
||||
assert_eq!(response_json.len(), 0);
|
||||
}
|
||||
|
||||
{
|
||||
let songs: Vec<index::Song>;
|
||||
{
|
||||
let mut response = client.get("/api/flatten").dispatch();
|
||||
let response_body = response.body_string().unwrap();
|
||||
songs = serde_json::from_str(&response_body).unwrap();
|
||||
}
|
||||
let my_playlist = api::SavePlaylistInput {
|
||||
tracks: songs[2..6].into_iter().map(|s| s.path.clone()).collect(),
|
||||
};
|
||||
let response = client
|
||||
.put("/api/playlist/my_playlist")
|
||||
.body(serde_json::to_string(&my_playlist).unwrap())
|
||||
.dispatch();
|
||||
assert_eq!(response.status(), Status::Ok);
|
||||
}
|
||||
|
||||
{
|
||||
let mut response = client.get("/api/playlists").dispatch();
|
||||
assert_eq!(response.status(), Status::Ok);
|
||||
let response_body = response.body_string().unwrap();
|
||||
let response_json: Vec<api::ListPlaylistsEntry> =
|
||||
serde_json::from_str(&response_body).unwrap();
|
||||
assert_eq!(
|
||||
response_json,
|
||||
vec![api::ListPlaylistsEntry {
|
||||
name: "my_playlist".into()
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let mut response = client.get("/api/playlist/my_playlist").dispatch();
|
||||
assert_eq!(response.status(), Status::Ok);
|
||||
let response_body = response.body_string().unwrap();
|
||||
let response_json: Vec<index::Song> = serde_json::from_str(&response_body).unwrap();
|
||||
assert_eq!(response_json.len(), 4);
|
||||
}
|
||||
|
||||
{
|
||||
let response = client.delete("/api/playlist/my_playlist").dispatch();
|
||||
assert_eq!(response.status(), Status::Ok);
|
||||
}
|
||||
|
||||
{
|
||||
let mut response = client.get("/api/playlists").dispatch();
|
||||
assert_eq!(response.status(), Status::Ok);
|
||||
let response_body = response.body_string().unwrap();
|
||||
let response_json: Vec<api::ListPlaylistsEntry> =
|
||||
serde_json::from_str(&response_body).unwrap();
|
||||
assert_eq!(response_json.len(), 0);
|
||||
}
|
||||
}
|
|
@ -2,19 +2,18 @@ use core::ops::Deref;
|
|||
use diesel;
|
||||
use diesel::prelude::*;
|
||||
use regex::Regex;
|
||||
use serde_json;
|
||||
use std::fs;
|
||||
use std::io::Read;
|
||||
use std::path;
|
||||
use toml;
|
||||
|
||||
use db::ConnectionSource;
|
||||
use db::DB;
|
||||
use db::{ddns_config, misc_settings, mount_points, users};
|
||||
use ddns::DDNSConfig;
|
||||
use errors::*;
|
||||
use user::*;
|
||||
use vfs::MountPoint;
|
||||
use crate::db::ConnectionSource;
|
||||
use crate::db::DB;
|
||||
use crate::db::{ddns_config, misc_settings, mount_points, users};
|
||||
use crate::ddns::DDNSConfig;
|
||||
use crate::errors::*;
|
||||
use crate::user::*;
|
||||
use crate::vfs::MountPoint;
|
||||
|
||||
#[derive(Debug, Queryable)]
|
||||
pub struct MiscSettings {
|
||||
|
@ -61,12 +60,6 @@ impl Config {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn parse_json(content: &str) -> Result<Config> {
|
||||
let mut config = serde_json::from_str::<Config>(content)?;
|
||||
config.clean_paths()?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub fn parse_toml_file(path: &path::Path) -> Result<Config> {
|
||||
info!("Config file path: {}", path.to_string_lossy());
|
||||
let mut config_file = fs::File::open(path)?;
|
||||
|
@ -100,7 +93,8 @@ where
|
|||
index_album_art_pattern,
|
||||
index_sleep_duration_seconds,
|
||||
prefix_url,
|
||||
)).get_result(connection.deref())?;
|
||||
))
|
||||
.get_result(connection.deref())?;
|
||||
config.album_art_pattern = Some(art_pattern);
|
||||
config.reindex_every_n_seconds = Some(sleep_duration);
|
||||
config.prefix_url = if url != "" { Some(url) } else { None };
|
||||
|
@ -124,7 +118,8 @@ where
|
|||
name,
|
||||
password: "".to_owned(),
|
||||
admin: admin != 0,
|
||||
}).collect::<_>(),
|
||||
})
|
||||
.collect::<_>(),
|
||||
);
|
||||
|
||||
let ydns = ddns_config
|
||||
|
@ -194,7 +189,8 @@ where
|
|||
.iter()
|
||||
.find(|old_name| *old_name == &u.name)
|
||||
.is_none()
|
||||
}).collect::<_>();
|
||||
})
|
||||
.collect::<_>();
|
||||
for config_user in &insert_users {
|
||||
let new_user = User::new(&config_user.name, &config_user.password);
|
||||
diesel::insert_into(users::table)
|
||||
|
@ -242,7 +238,8 @@ where
|
|||
host.eq(ydns.host.clone()),
|
||||
username.eq(ydns.username.clone()),
|
||||
password.eq(ydns.password.clone()),
|
||||
)).execute(connection.deref())?;
|
||||
))
|
||||
.execute(connection.deref())?;
|
||||
}
|
||||
|
||||
if let Some(ref prefix_url) = new_config.prefix_url {
|
||||
|
|
|
@ -6,7 +6,7 @@ use std::fs;
|
|||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, MutexGuard};
|
||||
|
||||
use errors::*;
|
||||
use crate::errors::*;
|
||||
|
||||
mod schema;
|
||||
|
||||
|
@ -85,7 +85,7 @@ impl ConnectionSource for DB {
|
|||
}
|
||||
|
||||
pub fn _get_test_db(name: &str) -> DB {
|
||||
use config;
|
||||
use crate::config;
|
||||
let config_path = Path::new("test/config.toml");
|
||||
let config = config::parse_toml_file(&config_path).unwrap();
|
||||
|
||||
|
|
|
@ -5,9 +5,9 @@ use std::io;
|
|||
use std::thread;
|
||||
use std::time;
|
||||
|
||||
use db::ddns_config;
|
||||
use db::{ConnectionSource, DB};
|
||||
use errors;
|
||||
use crate::db::ddns_config;
|
||||
use crate::db::{ConnectionSource, DB};
|
||||
use crate::errors;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Insertable, PartialEq, Queryable, Serialize)]
|
||||
#[table_name = "ddns_config"]
|
||||
|
|
|
@ -3,14 +3,12 @@ use core;
|
|||
use diesel;
|
||||
use diesel_migrations;
|
||||
use getopts;
|
||||
use hyper;
|
||||
use id3;
|
||||
use image;
|
||||
use iron::status::Status;
|
||||
use iron::IronError;
|
||||
use lewton;
|
||||
use metaflac;
|
||||
use regex;
|
||||
use rocket;
|
||||
use rustfm_scrobble;
|
||||
use serde_json;
|
||||
use std;
|
||||
|
@ -25,7 +23,6 @@ error_chain! {
|
|||
Encoding(core::str::Utf8Error);
|
||||
Flac(metaflac::Error);
|
||||
GetOpts(getopts::Fail);
|
||||
Hyper(hyper::Error);
|
||||
Id3(id3::Error);
|
||||
Image(image::ImageError);
|
||||
Io(std::io::Error);
|
||||
|
@ -33,51 +30,27 @@ error_chain! {
|
|||
Time(std::time::SystemTimeError);
|
||||
Toml(toml::de::Error);
|
||||
Regex(regex::Error);
|
||||
RocketConfig(rocket::config::ConfigError);
|
||||
Scrobbler(rustfm_scrobble::ScrobblerError);
|
||||
Vorbis(lewton::VorbisError);
|
||||
}
|
||||
|
||||
errors {
|
||||
DaemonError {}
|
||||
AuthenticationRequired {}
|
||||
AdminPrivilegeRequired {}
|
||||
MissingConfig {}
|
||||
MissingPreferences {}
|
||||
MissingUsername {}
|
||||
MissingPassword {}
|
||||
MissingPlaylist {}
|
||||
IncorrectCredentials {}
|
||||
CannotServeDirectory {}
|
||||
UnsupportedFileType {}
|
||||
FileNotFound {}
|
||||
MissingIndexVersion {}
|
||||
MissingPlaylistName {}
|
||||
EncodingError {}
|
||||
MissingLastFMCredentials {}
|
||||
LastFMAuthError {}
|
||||
LastFMDeserializationError {}
|
||||
MissingDesiredResponse {}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Error> for IronError {
|
||||
fn from(err: Error) -> IronError {
|
||||
match err {
|
||||
e @ Error(ErrorKind::AuthenticationRequired, _) => {
|
||||
IronError::new(e, Status::Unauthorized)
|
||||
}
|
||||
e @ Error(ErrorKind::AdminPrivilegeRequired, _) => IronError::new(e, Status::Forbidden),
|
||||
e @ Error(ErrorKind::MissingUsername, _) => IronError::new(e, Status::BadRequest),
|
||||
e @ Error(ErrorKind::MissingPassword, _) => IronError::new(e, Status::BadRequest),
|
||||
e @ Error(ErrorKind::IncorrectCredentials, _) => {
|
||||
IronError::new(e, Status::Unauthorized)
|
||||
}
|
||||
e @ Error(ErrorKind::CannotServeDirectory, _) => IronError::new(e, Status::BadRequest),
|
||||
e @ Error(ErrorKind::UnsupportedFileType, _) => IronError::new(e, Status::BadRequest),
|
||||
e @ Error(ErrorKind::MissingLastFMCredentials, _) => {
|
||||
IronError::new(e, Status::Unauthorized)
|
||||
}
|
||||
e => IronError::new(e, Status::InternalServerError),
|
||||
}
|
||||
impl<'r> rocket::response::Responder<'r> for Error {
|
||||
fn respond_to(self, _: &rocket::request::Request) -> rocket::response::Result<'r> {
|
||||
let mut build = rocket::response::Response::build();
|
||||
build
|
||||
.status(match self.0 {
|
||||
ErrorKind::IncorrectCredentials => rocket::http::Status::Unauthorized,
|
||||
_ => rocket::http::Status::InternalServerError,
|
||||
})
|
||||
.ok()
|
||||
}
|
||||
}
|
||||
|
|
122
src/index.rs
122
src/index.rs
|
@ -14,14 +14,14 @@ use std::sync::{Arc, Mutex};
|
|||
use std::thread;
|
||||
use std::time;
|
||||
|
||||
use config::MiscSettings;
|
||||
use crate::config::MiscSettings;
|
||||
#[cfg(test)]
|
||||
use db;
|
||||
use db::ConnectionSource;
|
||||
use db::{directories, misc_settings, songs};
|
||||
use errors;
|
||||
use metadata;
|
||||
use vfs::{VFSSource, VFS};
|
||||
use crate::db;
|
||||
use crate::db::{directories, misc_settings, songs};
|
||||
use crate::db::{ConnectionSource, DB};
|
||||
use crate::errors;
|
||||
use crate::metadata;
|
||||
use crate::vfs::{VFSSource, VFS};
|
||||
|
||||
const INDEX_BUILDING_INSERT_BUFFER_SIZE: usize = 1000; // Insertions in each transaction
|
||||
const INDEX_BUILDING_CLEAN_BUFFER_SIZE: usize = 500; // Insertions in each transaction
|
||||
|
@ -32,17 +32,72 @@ no_arg_sql_function!(
|
|||
"Represents the SQL RANDOM() function"
|
||||
);
|
||||
|
||||
pub enum Command {
|
||||
enum Command {
|
||||
REINDEX,
|
||||
EXIT,
|
||||
}
|
||||
|
||||
#[derive(Debug, Queryable, QueryableByName, Serialize)]
|
||||
struct CommandReceiver {
|
||||
receiver: Receiver<Command>,
|
||||
}
|
||||
|
||||
impl CommandReceiver {
|
||||
fn new(receiver: Receiver<Command>) -> CommandReceiver {
|
||||
CommandReceiver { receiver }
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CommandSender {
|
||||
sender: Mutex<Sender<Command>>,
|
||||
}
|
||||
|
||||
impl CommandSender {
|
||||
fn new(sender: Sender<Command>) -> CommandSender {
|
||||
CommandSender {
|
||||
sender: Mutex::new(sender),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn trigger_reindex(&self) -> Result<(), errors::Error> {
|
||||
let sender = self.sender.lock().unwrap();
|
||||
match sender.send(Command::REINDEX) {
|
||||
Ok(_) => Ok(()),
|
||||
Err(_) => bail!("Trigger reindex channel error"),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn exit(&self) -> Result<(), errors::Error> {
|
||||
let sender = self.sender.lock().unwrap();
|
||||
match sender.send(Command::EXIT) {
|
||||
Ok(_) => Ok(()),
|
||||
Err(_) => bail!("Index exit channel error"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init(db: Arc<DB>) -> Arc<CommandSender> {
|
||||
let (index_sender, index_receiver) = channel();
|
||||
let command_sender = Arc::new(CommandSender::new(index_sender));
|
||||
let command_receiver = CommandReceiver::new(index_receiver);
|
||||
|
||||
// Start update loop
|
||||
let db_ref = db.clone();
|
||||
std::thread::spawn(move || {
|
||||
let db = db_ref.deref();
|
||||
update_loop(db, &command_receiver);
|
||||
});
|
||||
|
||||
command_sender
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Queryable, QueryableByName, Serialize, Deserialize)]
|
||||
#[table_name = "songs"]
|
||||
pub struct Song {
|
||||
#[serde(skip_serializing)]
|
||||
#[serde(skip_serializing, skip_deserializing)]
|
||||
id: i32,
|
||||
pub path: String,
|
||||
#[serde(skip_serializing)]
|
||||
#[serde(skip_serializing, skip_deserializing)]
|
||||
pub parent: String,
|
||||
pub track_number: Option<i32>,
|
||||
pub disc_number: Option<i32>,
|
||||
|
@ -55,12 +110,12 @@ pub struct Song {
|
|||
pub duration: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Queryable, Serialize)]
|
||||
#[derive(Debug, PartialEq, Queryable, Serialize, Deserialize)]
|
||||
pub struct Directory {
|
||||
#[serde(skip_serializing)]
|
||||
#[serde(skip_serializing, skip_deserializing)]
|
||||
id: i32,
|
||||
pub path: String,
|
||||
#[serde(skip_serializing)]
|
||||
#[serde(skip_serializing, skip_deserializing)]
|
||||
pub parent: Option<String>,
|
||||
pub artist: Option<String>,
|
||||
pub year: Option<i32>,
|
||||
|
@ -69,7 +124,7 @@ pub struct Directory {
|
|||
pub date_added: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[derive(Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub enum CollectionFile {
|
||||
Directory(Directory),
|
||||
Song(Song),
|
||||
|
@ -318,7 +373,8 @@ where
|
|||
.filter(|ref song_path| {
|
||||
let path = Path::new(&song_path);
|
||||
!path.exists() || vfs.real_to_virtual(path).is_err()
|
||||
}).collect::<Vec<_>>();
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
{
|
||||
let connection = db.get_connection();
|
||||
|
@ -343,7 +399,8 @@ where
|
|||
.filter(|ref directory_path| {
|
||||
let path = Path::new(&directory_path);
|
||||
!path.exists() || vfs.real_to_virtual(path).is_err()
|
||||
}).collect::<Vec<_>>();
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
{
|
||||
let connection = db.get_connection();
|
||||
|
@ -396,24 +453,21 @@ where
|
|||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update_loop<T>(db: &T, command_buffer: &Receiver<Command>)
|
||||
fn update_loop<T>(db: &T, command_buffer: &CommandReceiver)
|
||||
where
|
||||
T: ConnectionSource + VFSSource,
|
||||
{
|
||||
loop {
|
||||
// Wait for a command
|
||||
if let Err(e) = command_buffer.recv() {
|
||||
error!("Error while waiting on index command buffer: {}", e);
|
||||
if command_buffer.receiver.recv().is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Flush the buffer to ignore spammy requests
|
||||
loop {
|
||||
match command_buffer.try_recv() {
|
||||
Err(TryRecvError::Disconnected) => {
|
||||
error!("Error while flushing index command buffer");
|
||||
return;
|
||||
}
|
||||
match command_buffer.receiver.try_recv() {
|
||||
Err(TryRecvError::Disconnected) => return,
|
||||
Ok(Command::EXIT) => return,
|
||||
Err(TryRecvError::Empty) => break,
|
||||
Ok(_) => (),
|
||||
}
|
||||
|
@ -426,15 +480,14 @@ where
|
|||
}
|
||||
}
|
||||
|
||||
pub fn self_trigger<T>(db: &T, command_buffer: &Arc<Mutex<Sender<Command>>>)
|
||||
pub fn self_trigger<T>(db: &T, command_buffer: &Arc<CommandSender>)
|
||||
where
|
||||
T: ConnectionSource,
|
||||
{
|
||||
loop {
|
||||
{
|
||||
let command_buffer = command_buffer.lock().unwrap();
|
||||
let command_buffer = command_buffer.deref();
|
||||
if let Err(e) = command_buffer.send(Command::REINDEX) {
|
||||
if let Err(e) = command_buffer.trigger_reindex() {
|
||||
error!("Error while writing to index command buffer: {}", e);
|
||||
return;
|
||||
}
|
||||
|
@ -484,15 +537,16 @@ fn virtualize_directory(vfs: &VFS, mut directory: Directory) -> Option<Directory
|
|||
Some(directory)
|
||||
}
|
||||
|
||||
pub fn browse<T>(db: &T, virtual_path: &Path) -> Result<Vec<CollectionFile>, errors::Error>
|
||||
pub fn browse<T, P>(db: &T, virtual_path: P) -> Result<Vec<CollectionFile>, errors::Error>
|
||||
where
|
||||
T: ConnectionSource + VFSSource,
|
||||
P: AsRef<Path>,
|
||||
{
|
||||
let mut output = Vec::new();
|
||||
let vfs = db.get_vfs()?;
|
||||
let connection = db.get_connection();
|
||||
|
||||
if virtual_path.components().count() == 0 {
|
||||
if virtual_path.as_ref().components().count() == 0 {
|
||||
// Browse top-level
|
||||
let real_directories: Vec<Directory> = directories::table
|
||||
.filter(directories::parent.is_null())
|
||||
|
@ -528,15 +582,16 @@ where
|
|||
Ok(output)
|
||||
}
|
||||
|
||||
pub fn flatten<T>(db: &T, virtual_path: &Path) -> Result<Vec<Song>, errors::Error>
|
||||
pub fn flatten<T, P>(db: &T, virtual_path: P) -> Result<Vec<Song>, errors::Error>
|
||||
where
|
||||
T: ConnectionSource + VFSSource,
|
||||
P: AsRef<Path>,
|
||||
{
|
||||
use self::songs::dsl::*;
|
||||
let vfs = db.get_vfs()?;
|
||||
let connection = db.get_connection();
|
||||
|
||||
let real_songs: Vec<Song> = if virtual_path.parent() != None {
|
||||
let real_songs: Vec<Song> = if virtual_path.as_ref().parent() != None {
|
||||
let real_path = vfs.virtual_to_real(virtual_path)?;
|
||||
let like_path = real_path.as_path().to_string_lossy().into_owned() + "%";
|
||||
songs
|
||||
|
@ -623,7 +678,8 @@ where
|
|||
.or(album.like(&like_test))
|
||||
.or(artist.like(&like_test))
|
||||
.or(album_artist.like(&like_test)),
|
||||
).filter(parent.not_like(&like_test))
|
||||
)
|
||||
.filter(parent.not_like(&like_test))
|
||||
.load(connection.deref())?;
|
||||
|
||||
let virtual_songs = real_songs
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
use rustfm_scrobble::{Scrobble, Scrobbler};
|
||||
use std::path::Path;
|
||||
|
||||
use db::ConnectionSource;
|
||||
use errors;
|
||||
use index;
|
||||
use user;
|
||||
use vfs::VFSSource;
|
||||
use crate::db::ConnectionSource;
|
||||
use crate::errors;
|
||||
use crate::index;
|
||||
use crate::user;
|
||||
use crate::vfs::VFSSource;
|
||||
|
||||
const LASTFM_API_KEY: &str = "02b96c939a2b451c31dfd67add1f696e";
|
||||
const LASTFM_API_SECRET: &str = "0f25a80ceef4b470b5cb97d99d4b3420";
|
||||
|
@ -60,12 +60,7 @@ where
|
|||
let mut scrobbler = Scrobbler::new(LASTFM_API_KEY.into(), LASTFM_API_SECRET.into());
|
||||
let auth_response = scrobbler.authenticate_with_token(token.to_string())?;
|
||||
|
||||
user::lastfm_link(
|
||||
db,
|
||||
username,
|
||||
&auth_response.name,
|
||||
&auth_response.key,
|
||||
)
|
||||
user::lastfm_link(db, username, &auth_response.name, &auth_response.key)
|
||||
}
|
||||
|
||||
pub fn unlink<T>(db: &T, username: &str) -> Result<(), errors::Error>
|
||||
|
|
72
src/main.rs
72
src/main.rs
|
@ -1,4 +1,5 @@
|
|||
#![recursion_limit = "256"]
|
||||
#![feature(proc_macro_hygiene, decl_macro)]
|
||||
#![allow(proc_macro_derive_resolution_fallback)]
|
||||
|
||||
extern crate ape;
|
||||
|
@ -13,30 +14,24 @@ extern crate diesel_migrations;
|
|||
#[macro_use]
|
||||
extern crate error_chain;
|
||||
extern crate getopts;
|
||||
extern crate hyper;
|
||||
extern crate id3;
|
||||
extern crate image;
|
||||
extern crate iron;
|
||||
extern crate lewton;
|
||||
extern crate metaflac;
|
||||
extern crate mount;
|
||||
extern crate mp3_duration;
|
||||
extern crate params;
|
||||
extern crate rand;
|
||||
extern crate regex;
|
||||
extern crate reqwest;
|
||||
extern crate ring;
|
||||
extern crate router;
|
||||
#[macro_use]
|
||||
extern crate rocket;
|
||||
extern crate rocket_contrib;
|
||||
extern crate rustfm_scrobble;
|
||||
extern crate secure_session;
|
||||
extern crate serde;
|
||||
#[macro_use]
|
||||
extern crate serde_derive;
|
||||
extern crate serde_json;
|
||||
extern crate staticfile;
|
||||
extern crate toml;
|
||||
extern crate typemap;
|
||||
extern crate url;
|
||||
#[macro_use]
|
||||
extern crate log;
|
||||
extern crate simplelog;
|
||||
|
@ -57,17 +52,15 @@ use std::io::prelude::*;
|
|||
use unix_daemonize::{daemonize_redirect, ChdirMode};
|
||||
|
||||
use core::ops::Deref;
|
||||
use errors::*;
|
||||
use crate::errors::*;
|
||||
use getopts::Options;
|
||||
use iron::prelude::*;
|
||||
use mount::Mount;
|
||||
use simplelog::{Level, LevelFilter, SimpleLogger, TermLogger};
|
||||
use staticfile::Static;
|
||||
use std::path::Path;
|
||||
use std::sync::mpsc::channel;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::Arc;
|
||||
|
||||
mod api;
|
||||
#[cfg(test)]
|
||||
mod api_tests;
|
||||
mod config;
|
||||
mod db;
|
||||
mod ddns;
|
||||
|
@ -77,6 +70,7 @@ mod lastfm;
|
|||
mod metadata;
|
||||
mod playlist;
|
||||
mod serve;
|
||||
mod server;
|
||||
mod thumbnails;
|
||||
mod ui;
|
||||
mod user;
|
||||
|
@ -204,6 +198,7 @@ fn run() -> Result<()> {
|
|||
let db = Arc::new(db::DB::new(&db_path)?);
|
||||
|
||||
// Parse config
|
||||
info!("Parsing configuration");
|
||||
let config_file_name = matches.opt_str("c");
|
||||
let config_file_path = config_file_name.map(|p| Path::new(p.as_str()).to_path_buf());
|
||||
if let Some(path) = config_file_path {
|
||||
|
@ -213,30 +208,22 @@ fn run() -> Result<()> {
|
|||
let config = config::read(db.deref())?;
|
||||
|
||||
// Init index
|
||||
let (index_sender, index_receiver) = channel();
|
||||
let index_sender = Arc::new(Mutex::new(index_sender));
|
||||
let db_ref = db.clone();
|
||||
std::thread::spawn(move || {
|
||||
let db = db_ref.deref();
|
||||
index::update_loop(db, &index_receiver);
|
||||
});
|
||||
info!("Initializing index");
|
||||
let command_sender = index::init(db.clone());
|
||||
|
||||
// Trigger auto-indexing
|
||||
let db_ref = db.clone();
|
||||
let sender_ref = index_sender.clone();
|
||||
let db_auto_index = db.clone();
|
||||
let command_sender_auto_index = command_sender.clone();
|
||||
std::thread::spawn(move || {
|
||||
index::self_trigger(db_ref.deref(), &sender_ref);
|
||||
index::self_trigger(db_auto_index.deref(), &command_sender_auto_index);
|
||||
});
|
||||
|
||||
// Mount API
|
||||
// API mount target
|
||||
let prefix_url = config.prefix_url.unwrap_or_else(|| "".to_string());
|
||||
let api_url = format!("{}/api", &prefix_url);
|
||||
info!("Mounting API on {}", api_url);
|
||||
let mut mount = Mount::new();
|
||||
let handler = api::get_handler(&db.clone(), &index_sender)?;
|
||||
mount.mount(&api_url, handler);
|
||||
|
||||
// Mount static files
|
||||
// Static files mount target
|
||||
let web_dir_name = matches.opt_str("w");
|
||||
let mut default_web_dir = utils::get_data_root()?;
|
||||
default_web_dir.push("web");
|
||||
|
@ -246,8 +233,8 @@ fn run() -> Result<()> {
|
|||
info!("Static files location is {}", web_dir_path.display());
|
||||
let static_url = format!("/{}", &prefix_url);
|
||||
info!("Mounting static files on {}", static_url);
|
||||
mount.mount(&static_url, Static::new(web_dir_path));
|
||||
|
||||
// Start server
|
||||
info!("Starting up server");
|
||||
let port: u16 = matches
|
||||
.opt_str("p")
|
||||
|
@ -255,24 +242,27 @@ fn run() -> Result<()> {
|
|||
.parse()
|
||||
.or(Err("invalid port number"))?;
|
||||
|
||||
let mut server = match Iron::new(mount).http(("0.0.0.0", port)) {
|
||||
Ok(s) => s,
|
||||
Err(e) => bail!("Error starting up server: {}", e),
|
||||
};
|
||||
let server = server::get_server(
|
||||
port,
|
||||
&static_url,
|
||||
&api_url,
|
||||
&web_dir_path,
|
||||
db.clone(),
|
||||
command_sender,
|
||||
)?;
|
||||
std::thread::spawn(move || {
|
||||
server.launch();
|
||||
});
|
||||
|
||||
// Start DDNS updates
|
||||
let db_ref = db.clone();
|
||||
let db_ddns = db.clone();
|
||||
std::thread::spawn(move || {
|
||||
ddns::run(db_ref.deref());
|
||||
ddns::run(db_ddns.deref());
|
||||
});
|
||||
|
||||
// Run UI
|
||||
ui::run();
|
||||
|
||||
info!("Shutting down server");
|
||||
if let Err(e) = server.close() {
|
||||
bail!("Error shutting down server: {}", e);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
@ -7,9 +7,9 @@ use regex::Regex;
|
|||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use errors::*;
|
||||
use utils;
|
||||
use utils::AudioFormat;
|
||||
use crate::errors::*;
|
||||
use crate::utils;
|
||||
use crate::utils::AudioFormat;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct SongTags {
|
||||
|
|
|
@ -7,12 +7,12 @@ use diesel::BelongingToDsl;
|
|||
use std::path::Path;
|
||||
|
||||
#[cfg(test)]
|
||||
use db;
|
||||
use db::ConnectionSource;
|
||||
use db::{playlist_songs, playlists, users};
|
||||
use errors::*;
|
||||
use index::{self, Song};
|
||||
use vfs::VFSSource;
|
||||
use crate::db;
|
||||
use crate::db::ConnectionSource;
|
||||
use crate::db::{playlist_songs, playlists, users};
|
||||
use crate::errors::*;
|
||||
use crate::index::{self, Song};
|
||||
use crate::vfs::VFSSource;
|
||||
|
||||
#[derive(Insertable)]
|
||||
#[table_name = "playlists"]
|
||||
|
@ -262,7 +262,7 @@ fn test_delete_playlist() {
|
|||
|
||||
#[test]
|
||||
fn test_fill_playlist() {
|
||||
use index;
|
||||
use crate::index;
|
||||
|
||||
let db = db::_get_test_db("fill_playlist.sqlite");
|
||||
index::update(&db).unwrap();
|
||||
|
|
238
src/serve.rs
238
src/serve.rs
|
@ -1,53 +1,15 @@
|
|||
use iron::headers::{
|
||||
AcceptRanges, ByteRangeSpec, ContentLength, ContentRange, ContentRangeSpec, Range, RangeUnit,
|
||||
};
|
||||
use iron::modifier::Modifier;
|
||||
use iron::modifiers::Header;
|
||||
use iron::prelude::*;
|
||||
use iron::response::WriteBody;
|
||||
use iron::status::{self, Status};
|
||||
use rocket;
|
||||
use rocket::http::hyper::header::*;
|
||||
use rocket::http::Status;
|
||||
use rocket::Response;
|
||||
use rocket::response::{self, Responder};
|
||||
use std::cmp;
|
||||
use std::fs::{self, File};
|
||||
use std::io::{self, Read, Seek, SeekFrom, Write};
|
||||
use std::path::Path;
|
||||
|
||||
use errors::{Error, ErrorKind};
|
||||
|
||||
pub fn deliver(path: &Path, range_header: Option<&Range>) -> IronResult<Response> {
|
||||
match fs::metadata(path) {
|
||||
Ok(meta) => meta,
|
||||
Err(e) => {
|
||||
let status = match e.kind() {
|
||||
io::ErrorKind::NotFound => status::NotFound,
|
||||
io::ErrorKind::PermissionDenied => status::Forbidden,
|
||||
_ => status::InternalServerError,
|
||||
};
|
||||
return Err(IronError::new(e, status));
|
||||
}
|
||||
};
|
||||
|
||||
let accept_range_header = Header(AcceptRanges(vec![RangeUnit::Bytes]));
|
||||
let range_header = range_header.cloned();
|
||||
|
||||
match range_header {
|
||||
None => Ok(Response::with((status::Ok, path, accept_range_header))),
|
||||
Some(range) => match range {
|
||||
Range::Bytes(vec_range) => {
|
||||
if let Ok(partial_file) = PartialFile::from_path(path, vec_range) {
|
||||
Ok(Response::with((
|
||||
status::Ok,
|
||||
partial_file,
|
||||
accept_range_header,
|
||||
)))
|
||||
} else {
|
||||
Err(Error::from(ErrorKind::FileNotFound).into())
|
||||
}
|
||||
}
|
||||
_ => Ok(Response::with(status::RangeNotSatisfiable)),
|
||||
},
|
||||
}
|
||||
}
|
||||
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),
|
||||
|
@ -64,11 +26,6 @@ impl From<ByteRangeSpec> for PartialFileRange {
|
|||
}
|
||||
}
|
||||
|
||||
pub struct PartialFile {
|
||||
file: File,
|
||||
range: PartialFileRange,
|
||||
}
|
||||
|
||||
impl From<Vec<ByteRangeSpec>> for PartialFileRange {
|
||||
fn from(v: Vec<ByteRangeSpec>) -> PartialFileRange {
|
||||
match v.into_iter().next() {
|
||||
|
@ -78,90 +35,119 @@ impl From<Vec<ByteRangeSpec>> for PartialFileRange {
|
|||
}
|
||||
}
|
||||
|
||||
impl PartialFile {
|
||||
pub fn new<Range>(file: File, range: Range) -> PartialFile
|
||||
where
|
||||
Range: Into<PartialFileRange>,
|
||||
{
|
||||
let range = range.into();
|
||||
PartialFile { file, range }
|
||||
pub struct RangeResponder<R> {
|
||||
original: R,
|
||||
}
|
||||
|
||||
impl<'r, R: Responder<'r>> RangeResponder<R> {
|
||||
pub fn new(original: R) -> RangeResponder<R> {
|
||||
RangeResponder { original }
|
||||
}
|
||||
|
||||
pub fn from_path<P: AsRef<Path>, Range>(path: P, range: Range) -> Result<PartialFile, io::Error>
|
||||
where
|
||||
Range: Into<PartialFileRange>,
|
||||
{
|
||||
let file = File::open(path.as_ref())?;
|
||||
Ok(Self::new(file, range))
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
impl Modifier<Response> for PartialFile {
|
||||
fn modify(self, res: &mut Response) {
|
||||
use self::PartialFileRange::*;
|
||||
let metadata: Option<_> = self.file.metadata().ok();
|
||||
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: Option<(u64, u64)> = match (self.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,
|
||||
|
||||
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,
|
||||
};
|
||||
if let Some(range) = range {
|
||||
|
||||
let vec_range = match Range::from_str(range_header) {
|
||||
Ok(Range::Bytes(v)) => v,
|
||||
_ => {
|
||||
warn!("Ignoring range header that could not be parse {:?}, 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: Some(range),
|
||||
range: range,
|
||||
instance_length: file_length,
|
||||
});
|
||||
let content_len = range.1 - range.0 + 1;
|
||||
res.headers.set(ContentLength(content_len));
|
||||
res.headers.set(content_range);
|
||||
let partial_content = PartialContentBody {
|
||||
file: self.file,
|
||||
offset: range.0,
|
||||
len: content_len,
|
||||
};
|
||||
res.status = Some(Status::PartialContent);
|
||||
res.body = Some(Box::new(partial_content));
|
||||
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 {
|
||||
if let Some(file_length) = file_length {
|
||||
res.headers.set(ContentRange(ContentRangeSpec::Bytes {
|
||||
range: None,
|
||||
instance_length: Some(file_length),
|
||||
}));
|
||||
};
|
||||
res.status = Some(Status::RangeNotSatisfiable);
|
||||
warn!("Rejecting unsatisfiable range header {:?}, file length is {:?}", &partial_file_range, &file_length);
|
||||
self.reject_range(file_length)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PartialContentBody {
|
||||
pub file: File,
|
||||
pub offset: u64,
|
||||
pub len: u64,
|
||||
}
|
||||
|
||||
impl WriteBody for PartialContentBody {
|
||||
fn write_body(&mut self, res: &mut Write) -> io::Result<()> {
|
||||
self.file.seek(SeekFrom::Start(self.offset))?;
|
||||
let mut limiter = <File as Read>::by_ref(&mut self.file).take(self.len);
|
||||
io::copy(&mut limiter, res).map(|_| ())
|
||||
}
|
||||
}
|
||||
|
|
28
src/server.rs
Normal file
28
src/server.rs
Normal file
|
@ -0,0 +1,28 @@
|
|||
use rocket;
|
||||
use rocket_contrib::serve::StaticFiles;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::api;
|
||||
use crate::db::DB;
|
||||
use crate::errors;
|
||||
use crate::index::CommandSender;
|
||||
|
||||
pub fn get_server(
|
||||
port: u16,
|
||||
static_url: &str,
|
||||
api_url: &str,
|
||||
web_dir_path: &PathBuf,
|
||||
db: Arc<DB>,
|
||||
command_sender: Arc<CommandSender>,
|
||||
) -> Result<rocket::Rocket, errors::Error> {
|
||||
let config = rocket::Config::build(rocket::config::Environment::Production)
|
||||
.port(port)
|
||||
.finalize()?;
|
||||
|
||||
Ok(rocket::custom(config)
|
||||
.manage(db)
|
||||
.manage(command_sender)
|
||||
.mount(&static_url, StaticFiles::from(web_dir_path))
|
||||
.mount(&api_url, api::get_routes()))
|
||||
}
|
|
@ -11,8 +11,8 @@ use std::fs::{DirBuilder, File};
|
|||
use std::hash::{Hash, Hasher};
|
||||
use std::path::*;
|
||||
|
||||
use errors::*;
|
||||
use utils;
|
||||
use crate::errors::*;
|
||||
use crate::utils;
|
||||
|
||||
const THUMBNAILS_PATH: &str = "thumbnails";
|
||||
|
||||
|
|
24
src/user.rs
24
src/user.rs
|
@ -4,9 +4,9 @@ use diesel::prelude::*;
|
|||
use rand;
|
||||
use ring::{digest, pbkdf2};
|
||||
|
||||
use db::users;
|
||||
use db::ConnectionSource;
|
||||
use errors::*;
|
||||
use crate::db::users;
|
||||
use crate::db::ConnectionSource;
|
||||
use crate::errors::*;
|
||||
|
||||
#[derive(Debug, Insertable, Queryable)]
|
||||
#[table_name = "users"]
|
||||
|
@ -58,14 +58,15 @@ fn verify_password(
|
|||
password_salt,
|
||||
attempted_password.as_bytes(),
|
||||
password_hash,
|
||||
).is_ok()
|
||||
)
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
pub fn auth<T>(db: &T, username: &str, password: &str) -> Result<bool>
|
||||
where
|
||||
T: ConnectionSource,
|
||||
{
|
||||
use db::users::dsl::*;
|
||||
use crate::db::users::dsl::*;
|
||||
let connection = db.get_connection();
|
||||
match users
|
||||
.select((password_hash, password_salt))
|
||||
|
@ -82,7 +83,7 @@ pub fn count<T>(db: &T) -> Result<i64>
|
|||
where
|
||||
T: ConnectionSource,
|
||||
{
|
||||
use db::users::dsl::*;
|
||||
use crate::db::users::dsl::*;
|
||||
let connection = db.get_connection();
|
||||
let count = users.count().get_result(connection.deref())?;
|
||||
Ok(count)
|
||||
|
@ -92,7 +93,7 @@ pub fn is_admin<T>(db: &T, username: &str) -> Result<bool>
|
|||
where
|
||||
T: ConnectionSource,
|
||||
{
|
||||
use db::users::dsl::*;
|
||||
use crate::db::users::dsl::*;
|
||||
let connection = db.get_connection();
|
||||
let is_admin: i32 = users
|
||||
.filter(name.eq(username))
|
||||
|
@ -105,13 +106,14 @@ pub fn lastfm_link<T>(db: &T, username: &str, lastfm_login: &str, session_key: &
|
|||
where
|
||||
T: ConnectionSource,
|
||||
{
|
||||
use db::users::dsl::*;
|
||||
use crate::db::users::dsl::*;
|
||||
let connection = db.get_connection();
|
||||
diesel::update(users.filter(name.eq(username)))
|
||||
.set((
|
||||
lastfm_username.eq(lastfm_login),
|
||||
lastfm_session_key.eq(session_key),
|
||||
)).execute(connection.deref())?;
|
||||
))
|
||||
.execute(connection.deref())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
@ -119,7 +121,7 @@ pub fn get_lastfm_session_key<T>(db: &T, username: &str) -> Result<String>
|
|||
where
|
||||
T: ConnectionSource,
|
||||
{
|
||||
use db::users::dsl::*;
|
||||
use crate::db::users::dsl::*;
|
||||
let connection = db.get_connection();
|
||||
let token = users
|
||||
.filter(name.eq(username))
|
||||
|
@ -135,7 +137,7 @@ pub fn lastfm_unlink<T>(db: &T, username: &str) -> Result<()>
|
|||
where
|
||||
T: ConnectionSource,
|
||||
{
|
||||
use db::users::dsl::*;
|
||||
use crate::db::users::dsl::*;
|
||||
let connection = db.get_connection();
|
||||
diesel::update(users.filter(name.eq(username)))
|
||||
.set((lastfm_session_key.eq(""), lastfm_username.eq("")))
|
||||
|
|
12
src/utils.rs
12
src/utils.rs
|
@ -2,7 +2,7 @@ use app_dirs::{app_root, AppDataType, AppInfo};
|
|||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use errors::*;
|
||||
use crate::errors::*;
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
const APP_INFO: AppInfo = AppInfo {
|
||||
|
@ -64,16 +64,6 @@ fn test_get_audio_format() {
|
|||
);
|
||||
}
|
||||
|
||||
pub fn is_song(path: &Path) -> bool {
|
||||
get_audio_format(path).is_some()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_song() {
|
||||
assert!(is_song(Path::new("animals/🐷/my🐖file.mp3")));
|
||||
assert!(!is_song(Path::new("animals/🐷/my🐖file.jpg")));
|
||||
}
|
||||
|
||||
pub fn is_image(path: &Path) -> bool {
|
||||
let extension = match path.extension() {
|
||||
Some(e) => e,
|
||||
|
|
14
src/vfs.rs
14
src/vfs.rs
|
@ -4,9 +4,9 @@ use std::collections::HashMap;
|
|||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use db::mount_points;
|
||||
use db::{ConnectionSource, DB};
|
||||
use errors::*;
|
||||
use crate::db::mount_points;
|
||||
use crate::db::{ConnectionSource, DB};
|
||||
use crate::errors::*;
|
||||
|
||||
pub trait VFSSource {
|
||||
fn get_vfs(&self) -> Result<VFS>;
|
||||
|
@ -51,9 +51,9 @@ impl VFS {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
pub fn real_to_virtual(&self, real_path: &Path) -> Result<PathBuf> {
|
||||
pub fn real_to_virtual<P: AsRef<Path>>(&self, real_path: P) -> Result<PathBuf> {
|
||||
for (name, target) in &self.mount_points {
|
||||
if let Ok(p) = real_path.strip_prefix(target) {
|
||||
if let Ok(p) = real_path.as_ref().strip_prefix(target) {
|
||||
let mount_path = Path::new(&name);
|
||||
return if p.components().count() == 0 {
|
||||
Ok(mount_path.to_path_buf())
|
||||
|
@ -65,10 +65,10 @@ impl VFS {
|
|||
bail!("Real path has no match in VFS")
|
||||
}
|
||||
|
||||
pub fn virtual_to_real(&self, virtual_path: &Path) -> Result<PathBuf> {
|
||||
pub fn virtual_to_real<P: AsRef<Path>>(&self, virtual_path: P) -> Result<PathBuf> {
|
||||
for (name, target) in &self.mount_points {
|
||||
let mount_path = Path::new(&name);
|
||||
if let Ok(p) = virtual_path.strip_prefix(mount_path) {
|
||||
if let Ok(p) = virtual_path.as_ref().strip_prefix(mount_path) {
|
||||
return if p.components().count() == 0 {
|
||||
Ok(target.clone())
|
||||
} else {
|
||||
|
|
2
web
2
web
|
@ -1 +1 @@
|
|||
Subproject commit 484a1099806c0805964d19006c6128c75384bb30
|
||||
Subproject commit 8cc2cbaee0c5ede96a6c0b66ce1c2c36a94473bc
|
Loading…
Reference in a new issue