Merge 1d57691e8b
into release
This commit is contained in:
commit
a8c7509657
58 changed files with 4598 additions and 2462 deletions
.github/workflows
Cargo.lockCargo.tomldocs/swagger
migrations/201706272304_misc_settings_table
rust-toolchainsrc
test-data
8
.github/workflows/build.yml
vendored
8
.github/workflows/build.yml
vendored
|
@ -10,15 +10,15 @@ jobs:
|
|||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
features: [--all-features, --features default, --features "service-rocket" --no-default-features]
|
||||
os: [ubuntu-latest, windows-latest, macOS-latest]
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
features: [--no-default-features, --features bundle-sqlite, --features ui]
|
||||
exclude:
|
||||
- os: windows-latest
|
||||
features: --features "service-rocket" --no-default-features
|
||||
features: --no-default-features
|
||||
|
||||
steps:
|
||||
- name: Install libsqlite3-dev
|
||||
if: contains(matrix.os, 'ubuntu')
|
||||
if: contains(matrix.os, 'ubuntu') && !contains(matrix.features, 'bundle-sqlite')
|
||||
run: sudo apt-get update && sudo apt-get install libsqlite3-dev
|
||||
- uses: actions/checkout@v1
|
||||
- uses: actions-rs/toolchain@v1
|
||||
|
|
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
|
@ -24,7 +24,7 @@ jobs:
|
|||
uses: actions/checkout@master
|
||||
with:
|
||||
ref: release
|
||||
- name: Update Version in Cargo.toml
|
||||
- name: Update Polaris Version in Cargo.toml
|
||||
run: gawk -i inplace '/^version/ { if (count == 0) { $3 = "\"${{ github.event.inputs.versionNumber }}\""; count++ } } 1' Cargo.toml
|
||||
- name: Commit Cargo.toml Version Change
|
||||
uses: EndBug/add-and-commit@v5
|
||||
|
|
1557
Cargo.lock
generated
1557
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
27
Cargo.toml
27
Cargo.toml
|
@ -5,21 +5,26 @@ authors = ["Antoine Gersant <antoine.gersant@lesforges.org>"]
|
|||
edition = "2018"
|
||||
|
||||
[features]
|
||||
default = ["service-rocket", "bundle-sqlite"]
|
||||
ui = ["uuid", "winapi"]
|
||||
service-rocket = ["rocket", "rocket_contrib"]
|
||||
default = ["bundle-sqlite"]
|
||||
bundle-sqlite = ["libsqlite3-sys"]
|
||||
ui = ["uuid", "winapi"]
|
||||
|
||||
[dependencies]
|
||||
actix-files = { version = "0.4" }
|
||||
actix-web = { version = "3" }
|
||||
actix-web-httpauth = { version = "0.5.0" }
|
||||
anyhow = "1.0.35"
|
||||
ape = "0.3.0"
|
||||
base64 = "0.13"
|
||||
branca = "0.10.0"
|
||||
cookie = { version = "0.14", features = ["signed", "key-expansion"] }
|
||||
crossbeam-channel = "0.5"
|
||||
diesel_migrations = { version = "1.4", features = ["sqlite"] }
|
||||
futures-util = { version = "0.3" }
|
||||
getopts = "0.2.15"
|
||||
http = "0.2.2"
|
||||
id3 = "0.5.1"
|
||||
libsqlite3-sys = { version = "0.18", features = ["bundled", "bundled-windows"], optional = true }
|
||||
rustfm-scrobble = "1.1"
|
||||
lewton = "0.10.1"
|
||||
log = "0.4.5"
|
||||
metaflac = "0.2.3"
|
||||
|
@ -27,17 +32,18 @@ mp3-duration = "0.1.9"
|
|||
mp4ameta = "0.7.1"
|
||||
num_cpus = "1.13.0"
|
||||
opus_headers = "0.1.2"
|
||||
percent-encoding = "2.1"
|
||||
pbkdf2 = "0.6"
|
||||
rand = "0.7"
|
||||
rayon = "1.3"
|
||||
regex = "1.3.9"
|
||||
rocket = { version = "0.4.5", optional = true }
|
||||
rustfm-scrobble = "1.1"
|
||||
serde = { version = "1.0.111", features = ["derive"] }
|
||||
serde_derive = "1.0.111"
|
||||
serde_json = "1.0.53"
|
||||
simplelog = "0.8.0"
|
||||
thiserror = "1.0.19"
|
||||
time = "0.1"
|
||||
time = "0.2"
|
||||
toml = "0.5"
|
||||
ureq = "1.5"
|
||||
url = "2.1"
|
||||
|
@ -52,12 +58,6 @@ version = "0.23.12"
|
|||
default_features = false
|
||||
features = ["bmp", "gif", "jpeg", "png"]
|
||||
|
||||
[dependencies.rocket_contrib]
|
||||
version = "0.4.5"
|
||||
default_features = false
|
||||
features = ["json", "serve"]
|
||||
optional = true
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
uuid = { version="0.8", optional = true }
|
||||
winapi = { version = "0.3.3", features = ["winuser", "libloaderapi", "shellapi", "errhandlingapi"], optional = true }
|
||||
|
@ -67,7 +67,4 @@ sd-notify = "0.1.0"
|
|||
unix-daemonize = "0.1.2"
|
||||
|
||||
[dev-dependencies]
|
||||
percent-encoding = "2.1"
|
||||
cookie = "0.14.0"
|
||||
http = "0.2.1"
|
||||
headers = "0.3"
|
||||
|
|
|
@ -16,18 +16,22 @@
|
|||
"name": "Collection",
|
||||
"description": "Browsing the music collection"
|
||||
},
|
||||
{
|
||||
"name": "Configuration",
|
||||
"description": "Managing the polaris installation"
|
||||
},
|
||||
{
|
||||
"name": "Last.fm",
|
||||
"description": "Integrating with Last.fm"
|
||||
},
|
||||
{
|
||||
"name": "Settings",
|
||||
"description": "Managing the polaris installation"
|
||||
},
|
||||
{
|
||||
"name": "Playlists",
|
||||
"description": "Managing playlists"
|
||||
},
|
||||
{
|
||||
"name": "Users",
|
||||
"description": "Managing user accounts"
|
||||
},
|
||||
{
|
||||
"name": "Other"
|
||||
}
|
||||
|
@ -89,44 +93,21 @@
|
|||
},
|
||||
"security": [
|
||||
{
|
||||
"admin_http_header": [],
|
||||
"admin_http_basic": [],
|
||||
"admin_http_bearer": [],
|
||||
"admin_query_parameter": [],
|
||||
"admin_cookie": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/settings": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Settings"
|
||||
],
|
||||
"summary": "Reads the existing server configuration",
|
||||
"operationId": "getSettings",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful operation",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#components/schemas/Config"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"admin_http_header": [],
|
||||
"admin_cookie": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"/config": {
|
||||
"put": {
|
||||
"tags": [
|
||||
"Settings"
|
||||
"Configuration"
|
||||
],
|
||||
"summary": "Overwrites the server configuration",
|
||||
"operationId": "getSettings",
|
||||
"summary": "Amends the server settings, mount directories and list of users",
|
||||
"operationId": "putConfig",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
|
@ -144,7 +125,265 @@
|
|||
},
|
||||
"security": [
|
||||
{
|
||||
"admin_http_header": [],
|
||||
"admin_http_basic": [],
|
||||
"admin_http_bearer": [],
|
||||
"admin_query_parameter": [],
|
||||
"admin_cookie": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/mount_dirs": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Configuration"
|
||||
],
|
||||
"summary": "Reads the existing collection mount directories",
|
||||
"operationId": "getMountDirs",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful operation",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"$ref": "#components/schemas/MountDir"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"admin_http_basic": [],
|
||||
"admin_http_bearer": [],
|
||||
"admin_query_parameter": [],
|
||||
"admin_cookie": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"put": {
|
||||
"tags": [
|
||||
"Configuration"
|
||||
],
|
||||
"summary": "Replaces the list collection mount directories",
|
||||
"operationId": "putMountDirs",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"$ref": "#components/schemas/MountDir"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful operation"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"admin_http_basic": [],
|
||||
"admin_http_bearer": [],
|
||||
"admin_query_parameter": [],
|
||||
"admin_cookie": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/settings": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Configuration"
|
||||
],
|
||||
"summary": "Reads the existing server settings",
|
||||
"operationId": "getSettings",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful operation",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#components/schemas/Settings"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"admin_http_basic": [],
|
||||
"admin_http_bearer": [],
|
||||
"admin_query_parameter": [],
|
||||
"admin_cookie": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"put": {
|
||||
"tags": [
|
||||
"Configuration"
|
||||
],
|
||||
"summary": "Amends the server settings",
|
||||
"operationId": "putSettings",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#components/schemas/Settings"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful operation"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"admin_http_basic": [],
|
||||
"admin_http_bearer": [],
|
||||
"admin_query_parameter": [],
|
||||
"admin_cookie": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/users": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Users"
|
||||
],
|
||||
"summary": "List existing user accounts",
|
||||
"operationId": "getUsers",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful operation",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/User"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"admin_http_basic": [],
|
||||
"admin_http_bearer": [],
|
||||
"admin_query_parameter": [],
|
||||
"admin_cookie": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/user": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Users"
|
||||
],
|
||||
"summary": "Creates a new user account",
|
||||
"operationId": "postUser",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#components/schemas/NewUser"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful operation"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"admin_http_basic": [],
|
||||
"admin_http_bearer": [],
|
||||
"admin_query_parameter": [],
|
||||
"admin_cookie": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/user/{name}": {
|
||||
"put": {
|
||||
"tags": [
|
||||
"Users"
|
||||
],
|
||||
"summary": "Updates properties of an existing user",
|
||||
"operationId": "putUserName",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "name",
|
||||
"in": "path",
|
||||
"description": "Name of the affected user",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#components/schemas/UserUpdate"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful operation"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"admin_http_basic": [],
|
||||
"admin_http_bearer": [],
|
||||
"admin_query_parameter": [],
|
||||
"admin_cookie": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"delete": {
|
||||
"tags": [
|
||||
"Users"
|
||||
],
|
||||
"summary": "Deletes an existing user",
|
||||
"operationId": "deleteUserName",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "name",
|
||||
"in": "path",
|
||||
"description": "Name of the affected user",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful operation"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"admin_http_basic": [],
|
||||
"admin_http_bearer": [],
|
||||
"admin_query_parameter": [],
|
||||
"admin_cookie": []
|
||||
}
|
||||
]
|
||||
|
@ -153,7 +392,7 @@
|
|||
"/preferences": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Settings"
|
||||
"Users"
|
||||
],
|
||||
"summary": "Reads the preferences of the current user",
|
||||
"operationId": "getPreferences",
|
||||
|
@ -171,7 +410,39 @@
|
|||
},
|
||||
"security": [
|
||||
{
|
||||
"auth_http_header": [],
|
||||
"auth_http_basic": [],
|
||||
"auth_http_bearer": [],
|
||||
"auth_query_parameter": [],
|
||||
"auth_cookie": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"put": {
|
||||
"tags": [
|
||||
"Users"
|
||||
],
|
||||
"summary": "Saves the preferences of the current user",
|
||||
"operationId": "putPreferences",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#components/schemas/Preferences"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful operation"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"auth_http_basic": [],
|
||||
"auth_http_bearer": [],
|
||||
"auth_query_parameter": [],
|
||||
"auth_cookie": []
|
||||
}
|
||||
]
|
||||
|
@ -180,7 +451,7 @@
|
|||
"/auth": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Other"
|
||||
"Users"
|
||||
],
|
||||
"summary": "Signs in a user. Response has Set-Cookie headers for the session, username and admin permission of the user.",
|
||||
"operationId": "postAuth",
|
||||
|
@ -189,14 +460,21 @@
|
|||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#components/schemas/AuthCredentials"
|
||||
"$ref": "#components/schemas/Credentials"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful operation"
|
||||
"description": "Successful operation",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Authorization"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Invalid credentials"
|
||||
|
@ -228,7 +506,9 @@
|
|||
},
|
||||
"security": [
|
||||
{
|
||||
"auth_http_header": [],
|
||||
"auth_http_basic": [],
|
||||
"auth_http_bearer": [],
|
||||
"auth_query_parameter": [],
|
||||
"auth_cookie": []
|
||||
}
|
||||
]
|
||||
|
@ -268,7 +548,9 @@
|
|||
},
|
||||
"security": [
|
||||
{
|
||||
"auth_http_header": [],
|
||||
"auth_http_basic": [],
|
||||
"auth_http_bearer": [],
|
||||
"auth_query_parameter": [],
|
||||
"auth_cookie": []
|
||||
}
|
||||
]
|
||||
|
@ -298,7 +580,9 @@
|
|||
},
|
||||
"security": [
|
||||
{
|
||||
"auth_http_header": [],
|
||||
"auth_http_basic": [],
|
||||
"auth_http_bearer": [],
|
||||
"auth_query_parameter": [],
|
||||
"auth_cookie": []
|
||||
}
|
||||
]
|
||||
|
@ -338,7 +622,9 @@
|
|||
},
|
||||
"security": [
|
||||
{
|
||||
"auth_http_header": [],
|
||||
"auth_http_basic": [],
|
||||
"auth_http_bearer": [],
|
||||
"auth_query_parameter": [],
|
||||
"auth_cookie": []
|
||||
}
|
||||
]
|
||||
|
@ -368,7 +654,9 @@
|
|||
},
|
||||
"security": [
|
||||
{
|
||||
"auth_http_header": [],
|
||||
"auth_http_basic": [],
|
||||
"auth_http_bearer": [],
|
||||
"auth_query_parameter": [],
|
||||
"auth_cookie": []
|
||||
}
|
||||
]
|
||||
|
@ -398,7 +686,9 @@
|
|||
},
|
||||
"security": [
|
||||
{
|
||||
"auth_http_header": [],
|
||||
"auth_http_basic": [],
|
||||
"auth_http_bearer": [],
|
||||
"auth_query_parameter": [],
|
||||
"auth_cookie": []
|
||||
}
|
||||
]
|
||||
|
@ -438,7 +728,9 @@
|
|||
},
|
||||
"security": [
|
||||
{
|
||||
"auth_http_header": [],
|
||||
"auth_http_basic": [],
|
||||
"auth_http_bearer": [],
|
||||
"auth_query_parameter": [],
|
||||
"auth_cookie": []
|
||||
}
|
||||
]
|
||||
|
@ -475,7 +767,9 @@
|
|||
},
|
||||
"security": [
|
||||
{
|
||||
"auth_http_header": [],
|
||||
"auth_http_basic": [],
|
||||
"auth_http_bearer": [],
|
||||
"auth_query_parameter": [],
|
||||
"auth_cookie": []
|
||||
}
|
||||
]
|
||||
|
@ -521,7 +815,9 @@
|
|||
},
|
||||
"security": [
|
||||
{
|
||||
"auth_http_header": [],
|
||||
"auth_http_basic": [],
|
||||
"auth_http_bearer": [],
|
||||
"auth_query_parameter": [],
|
||||
"auth_cookie": []
|
||||
}
|
||||
]
|
||||
|
@ -551,7 +847,9 @@
|
|||
},
|
||||
"security": [
|
||||
{
|
||||
"auth_http_header": [],
|
||||
"auth_http_basic": [],
|
||||
"auth_http_bearer": [],
|
||||
"auth_query_parameter": [],
|
||||
"auth_cookie": []
|
||||
}
|
||||
]
|
||||
|
@ -591,7 +889,9 @@
|
|||
},
|
||||
"security": [
|
||||
{
|
||||
"auth_http_header": [],
|
||||
"auth_http_basic": [],
|
||||
"auth_http_bearer": [],
|
||||
"auth_query_parameter": [],
|
||||
"auth_cookie": []
|
||||
}
|
||||
]
|
||||
|
@ -629,7 +929,9 @@
|
|||
},
|
||||
"security": [
|
||||
{
|
||||
"auth_http_header": [],
|
||||
"auth_http_basic": [],
|
||||
"auth_http_bearer": [],
|
||||
"auth_query_parameter": [],
|
||||
"auth_cookie": []
|
||||
}
|
||||
]
|
||||
|
@ -657,7 +959,9 @@
|
|||
},
|
||||
"security": [
|
||||
{
|
||||
"auth_http_header": [],
|
||||
"auth_http_basic": [],
|
||||
"auth_http_bearer": [],
|
||||
"auth_query_parameter": [],
|
||||
"auth_cookie": []
|
||||
}
|
||||
]
|
||||
|
@ -687,7 +991,9 @@
|
|||
},
|
||||
"security": [
|
||||
{
|
||||
"auth_http_header": [],
|
||||
"auth_http_basic": [],
|
||||
"auth_http_bearer": [],
|
||||
"auth_query_parameter": [],
|
||||
"auth_cookie": []
|
||||
}
|
||||
]
|
||||
|
@ -717,7 +1023,38 @@
|
|||
},
|
||||
"security": [
|
||||
{
|
||||
"auth_http_header": [],
|
||||
"auth_http_basic": [],
|
||||
"auth_http_bearer": [],
|
||||
"auth_query_parameter": [],
|
||||
"auth_cookie": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/lastfm/link_token": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Last.fm"
|
||||
],
|
||||
"summary": "Obtain an authentication token to be used when linking a Polaris account to a Last.fm account. The token is only valid for 10 minutes.",
|
||||
"operationId": "getLastFMLinkToken",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful operation",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/LastFMLinkToken"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"auth_http_basic": [],
|
||||
"auth_http_bearer": [],
|
||||
"auth_query_parameter": [],
|
||||
"auth_cookie": []
|
||||
}
|
||||
]
|
||||
|
@ -735,6 +1072,15 @@
|
|||
},
|
||||
"operationId": "getLastFMLink",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "auth_token",
|
||||
"in": "query",
|
||||
"required": true,
|
||||
"description": "Polaris authentication token received from the `lastfm/link_token` endpoint",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "token",
|
||||
"in": "query",
|
||||
|
@ -778,7 +1124,9 @@
|
|||
},
|
||||
"security": [
|
||||
{
|
||||
"auth_http_header": [],
|
||||
"auth_http_basic": [],
|
||||
"auth_http_bearer": [],
|
||||
"auth_query_parameter": [],
|
||||
"auth_cookie": []
|
||||
}
|
||||
]
|
||||
|
@ -810,7 +1158,7 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Config": {
|
||||
"Settings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"album_art_pattern": {
|
||||
|
@ -821,18 +1169,6 @@
|
|||
"type": "integer",
|
||||
"example": 3600
|
||||
},
|
||||
"mount_dirs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/MountPoint"
|
||||
}
|
||||
},
|
||||
"users": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ConfigUser"
|
||||
}
|
||||
},
|
||||
"ydns": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
@ -852,22 +1188,72 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"ConfigUser": {
|
||||
"Config": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"settings": {
|
||||
"$ref": "#/components/schemas/Settings"
|
||||
},
|
||||
"mount_dirs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/MountDir"
|
||||
}
|
||||
},
|
||||
"users": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/User"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"User": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"is_admin": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"is_admin"
|
||||
]
|
||||
},
|
||||
"NewUser": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"password": {
|
||||
"type": "string",
|
||||
"description": "Always blank when this field appear in a server response"
|
||||
"type": "string"
|
||||
},
|
||||
"admin": {
|
||||
"is_admin": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"password",
|
||||
"is_admin"
|
||||
]
|
||||
},
|
||||
"UserUpdate": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"password": {
|
||||
"type": "string"
|
||||
},
|
||||
"is_admin": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"MountPoint": {
|
||||
"MountDir": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"source": {
|
||||
|
@ -878,7 +1264,11 @@
|
|||
"type": "string",
|
||||
"example": "My Music"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"source",
|
||||
"name"
|
||||
]
|
||||
},
|
||||
"Preferences": {
|
||||
"type": "object",
|
||||
|
@ -894,7 +1284,7 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"AuthCredentials": {
|
||||
"Credentials": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"username": {
|
||||
|
@ -905,6 +1295,28 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Authorization": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"username": {
|
||||
"type": "string"
|
||||
},
|
||||
"token": {
|
||||
"type": "string"
|
||||
},
|
||||
"is_admin": {
|
||||
"type": "bool"
|
||||
}
|
||||
}
|
||||
},
|
||||
"LastFMLinkToken": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"value": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"CollectionFile": {
|
||||
"oneOf": [
|
||||
{
|
||||
|
@ -1031,26 +1443,49 @@
|
|||
}
|
||||
},
|
||||
"securitySchemes": {
|
||||
"auth_http_header": {
|
||||
"auth_http_bearer": {
|
||||
"type": "http",
|
||||
"scheme": "basic"
|
||||
"scheme": "bearer",
|
||||
"description": "An authentication token obtained in the output of the `auth` endpoint"
|
||||
},
|
||||
"admin_http_bearer": {
|
||||
"type": "http",
|
||||
"scheme": "bearer",
|
||||
"description": "Identical to the auth_http_bearer scheme but only for users recognized as admin by the Polaris server"
|
||||
},
|
||||
"auth_query_parameter": {
|
||||
"type": "apikey",
|
||||
"in": "query",
|
||||
"name": "auth_token",
|
||||
"description": "An authentication token obtained in the output of the `auth` endpoint"
|
||||
},
|
||||
"admin_query_parameter": {
|
||||
"type": "apikey",
|
||||
"in": "query",
|
||||
"name": "auth_token",
|
||||
"description": "Identical to the auth_query_parameter scheme but only for users recognized as admin by the Polaris server"
|
||||
},
|
||||
"auth_http_basic": {
|
||||
"type": "http",
|
||||
"scheme": "basic",
|
||||
"description": "[deprecated]"
|
||||
},
|
||||
"admin_http_basic": {
|
||||
"type": "http",
|
||||
"scheme": "basic",
|
||||
"description": "[deprecated] Identical to the auth_http_basic scheme but only for users recognized as admin by the Polaris server"
|
||||
},
|
||||
"auth_cookie": {
|
||||
"type": "apikey",
|
||||
"in": "cookie",
|
||||
"name": "session",
|
||||
"description": "A session token obtained returned as a server cookie by making a request via the auth_http_header scheme."
|
||||
},
|
||||
"admin_http_header": {
|
||||
"type": "http",
|
||||
"scheme": "basic",
|
||||
"description": "Identical to the auth_http_header scheme but only for users recognized as admin by the Polaris server"
|
||||
"description": "[deprecated] A token obtained via the SET-COOKIE header in a response to a request via the auth_http_basic scheme, or a request to the `auth` endpoint."
|
||||
},
|
||||
"admin_cookie": {
|
||||
"type": "apikey",
|
||||
"in": "cookie",
|
||||
"name": "session",
|
||||
"description": "Identical to the auth_cookie scheme but only for users recognized as admin by the Polaris server"
|
||||
"description": "[deprecated] Identical to the auth_cookie scheme but only for users recognized as admin by the Polaris server"
|
||||
}
|
||||
},
|
||||
"links": {},
|
||||
|
|
|
@ -4,4 +4,4 @@ CREATE TABLE misc_settings (
|
|||
index_sleep_duration_seconds INTEGER NOT NULL,
|
||||
index_album_art_pattern TEXT NOT NULL
|
||||
);
|
||||
INSERT INTO misc_settings (id, auth_secret, index_sleep_duration_seconds, index_album_art_pattern) VALUES (0, hex(randomblob(64)), 1800, "Folder.(jpg|png)");
|
||||
INSERT INTO misc_settings (id, auth_secret, index_sleep_duration_seconds, index_album_art_pattern) VALUES (0, hex(randomblob(64)), 1800, "Folder.(jpeg|jpg|png)");
|
||||
|
|
|
@ -1 +1 @@
|
|||
nightly-2020-12-08
|
||||
stable
|
|
@ -1,13 +1,5 @@
|
|||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum Error {
|
||||
#[error("Missing auth secret")]
|
||||
AuthSecretNotFound,
|
||||
#[error("Missing index sleep duration")]
|
||||
IndexSleepDurationNotFound,
|
||||
#[error("Missing index album art pattern")]
|
||||
IndexAlbumArtPatternNotFound,
|
||||
#[error("Index album art pattern is not a valid regex")]
|
||||
IndexAlbumArtPatternInvalid,
|
||||
#[error("Unspecified")]
|
||||
Unspecified,
|
||||
}
|
||||
|
|
|
@ -1,190 +1,83 @@
|
|||
use diesel;
|
||||
use diesel::prelude::*;
|
||||
use std::time::Duration;
|
||||
use regex::Regex;
|
||||
|
||||
use super::*;
|
||||
use crate::app::user;
|
||||
use crate::db::{ddns_config, misc_settings, mount_points, users, DB};
|
||||
use crate::app::{ddns, settings, user, vfs};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Manager {
|
||||
pub db: DB,
|
||||
settings_manager: settings::Manager,
|
||||
user_manager: user::Manager,
|
||||
vfs_manager: vfs::Manager,
|
||||
ddns_manager: ddns::Manager,
|
||||
}
|
||||
|
||||
impl Manager {
|
||||
pub fn new(db: DB, user_manager: user::Manager) -> Self {
|
||||
Self { db, user_manager }
|
||||
pub fn new(
|
||||
settings_manager: settings::Manager,
|
||||
user_manager: user::Manager,
|
||||
vfs_manager: vfs::Manager,
|
||||
ddns_manager: ddns::Manager,
|
||||
) -> Self {
|
||||
Self {
|
||||
settings_manager,
|
||||
user_manager,
|
||||
vfs_manager,
|
||||
ddns_manager,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_auth_secret(&self) -> Result<Vec<u8>, Error> {
|
||||
use self::misc_settings::dsl::*;
|
||||
let connection = self.db.connect()?;
|
||||
misc_settings
|
||||
.select(auth_secret)
|
||||
.get_result(&connection)
|
||||
.map_err(|e| match e {
|
||||
diesel::result::Error::NotFound => Error::AuthSecretNotFound,
|
||||
_ => Error::Unspecified,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_index_sleep_duration(&self) -> Result<Duration, Error> {
|
||||
use self::misc_settings::dsl::*;
|
||||
let connection = self.db.connect()?;
|
||||
misc_settings
|
||||
.select(index_sleep_duration_seconds)
|
||||
.get_result(&connection)
|
||||
.map_err(|e| match e {
|
||||
diesel::result::Error::NotFound => Error::IndexSleepDurationNotFound,
|
||||
_ => Error::Unspecified,
|
||||
})
|
||||
.map(|s: i32| Duration::from_secs(s as u64))
|
||||
}
|
||||
|
||||
pub fn get_index_album_art_pattern(&self) -> Result<Regex, Error> {
|
||||
use self::misc_settings::dsl::*;
|
||||
let connection = self.db.connect()?;
|
||||
misc_settings
|
||||
.select(index_album_art_pattern)
|
||||
.get_result(&connection)
|
||||
.map_err(|e| match e {
|
||||
diesel::result::Error::NotFound => Error::IndexAlbumArtPatternNotFound,
|
||||
_ => Error::Unspecified,
|
||||
})
|
||||
.and_then(|s: String| Regex::new(&s).map_err(|_| Error::IndexAlbumArtPatternInvalid))
|
||||
}
|
||||
|
||||
pub fn read(&self) -> anyhow::Result<Config> {
|
||||
use self::ddns_config::dsl::*;
|
||||
use self::misc_settings::dsl::*;
|
||||
|
||||
let connection = self.db.connect()?;
|
||||
|
||||
let mut config = Config {
|
||||
album_art_pattern: None,
|
||||
reindex_every_n_seconds: None,
|
||||
mount_dirs: None,
|
||||
users: None,
|
||||
ydns: None,
|
||||
};
|
||||
|
||||
let (art_pattern, sleep_duration) = misc_settings
|
||||
.select((index_album_art_pattern, index_sleep_duration_seconds))
|
||||
.get_result(&connection)?;
|
||||
|
||||
config.album_art_pattern = Some(art_pattern);
|
||||
config.reindex_every_n_seconds = Some(sleep_duration);
|
||||
|
||||
let mount_dirs;
|
||||
{
|
||||
use self::mount_points::dsl::*;
|
||||
mount_dirs = mount_points
|
||||
.select((source, name))
|
||||
.get_results(&connection)?;
|
||||
config.mount_dirs = Some(mount_dirs);
|
||||
pub fn apply(&self, config: &Config) -> Result<(), Error> {
|
||||
if let Some(new_settings) = &config.settings {
|
||||
self.settings_manager
|
||||
.amend(new_settings)
|
||||
.map_err(|_| Error::Unspecified)?;
|
||||
}
|
||||
|
||||
let found_users: Vec<(String, i32)> = users::table
|
||||
.select((users::columns::name, users::columns::admin))
|
||||
.get_results(&connection)?;
|
||||
config.users = Some(
|
||||
found_users
|
||||
.into_iter()
|
||||
.map(|(name, admin)| ConfigUser {
|
||||
name,
|
||||
password: "".to_owned(),
|
||||
admin: admin != 0,
|
||||
})
|
||||
.collect::<_>(),
|
||||
);
|
||||
|
||||
let ydns = ddns_config
|
||||
.select((host, username, password))
|
||||
.get_result(&connection)?;
|
||||
config.ydns = Some(ydns);
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub fn amend(&self, new_config: &Config) -> anyhow::Result<()> {
|
||||
let connection = self.db.connect()?;
|
||||
|
||||
if let Some(ref mount_dirs) = new_config.mount_dirs {
|
||||
diesel::delete(mount_points::table).execute(&connection)?;
|
||||
diesel::insert_into(mount_points::table)
|
||||
.values(mount_dirs)
|
||||
.execute(&*connection)?; // TODO https://github.com/diesel-rs/diesel/issues/1822
|
||||
if let Some(mount_dirs) = &config.mount_dirs {
|
||||
self.vfs_manager
|
||||
.set_mount_dirs(&mount_dirs)
|
||||
.map_err(|_| Error::Unspecified)?;
|
||||
}
|
||||
|
||||
if let Some(ref config_users) = new_config.users {
|
||||
let old_usernames: Vec<String> =
|
||||
users::table.select(users::name).get_results(&connection)?;
|
||||
if let Some(ddns_config) = &config.ydns {
|
||||
self.ddns_manager
|
||||
.set_config(&ddns_config)
|
||||
.map_err(|_| Error::Unspecified)?;
|
||||
}
|
||||
|
||||
if let Some(ref users) = config.users {
|
||||
let old_users: Vec<user::User> =
|
||||
self.user_manager.list().map_err(|_| Error::Unspecified)?;
|
||||
|
||||
// Delete users that are not in new list
|
||||
let delete_usernames: Vec<String> = old_usernames
|
||||
for old_user in old_users
|
||||
.iter()
|
||||
.cloned()
|
||||
.filter(|old_name| config_users.iter().find(|u| &u.name == old_name).is_none())
|
||||
.collect::<_>();
|
||||
diesel::delete(users::table.filter(users::name.eq_any(&delete_usernames)))
|
||||
.execute(&connection)?;
|
||||
.filter(|old_user| !users.iter().any(|u| u.name == old_user.name))
|
||||
{
|
||||
self.user_manager
|
||||
.delete(&old_user.name)
|
||||
.map_err(|_| Error::Unspecified)?;
|
||||
}
|
||||
|
||||
// Insert new users
|
||||
let insert_users: Vec<&ConfigUser> = config_users
|
||||
for new_user in users
|
||||
.iter()
|
||||
.filter(|u| {
|
||||
!u.name.is_empty()
|
||||
&& !u.password.is_empty()
|
||||
&& old_usernames
|
||||
.iter()
|
||||
.find(|old_name| *old_name == &u.name)
|
||||
.is_none()
|
||||
})
|
||||
.collect::<_>();
|
||||
for config_user in &insert_users {
|
||||
.filter(|u| !old_users.iter().any(|old_user| old_user.name == u.name))
|
||||
{
|
||||
self.user_manager
|
||||
.create_user(&config_user.name, &config_user.password)?;
|
||||
.create(new_user)
|
||||
.map_err(|_| Error::Unspecified)?;
|
||||
}
|
||||
|
||||
// Update users
|
||||
for user in config_users.iter() {
|
||||
// Update password if provided
|
||||
if !user.password.is_empty() {
|
||||
self.user_manager.set_password(&user.name, &user.password)?;
|
||||
}
|
||||
|
||||
// Update admin rights
|
||||
diesel::update(users::table.filter(users::name.eq(&user.name)))
|
||||
.set(users::admin.eq(user.admin as i32))
|
||||
.execute(&connection)?;
|
||||
for user in users {
|
||||
self.user_manager
|
||||
.set_password(&user.name, &user.password)
|
||||
.map_err(|_| Error::Unspecified)?;
|
||||
self.user_manager
|
||||
.set_is_admin(&user.name, user.admin)
|
||||
.map_err(|_| Error::Unspecified)?;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(sleep_duration) = new_config.reindex_every_n_seconds {
|
||||
diesel::update(misc_settings::table)
|
||||
.set(misc_settings::index_sleep_duration_seconds.eq(sleep_duration as i32))
|
||||
.execute(&connection)?;
|
||||
}
|
||||
|
||||
if let Some(ref album_art_pattern) = new_config.album_art_pattern {
|
||||
diesel::update(misc_settings::table)
|
||||
.set(misc_settings::index_album_art_pattern.eq(album_art_pattern))
|
||||
.execute(&connection)?;
|
||||
}
|
||||
|
||||
if let Some(ref ydns) = new_config.ydns {
|
||||
use self::ddns_config::dsl::*;
|
||||
diesel::update(ddns_config)
|
||||
.set((
|
||||
host.eq(ydns.host.clone()),
|
||||
username.eq(ydns.username.clone()),
|
||||
password.eq(ydns.password.clone()),
|
||||
))
|
||||
.execute(&connection)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
use crate::app::{ddns, vfs};
|
||||
use core::ops::Deref;
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::Deserialize;
|
||||
use std::io::Read;
|
||||
use std::path::{self, PathBuf};
|
||||
use std::path;
|
||||
|
||||
use crate::app::{ddns, settings, user, vfs};
|
||||
|
||||
mod error;
|
||||
mod manager;
|
||||
|
@ -13,20 +12,12 @@ mod test;
|
|||
pub use error::*;
|
||||
pub use manager::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ConfigUser {
|
||||
pub name: String,
|
||||
pub password: String,
|
||||
pub admin: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[derive(Default, Deserialize)]
|
||||
pub struct Config {
|
||||
pub album_art_pattern: Option<String>,
|
||||
pub reindex_every_n_seconds: Option<i32>,
|
||||
pub mount_dirs: Option<Vec<vfs::MountPoint>>,
|
||||
pub users: Option<Vec<ConfigUser>>,
|
||||
pub settings: Option<settings::NewSettings>,
|
||||
pub mount_dirs: Option<Vec<vfs::MountDir>>,
|
||||
pub ydns: Option<ddns::Config>,
|
||||
pub users: Option<Vec<user::NewUser>>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
|
@ -34,36 +25,7 @@ impl Config {
|
|||
let mut config_file = std::fs::File::open(path)?;
|
||||
let mut config_file_content = String::new();
|
||||
config_file.read_to_string(&mut config_file_content)?;
|
||||
let mut config = toml::de::from_str::<Config>(&config_file_content)?;
|
||||
config.clean_paths()?;
|
||||
let config = toml::de::from_str::<Self>(&config_file_content)?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn clean_paths(&mut self) -> anyhow::Result<()> {
|
||||
if let Some(ref mut mount_dirs) = self.mount_dirs {
|
||||
for mount_dir in mount_dirs {
|
||||
match Self::clean_path_string(&mount_dir.source).to_str() {
|
||||
Some(p) => mount_dir.source = p.to_owned(),
|
||||
_ => anyhow::bail!("Bad mount directory path"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn clean_path_string(path_string: &str) -> PathBuf {
|
||||
let separator_regex = Regex::new(r"\\|/").unwrap();
|
||||
let mut correct_separator = String::new();
|
||||
correct_separator.push(path::MAIN_SEPARATOR);
|
||||
let path_string = separator_regex.replace_all(path_string, correct_separator.as_str());
|
||||
path::Path::new(path_string.deref()).iter().collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Queryable)]
|
||||
pub struct MiscSettings {
|
||||
id: i32,
|
||||
pub auth_secret: Vec<u8>,
|
||||
pub index_sleep_duration_seconds: i32,
|
||||
pub index_album_art_pattern: String,
|
||||
}
|
||||
|
|
|
@ -1,10 +1,9 @@
|
|||
use diesel::prelude::*;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::*;
|
||||
use crate::app::{user, vfs};
|
||||
use crate::db::{users, DB};
|
||||
use crate::app::{settings, user, vfs};
|
||||
use crate::db::DB;
|
||||
use crate::test_name;
|
||||
|
||||
#[cfg(test)]
|
||||
|
@ -22,249 +21,135 @@ fn get_test_db(name: &str) -> DB {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn test_amend() {
|
||||
fn apply_saves_misc_settings() {
|
||||
let db = get_test_db(&test_name!());
|
||||
let user_manager = user::Manager::new(db.clone());
|
||||
let config_manager = Manager::new(db, user_manager);
|
||||
|
||||
let initial_config = Config {
|
||||
album_art_pattern: Some("file\\.png".into()),
|
||||
reindex_every_n_seconds: Some(123),
|
||||
mount_dirs: Some(vec![vfs::MountPoint {
|
||||
source: "C:\\Music".into(),
|
||||
name: "root".into(),
|
||||
}]),
|
||||
users: Some(vec![ConfigUser {
|
||||
name: "Teddy🐻".into(),
|
||||
password: "Tasty🍖".into(),
|
||||
admin: false,
|
||||
}]),
|
||||
ydns: None,
|
||||
};
|
||||
let settings_manager = settings::Manager::new(db.clone());
|
||||
let auth_secret = settings_manager.get_auth_secret().unwrap();
|
||||
let user_manager = user::Manager::new(db.clone(), auth_secret);
|
||||
let vfs_manager = vfs::Manager::new(db.clone());
|
||||
let ddns_manager = ddns::Manager::new(db.clone());
|
||||
let config_manager = Manager::new(
|
||||
settings_manager.clone(),
|
||||
user_manager.clone(),
|
||||
vfs_manager.clone(),
|
||||
ddns_manager.clone(),
|
||||
);
|
||||
|
||||
let new_config = Config {
|
||||
album_art_pattern: Some("🖼️\\.jpg".into()),
|
||||
reindex_every_n_seconds: None,
|
||||
mount_dirs: Some(vec![vfs::MountPoint {
|
||||
settings: Some(settings::NewSettings {
|
||||
album_art_pattern: Some("🖼️\\.jpg".into()),
|
||||
reindex_every_n_seconds: Some(100),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
config_manager.apply(&new_config).unwrap();
|
||||
let settings = settings_manager.read().unwrap();
|
||||
let new_settings = new_config.settings.unwrap();
|
||||
assert_eq!(
|
||||
settings.album_art_pattern,
|
||||
new_settings.album_art_pattern.unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
settings.reindex_every_n_seconds,
|
||||
new_settings.reindex_every_n_seconds.unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_saves_mount_points() {
|
||||
let db = get_test_db(&test_name!());
|
||||
let settings_manager = settings::Manager::new(db.clone());
|
||||
let auth_secret = settings_manager.get_auth_secret().unwrap();
|
||||
let user_manager = user::Manager::new(db.clone(), auth_secret);
|
||||
let vfs_manager = vfs::Manager::new(db.clone());
|
||||
let ddns_manager = ddns::Manager::new(db.clone());
|
||||
let config_manager = Manager::new(
|
||||
settings_manager.clone(),
|
||||
user_manager.clone(),
|
||||
vfs_manager.clone(),
|
||||
ddns_manager.clone(),
|
||||
);
|
||||
|
||||
let new_config = Config {
|
||||
mount_dirs: Some(vec![vfs::MountDir {
|
||||
source: "/home/music".into(),
|
||||
name: "🎵📁".into(),
|
||||
}]),
|
||||
users: Some(vec![ConfigUser {
|
||||
name: "Kermit🐸".into(),
|
||||
password: "🐞🐞".into(),
|
||||
admin: false,
|
||||
}]),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
config_manager.apply(&new_config).unwrap();
|
||||
let actual_mount_dirs: Vec<vfs::MountDir> = vfs_manager.mount_dirs().unwrap();
|
||||
assert_eq!(actual_mount_dirs, new_config.mount_dirs.unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_saves_ddns_settings() {
|
||||
use crate::app::ddns;
|
||||
|
||||
let db = get_test_db(&test_name!());
|
||||
let settings_manager = settings::Manager::new(db.clone());
|
||||
let auth_secret = settings_manager.get_auth_secret().unwrap();
|
||||
let user_manager = user::Manager::new(db.clone(), auth_secret);
|
||||
let vfs_manager = vfs::Manager::new(db.clone());
|
||||
let ddns_manager = ddns::Manager::new(db.clone());
|
||||
let config_manager = Manager::new(
|
||||
settings_manager.clone(),
|
||||
user_manager.clone(),
|
||||
vfs_manager.clone(),
|
||||
ddns_manager.clone(),
|
||||
);
|
||||
|
||||
let new_config = Config {
|
||||
ydns: Some(ddns::Config {
|
||||
host: "🐸🐸🐸.ydns.eu".into(),
|
||||
username: "kfr🐸g".into(),
|
||||
password: "tasty🐞".into(),
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut expected_config = new_config.clone();
|
||||
expected_config.reindex_every_n_seconds = initial_config.reindex_every_n_seconds;
|
||||
if let Some(ref mut users) = expected_config.users {
|
||||
users[0].password = "".into();
|
||||
}
|
||||
|
||||
config_manager.amend(&initial_config).unwrap();
|
||||
config_manager.amend(&new_config).unwrap();
|
||||
let db_config = config_manager.read().unwrap();
|
||||
assert_eq!(db_config, expected_config);
|
||||
config_manager.apply(&new_config).unwrap();
|
||||
let actual_ddns = ddns_manager.config().unwrap();
|
||||
assert_eq!(actual_ddns, new_config.ydns.unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_amend_preserve_password_hashes() {
|
||||
use self::users::dsl::*;
|
||||
|
||||
fn apply_can_toggle_admin() {
|
||||
let db = get_test_db(&test_name!());
|
||||
let user_manager = user::Manager::new(db.clone());
|
||||
let config_manager = Manager::new(db.clone(), user_manager);
|
||||
|
||||
let initial_hash: String;
|
||||
let new_hash: String;
|
||||
let settings_manager = settings::Manager::new(db.clone());
|
||||
let auth_secret = settings_manager.get_auth_secret().unwrap();
|
||||
let user_manager = user::Manager::new(db.clone(), auth_secret);
|
||||
let vfs_manager = vfs::Manager::new(db.clone());
|
||||
let ddns_manager = ddns::Manager::new(db.clone());
|
||||
let config_manager = Manager::new(
|
||||
settings_manager.clone(),
|
||||
user_manager.clone(),
|
||||
vfs_manager.clone(),
|
||||
ddns_manager.clone(),
|
||||
);
|
||||
|
||||
let initial_config = Config {
|
||||
album_art_pattern: None,
|
||||
reindex_every_n_seconds: None,
|
||||
mount_dirs: None,
|
||||
users: Some(vec![ConfigUser {
|
||||
name: "Teddy🐻".into(),
|
||||
password: "Tasty🍖".into(),
|
||||
admin: false,
|
||||
}]),
|
||||
ydns: None,
|
||||
};
|
||||
config_manager.amend(&initial_config).unwrap();
|
||||
|
||||
{
|
||||
let connection = db.connect().unwrap();
|
||||
initial_hash = users
|
||||
.select(password_hash)
|
||||
.filter(name.eq("Teddy🐻"))
|
||||
.get_result(&connection)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let new_config = Config {
|
||||
album_art_pattern: None,
|
||||
reindex_every_n_seconds: None,
|
||||
mount_dirs: None,
|
||||
users: Some(vec![
|
||||
ConfigUser {
|
||||
name: "Kermit🐸".into(),
|
||||
password: "tasty🐞".into(),
|
||||
admin: false,
|
||||
},
|
||||
ConfigUser {
|
||||
name: "Teddy🐻".into(),
|
||||
password: "".into(),
|
||||
admin: false,
|
||||
},
|
||||
]),
|
||||
ydns: None,
|
||||
};
|
||||
config_manager.amend(&new_config).unwrap();
|
||||
|
||||
{
|
||||
let connection = db.connect().unwrap();
|
||||
new_hash = users
|
||||
.select(password_hash)
|
||||
.filter(name.eq("Teddy🐻"))
|
||||
.get_result(&connection)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
assert_eq!(new_hash, initial_hash);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_amend_ignore_blank_users() {
|
||||
use self::users::dsl::*;
|
||||
|
||||
let db = get_test_db(&test_name!());
|
||||
let user_manager = user::Manager::new(db.clone());
|
||||
let config_manager = Manager::new(db.clone(), user_manager);
|
||||
|
||||
{
|
||||
let config = Config {
|
||||
album_art_pattern: None,
|
||||
reindex_every_n_seconds: None,
|
||||
mount_dirs: None,
|
||||
users: Some(vec![ConfigUser {
|
||||
name: "".into(),
|
||||
password: "Tasty🍖".into(),
|
||||
admin: false,
|
||||
}]),
|
||||
ydns: None,
|
||||
};
|
||||
config_manager.amend(&config).unwrap();
|
||||
|
||||
let connection = db.connect().unwrap();
|
||||
let user_count: i64 = users.count().get_result(&connection).unwrap();
|
||||
assert_eq!(user_count, 0);
|
||||
}
|
||||
|
||||
{
|
||||
let config = Config {
|
||||
album_art_pattern: None,
|
||||
reindex_every_n_seconds: None,
|
||||
mount_dirs: None,
|
||||
users: Some(vec![ConfigUser {
|
||||
name: "Teddy🐻".into(),
|
||||
password: "".into(),
|
||||
admin: false,
|
||||
}]),
|
||||
ydns: None,
|
||||
};
|
||||
config_manager.amend(&config).unwrap();
|
||||
|
||||
let connection = db.connect().unwrap();
|
||||
let user_count: i64 = users.count().get_result(&connection).unwrap();
|
||||
assert_eq!(user_count, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_toggle_admin() {
|
||||
use self::users::dsl::*;
|
||||
|
||||
let db = get_test_db(&test_name!());
|
||||
let user_manager = user::Manager::new(db.clone());
|
||||
let config_manager = Manager::new(db.clone(), user_manager);
|
||||
|
||||
let initial_config = Config {
|
||||
album_art_pattern: None,
|
||||
reindex_every_n_seconds: None,
|
||||
mount_dirs: None,
|
||||
users: Some(vec![ConfigUser {
|
||||
name: "Teddy🐻".into(),
|
||||
users: Some(vec![user::NewUser {
|
||||
name: "Walter".into(),
|
||||
password: "Tasty🍖".into(),
|
||||
admin: true,
|
||||
}]),
|
||||
ydns: None,
|
||||
..Default::default()
|
||||
};
|
||||
config_manager.amend(&initial_config).unwrap();
|
||||
|
||||
{
|
||||
let connection = db.connect().unwrap();
|
||||
let is_admin: i32 = users.select(admin).get_result(&connection).unwrap();
|
||||
assert_eq!(is_admin, 1);
|
||||
}
|
||||
config_manager.apply(&initial_config).unwrap();
|
||||
assert!(user_manager.list().unwrap()[0].is_admin());
|
||||
|
||||
let new_config = Config {
|
||||
album_art_pattern: None,
|
||||
reindex_every_n_seconds: None,
|
||||
mount_dirs: None,
|
||||
users: Some(vec![ConfigUser {
|
||||
name: "Teddy🐻".into(),
|
||||
password: "".into(),
|
||||
users: Some(vec![user::NewUser {
|
||||
name: "Walter".into(),
|
||||
password: "Tasty🍖".into(),
|
||||
admin: false,
|
||||
}]),
|
||||
ydns: None,
|
||||
..Default::default()
|
||||
};
|
||||
config_manager.amend(&new_config).unwrap();
|
||||
|
||||
{
|
||||
let connection = db.connect().unwrap();
|
||||
let is_admin: i32 = users.select(admin).get_result(&connection).unwrap();
|
||||
assert_eq!(is_admin, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clean_path_string() {
|
||||
let mut correct_path = path::PathBuf::new();
|
||||
if cfg!(target_os = "windows") {
|
||||
correct_path.push("C:\\");
|
||||
} else {
|
||||
correct_path.push("/usr");
|
||||
}
|
||||
correct_path.push("some");
|
||||
correct_path.push("path");
|
||||
if cfg!(target_os = "windows") {
|
||||
assert_eq!(correct_path, Config::clean_path_string(r#"C:/some/path"#));
|
||||
assert_eq!(correct_path, Config::clean_path_string(r#"C:\some\path"#));
|
||||
assert_eq!(correct_path, Config::clean_path_string(r#"C:\some\path\"#));
|
||||
assert_eq!(
|
||||
correct_path,
|
||||
Config::clean_path_string(r#"C:\some\path\\\\"#)
|
||||
);
|
||||
assert_eq!(correct_path, Config::clean_path_string(r#"C:\some/path//"#));
|
||||
} else {
|
||||
assert_eq!(correct_path, Config::clean_path_string(r#"/usr/some/path"#));
|
||||
assert_eq!(correct_path, Config::clean_path_string(r#"/usr\some\path"#));
|
||||
assert_eq!(
|
||||
correct_path,
|
||||
Config::clean_path_string(r#"/usr\some\path\"#)
|
||||
);
|
||||
assert_eq!(
|
||||
correct_path,
|
||||
Config::clean_path_string(r#"/usr\some\path\\\\"#)
|
||||
);
|
||||
assert_eq!(
|
||||
correct_path,
|
||||
Config::clean_path_string(r#"/usr\some/path//"#)
|
||||
);
|
||||
}
|
||||
config_manager.apply(&new_config).unwrap();
|
||||
assert!(!user_manager.list().unwrap()[0].is_admin());
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@ use crate::db::DB;
|
|||
|
||||
const DDNS_UPDATE_URL: &str = "https://ydns.io/api/v1/update/";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Manager {
|
||||
db: DB,
|
||||
}
|
||||
|
@ -20,7 +21,7 @@ impl Manager {
|
|||
}
|
||||
|
||||
fn update_my_ip(&self) -> Result<()> {
|
||||
let config = self.get_config()?;
|
||||
let config = self.config()?;
|
||||
if config.host.is_empty() || config.username.is_empty() {
|
||||
info!("Skipping DDNS update because credentials are missing");
|
||||
return Ok(());
|
||||
|
@ -41,7 +42,7 @@ impl Manager {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn get_config(&self) -> Result<Config> {
|
||||
pub fn config(&self) -> Result<Config> {
|
||||
use crate::db::ddns_config::dsl::*;
|
||||
let connection = self.db.connect()?;
|
||||
Ok(ddns_config
|
||||
|
@ -49,7 +50,27 @@ impl Manager {
|
|||
.get_result(&connection)?)
|
||||
}
|
||||
|
||||
pub fn run(&self) {
|
||||
pub fn set_config(&self, new_config: &Config) -> Result<()> {
|
||||
use crate::db::ddns_config::dsl::*;
|
||||
let connection = self.db.connect()?;
|
||||
diesel::update(ddns_config)
|
||||
.set((
|
||||
host.eq(&new_config.host),
|
||||
username.eq(&new_config.username),
|
||||
password.eq(&new_config.password),
|
||||
))
|
||||
.execute(&connection)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn begin_periodic_updates(&self) {
|
||||
let cloned = self.clone();
|
||||
std::thread::spawn(move || {
|
||||
cloned.run();
|
||||
});
|
||||
}
|
||||
|
||||
fn run(&self) {
|
||||
loop {
|
||||
if let Err(e) = self.update_my_ip() {
|
||||
error!("Dynamic DNS update error: {:?}", e);
|
||||
|
|
|
@ -3,7 +3,7 @@ use log::error;
|
|||
use std::sync::{Arc, Condvar, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::app::{config, vfs};
|
||||
use crate::app::{settings, vfs};
|
||||
use crate::db::DB;
|
||||
|
||||
mod metadata;
|
||||
|
@ -21,16 +21,16 @@ pub use self::update::*;
|
|||
pub struct Index {
|
||||
db: DB,
|
||||
vfs_manager: vfs::Manager,
|
||||
config_manager: config::Manager,
|
||||
settings_manager: settings::Manager,
|
||||
pending_reindex: Arc<(Mutex<bool>, Condvar)>,
|
||||
}
|
||||
|
||||
impl Index {
|
||||
pub fn new(db: DB, vfs_manager: vfs::Manager, config_manager: config::Manager) -> Self {
|
||||
pub fn new(db: DB, vfs_manager: vfs::Manager, settings_manager: settings::Manager) -> Self {
|
||||
let index = Self {
|
||||
db,
|
||||
vfs_manager,
|
||||
config_manager,
|
||||
settings_manager,
|
||||
pending_reindex: Arc::new((Mutex::new(false), Condvar::new())),
|
||||
};
|
||||
|
||||
|
@ -76,7 +76,7 @@ impl Index {
|
|||
loop {
|
||||
self.trigger_reindex();
|
||||
let sleep_duration = self
|
||||
.config_manager
|
||||
.settings_manager
|
||||
.get_index_sleep_duration()
|
||||
.unwrap_or_else(|e| {
|
||||
error!("Could not retrieve index sleep duration: {}", e);
|
||||
|
|
|
@ -2,17 +2,21 @@ use diesel::prelude::*;
|
|||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::*;
|
||||
use crate::app::{config, user, vfs};
|
||||
use crate::app::{index::Index, settings, vfs};
|
||||
use crate::db::{self, directories, songs};
|
||||
use crate::test_name;
|
||||
|
||||
fn get_context(test_name: &str) -> (db::DB, Index) {
|
||||
let db = db::get_test_db(test_name);
|
||||
let vfs_manager = vfs::Manager::new(db.clone());
|
||||
let settings_manager = settings::Manager::new(db.clone());
|
||||
let index = Index::new(db.clone(), vfs_manager, settings_manager);
|
||||
(db, index)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_populate() {
|
||||
let db = db::get_test_db(&test_name!());
|
||||
let vfs_manager = vfs::Manager::new(db.clone());
|
||||
let user_manager = user::Manager::new(db.clone());
|
||||
let config_manager = config::Manager::new(db.clone(), user_manager);
|
||||
let index = Index::new(db.clone(), vfs_manager, config_manager);
|
||||
let (db, index) = get_context(&test_name!());
|
||||
index.update().unwrap();
|
||||
index.update().unwrap(); // Validates that subsequent updates don't run into conflicts
|
||||
|
||||
|
@ -35,11 +39,7 @@ fn test_metadata() {
|
|||
let mut artwork_path = target.clone();
|
||||
artwork_path.push("Folder.png");
|
||||
|
||||
let db = db::get_test_db(&test_name!());
|
||||
let vfs_manager = vfs::Manager::new(db.clone());
|
||||
let user_manager = user::Manager::new(db.clone());
|
||||
let config_manager = config::Manager::new(db.clone(), user_manager);
|
||||
let index = Index::new(db.clone(), vfs_manager, config_manager);
|
||||
let (db, index) = get_context(&test_name!());
|
||||
index.update().unwrap();
|
||||
|
||||
let connection = db.connect().unwrap();
|
||||
|
@ -64,6 +64,35 @@ fn test_metadata() {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_artwork_pattern_case_insensitive() {
|
||||
let target: PathBuf = ["test-data", "small-collection", "Khemmis", "Hunted"]
|
||||
.iter()
|
||||
.collect();
|
||||
|
||||
let mut song_path = target.clone();
|
||||
song_path.push("05 - Hunted.mp3");
|
||||
|
||||
let mut artwork_path = target.clone();
|
||||
artwork_path.push("folder.jpg");
|
||||
|
||||
let (db, index) = get_context(&test_name!());
|
||||
index.update().unwrap();
|
||||
|
||||
let connection = db.connect().unwrap();
|
||||
let songs: Vec<Song> = songs::table
|
||||
.filter(songs::title.eq("Hunted"))
|
||||
.load(&connection)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(songs.len(), 1);
|
||||
let song = &songs[0];
|
||||
assert_eq!(
|
||||
song.artwork.as_ref().unwrap().to_lowercase(),
|
||||
artwork_path.to_string_lossy().to_lowercase()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_embedded_artwork() {
|
||||
let song_path: PathBuf = [
|
||||
|
@ -76,11 +105,7 @@ fn test_embedded_artwork() {
|
|||
.iter()
|
||||
.collect();
|
||||
|
||||
let db = db::get_test_db(&test_name!());
|
||||
let vfs_manager = vfs::Manager::new(db.clone());
|
||||
let user_manager = user::Manager::new(db.clone());
|
||||
let config_manager = config::Manager::new(db.clone(), user_manager);
|
||||
let index = Index::new(db.clone(), vfs_manager, config_manager);
|
||||
let (db, index) = get_context(&test_name!());
|
||||
index.update().unwrap();
|
||||
|
||||
let connection = db.connect().unwrap();
|
||||
|
@ -99,11 +124,7 @@ fn test_browse_top_level() {
|
|||
let mut root_path = PathBuf::new();
|
||||
root_path.push("root");
|
||||
|
||||
let db = db::get_test_db(&test_name!());
|
||||
let vfs_manager = vfs::Manager::new(db.clone());
|
||||
let user_manager = user::Manager::new(db.clone());
|
||||
let config_manager = config::Manager::new(db.clone(), user_manager);
|
||||
let index = Index::new(db.clone(), vfs_manager, config_manager);
|
||||
let (_db, index) = get_context(&test_name!());
|
||||
index.update().unwrap();
|
||||
|
||||
let results = index.browse(Path::new("")).unwrap();
|
||||
|
@ -120,11 +141,7 @@ fn test_browse() {
|
|||
let khemmis_path: PathBuf = ["root", "Khemmis"].iter().collect();
|
||||
let tobokegao_path: PathBuf = ["root", "Tobokegao"].iter().collect();
|
||||
|
||||
let db = db::get_test_db(&test_name!());
|
||||
let vfs_manager = vfs::Manager::new(db.clone());
|
||||
let user_manager = user::Manager::new(db.clone());
|
||||
let config_manager = config::Manager::new(db.clone(), user_manager);
|
||||
let index = Index::new(db.clone(), vfs_manager, config_manager);
|
||||
let (_db, index) = get_context(&test_name!());
|
||||
index.update().unwrap();
|
||||
|
||||
let results = index.browse(Path::new("root")).unwrap();
|
||||
|
@ -142,11 +159,7 @@ fn test_browse() {
|
|||
|
||||
#[test]
|
||||
fn test_flatten() {
|
||||
let db = db::get_test_db(&test_name!());
|
||||
let vfs_manager = vfs::Manager::new(db.clone());
|
||||
let user_manager = user::Manager::new(db.clone());
|
||||
let config_manager = config::Manager::new(db.clone(), user_manager);
|
||||
let index = Index::new(db.clone(), vfs_manager, config_manager);
|
||||
let (_db, index) = get_context(&test_name!());
|
||||
index.update().unwrap();
|
||||
|
||||
// Flatten all
|
||||
|
@ -167,11 +180,7 @@ fn test_flatten() {
|
|||
|
||||
#[test]
|
||||
fn test_random() {
|
||||
let db = db::get_test_db(&test_name!());
|
||||
let vfs_manager = vfs::Manager::new(db.clone());
|
||||
let user_manager = user::Manager::new(db.clone());
|
||||
let config_manager = config::Manager::new(db.clone(), user_manager);
|
||||
let index = Index::new(db.clone(), vfs_manager, config_manager);
|
||||
let (_db, index) = get_context(&test_name!());
|
||||
index.update().unwrap();
|
||||
|
||||
let results = index.get_random_albums(1).unwrap();
|
||||
|
@ -180,11 +189,7 @@ fn test_random() {
|
|||
|
||||
#[test]
|
||||
fn test_recent() {
|
||||
let db = db::get_test_db(&test_name!());
|
||||
let vfs_manager = vfs::Manager::new(db.clone());
|
||||
let user_manager = user::Manager::new(db.clone());
|
||||
let config_manager = config::Manager::new(db.clone(), user_manager);
|
||||
let index = Index::new(db.clone(), vfs_manager, config_manager);
|
||||
let (_db, index) = get_context(&test_name!());
|
||||
index.update().unwrap();
|
||||
|
||||
let results = index.get_recent_albums(2).unwrap();
|
||||
|
@ -194,11 +199,7 @@ fn test_recent() {
|
|||
|
||||
#[test]
|
||||
fn test_get_song() {
|
||||
let db = db::get_test_db(&test_name!());
|
||||
let vfs_manager = vfs::Manager::new(db.clone());
|
||||
let user_manager = user::Manager::new(db.clone());
|
||||
let config_manager = config::Manager::new(db.clone(), user_manager);
|
||||
let index = Index::new(db.clone(), vfs_manager, config_manager);
|
||||
let (_db, index) = get_context(&test_name!());
|
||||
index.update().unwrap();
|
||||
|
||||
let song_path: PathBuf = ["root", "Khemmis", "Hunted", "02 - Candlelight.mp3"]
|
||||
|
|
|
@ -18,7 +18,7 @@ impl Index {
|
|||
let start = time::Instant::now();
|
||||
info!("Beginning library index update");
|
||||
|
||||
let album_art_pattern = self.config_manager.get_index_album_art_pattern()?;
|
||||
let album_art_pattern = self.settings_manager.get_index_album_art_pattern()?;
|
||||
|
||||
let cleaner = Cleaner::new(self.db.clone(), self.vfs_manager.clone());
|
||||
cleaner.clean()?;
|
||||
|
@ -38,9 +38,9 @@ impl Index {
|
|||
|
||||
let vfs = self.vfs_manager.get_vfs()?;
|
||||
let traverser_thread = std::thread::spawn(move || {
|
||||
let mount_points = vfs.get_mount_points();
|
||||
let mounts = vfs.mounts();
|
||||
let traverser = Traverser::new(collect_sender);
|
||||
traverser.traverse(mount_points.values().map(|p| p.clone()).collect());
|
||||
traverser.traverse(mounts.iter().map(|p| p.source.clone()).collect());
|
||||
});
|
||||
|
||||
if let Err(e) = traverser_thread.join() {
|
||||
|
|
|
@ -2,6 +2,7 @@ use anyhow::*;
|
|||
use rustfm_scrobble::{Scrobble, Scrobbler};
|
||||
use serde::Deserialize;
|
||||
use std::path::Path;
|
||||
use user::AuthToken;
|
||||
|
||||
use crate::app::{index::Index, user};
|
||||
|
||||
|
@ -39,6 +40,7 @@ struct AuthResponse {
|
|||
pub session: AuthResponseSession,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Manager {
|
||||
index: Index,
|
||||
user_manager: user::Manager,
|
||||
|
@ -52,12 +54,19 @@ impl Manager {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn link(&self, username: &str, token: &str) -> Result<()> {
|
||||
pub fn generate_link_token(&self, username: &str) -> Result<AuthToken> {
|
||||
self.user_manager
|
||||
.generate_lastfm_link_token(username)
|
||||
.map_err(|e| e.into())
|
||||
}
|
||||
|
||||
pub fn link(&self, username: &str, lastfm_token: &str) -> Result<()> {
|
||||
let mut scrobbler = Scrobbler::new(LASTFM_API_KEY.into(), LASTFM_API_SECRET.into());
|
||||
let auth_response = scrobbler.authenticate_with_token(token)?;
|
||||
let auth_response = scrobbler.authenticate_with_token(lastfm_token)?;
|
||||
|
||||
self.user_manager
|
||||
.lastfm_link(username, &auth_response.name, &auth_response.key)
|
||||
.map_err(|e| e.into())
|
||||
}
|
||||
|
||||
pub fn unlink(&self, username: &str) -> Result<()> {
|
||||
|
|
|
@ -3,6 +3,7 @@ pub mod ddns;
|
|||
pub mod index;
|
||||
pub mod lastfm;
|
||||
pub mod playlist;
|
||||
pub mod settings;
|
||||
pub mod thumbnail;
|
||||
pub mod user;
|
||||
pub mod vfs;
|
||||
|
|
|
@ -11,6 +11,7 @@ use crate::app::index::Song;
|
|||
use crate::app::vfs;
|
||||
use crate::db::{playlist_songs, playlists, users, DB};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Manager {
|
||||
db: DB,
|
||||
vfs_manager: vfs::Manager,
|
||||
|
|
|
@ -2,7 +2,7 @@ use core::clone::Clone;
|
|||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::*;
|
||||
use crate::app::{config, index::Index, user, vfs};
|
||||
use crate::app::{index::Index, settings, vfs};
|
||||
use crate::db;
|
||||
use crate::test_name;
|
||||
|
||||
|
@ -58,9 +58,8 @@ fn test_delete_playlist() {
|
|||
fn test_fill_playlist() {
|
||||
let db = db::get_test_db(&test_name!());
|
||||
let vfs_manager = vfs::Manager::new(db.clone());
|
||||
let user_manager = user::Manager::new(db.clone());
|
||||
let config_manager = config::Manager::new(db.clone(), user_manager);
|
||||
let index = Index::new(db.clone(), vfs_manager.clone(), config_manager);
|
||||
let settings_manager = settings::Manager::new(db.clone());
|
||||
let index = Index::new(db.clone(), vfs_manager.clone(), settings_manager);
|
||||
let manager = Manager::new(db, vfs_manager);
|
||||
|
||||
index.update().unwrap();
|
||||
|
|
21
src/app/settings/error.rs
Normal file
21
src/app/settings/error.rs
Normal file
|
@ -0,0 +1,21 @@
|
|||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum Error {
|
||||
#[error("Missing auth secret")]
|
||||
AuthSecretNotFound,
|
||||
#[error("Auth secret does not have the expected format")]
|
||||
InvalidAuthSecret,
|
||||
#[error("Missing index sleep duration")]
|
||||
IndexSleepDurationNotFound,
|
||||
#[error("Missing index album art pattern")]
|
||||
IndexAlbumArtPatternNotFound,
|
||||
#[error("Index album art pattern is not a valid regex")]
|
||||
IndexAlbumArtPatternInvalid,
|
||||
#[error("Unspecified")]
|
||||
Unspecified,
|
||||
}
|
||||
|
||||
impl From<anyhow::Error> for Error {
|
||||
fn from(_: anyhow::Error) -> Self {
|
||||
Error::Unspecified
|
||||
}
|
||||
}
|
97
src/app/settings/manager.rs
Normal file
97
src/app/settings/manager.rs
Normal file
|
@ -0,0 +1,97 @@
|
|||
use diesel;
|
||||
use diesel::prelude::*;
|
||||
use regex::Regex;
|
||||
use std::convert::TryInto;
|
||||
use std::time::Duration;
|
||||
|
||||
use super::*;
|
||||
use crate::db::{misc_settings, DB};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Manager {
|
||||
pub db: DB,
|
||||
}
|
||||
|
||||
impl Manager {
|
||||
pub fn new(db: DB) -> Self {
|
||||
Self { db }
|
||||
}
|
||||
|
||||
pub fn get_auth_secret(&self) -> Result<AuthSecret, Error> {
|
||||
use self::misc_settings::dsl::*;
|
||||
let connection = self.db.connect()?;
|
||||
let secret: Vec<u8> = misc_settings
|
||||
.select(auth_secret)
|
||||
.get_result(&connection)
|
||||
.map_err(|e| match e {
|
||||
diesel::result::Error::NotFound => Error::AuthSecretNotFound,
|
||||
_ => Error::Unspecified,
|
||||
})?;
|
||||
secret
|
||||
.try_into()
|
||||
.map_err(|_| Error::InvalidAuthSecret)
|
||||
.map(|key| AuthSecret { key })
|
||||
}
|
||||
|
||||
pub fn get_index_sleep_duration(&self) -> Result<Duration, Error> {
|
||||
use self::misc_settings::dsl::*;
|
||||
let connection = self.db.connect()?;
|
||||
misc_settings
|
||||
.select(index_sleep_duration_seconds)
|
||||
.get_result(&connection)
|
||||
.map_err(|e| match e {
|
||||
diesel::result::Error::NotFound => Error::IndexSleepDurationNotFound,
|
||||
_ => Error::Unspecified,
|
||||
})
|
||||
.map(|s: i32| Duration::from_secs(s as u64))
|
||||
}
|
||||
|
||||
pub fn get_index_album_art_pattern(&self) -> Result<Regex, Error> {
|
||||
use self::misc_settings::dsl::*;
|
||||
let connection = self.db.connect()?;
|
||||
misc_settings
|
||||
.select(index_album_art_pattern)
|
||||
.get_result(&connection)
|
||||
.map_err(|e| match e {
|
||||
diesel::result::Error::NotFound => Error::IndexAlbumArtPatternNotFound,
|
||||
_ => Error::Unspecified,
|
||||
})
|
||||
.and_then(|s: String| {
|
||||
Regex::new(&format!("(?i){}", &s)).map_err(|_| Error::IndexAlbumArtPatternInvalid)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn read(&self) -> Result<Settings, Error> {
|
||||
let connection = self.db.connect()?;
|
||||
|
||||
let misc: MiscSettings = misc_settings::table
|
||||
.get_result(&connection)
|
||||
.map_err(|_| Error::Unspecified)?;
|
||||
|
||||
Ok(Settings {
|
||||
auth_secret: misc.auth_secret,
|
||||
album_art_pattern: misc.index_album_art_pattern,
|
||||
reindex_every_n_seconds: misc.index_sleep_duration_seconds,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn amend(&self, new_settings: &NewSettings) -> Result<(), Error> {
|
||||
let connection = self.db.connect()?;
|
||||
|
||||
if let Some(sleep_duration) = new_settings.reindex_every_n_seconds {
|
||||
diesel::update(misc_settings::table)
|
||||
.set(misc_settings::index_sleep_duration_seconds.eq(sleep_duration as i32))
|
||||
.execute(&connection)
|
||||
.map_err(|_| Error::Unspecified)?;
|
||||
}
|
||||
|
||||
if let Some(ref album_art_pattern) = new_settings.album_art_pattern {
|
||||
diesel::update(misc_settings::table)
|
||||
.set(misc_settings::index_album_art_pattern.eq(album_art_pattern))
|
||||
.execute(&connection)
|
||||
.map_err(|_| Error::Unspecified)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
33
src/app/settings/mod.rs
Normal file
33
src/app/settings/mod.rs
Normal file
|
@ -0,0 +1,33 @@
|
|||
use serde::Deserialize;
|
||||
|
||||
mod error;
|
||||
mod manager;
|
||||
|
||||
pub use error::*;
|
||||
pub use manager::*;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct AuthSecret {
|
||||
pub key: [u8; 32],
|
||||
}
|
||||
|
||||
#[derive(Debug, Queryable)]
|
||||
struct MiscSettings {
|
||||
id: i32,
|
||||
auth_secret: Vec<u8>,
|
||||
index_sleep_duration_seconds: i32,
|
||||
index_album_art_pattern: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Settings {
|
||||
auth_secret: Vec<u8>,
|
||||
pub reindex_every_n_seconds: i32,
|
||||
pub album_art_pattern: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub struct NewSettings {
|
||||
pub reindex_every_n_seconds: Option<i32>,
|
||||
pub album_art_pattern: Option<String>,
|
||||
}
|
|
@ -1,25 +1,24 @@
|
|||
use anyhow::*;
|
||||
use image::imageops::FilterType;
|
||||
use image::{DynamicImage, GenericImage, GenericImageView, ImageBuffer};
|
||||
use std::cmp;
|
||||
use std::path::*;
|
||||
|
||||
use crate::app::thumbnail::{read, Options};
|
||||
|
||||
pub fn generate_thumbnail(image_path: &Path, thumbnailoptions: &Options) -> Result<DynamicImage> {
|
||||
let source_image = read(image_path)?;
|
||||
pub fn generate_thumbnail(image_path: &Path, options: &Options) -> Result<DynamicImage> {
|
||||
let source_image = DynamicImage::ImageRgb8(read(image_path)?.into_rgb8());
|
||||
let (source_width, source_height) = source_image.dimensions();
|
||||
let largest_dimension = cmp::max(source_width, source_height);
|
||||
let out_dimension = cmp::min(thumbnailoptions.max_dimension, largest_dimension);
|
||||
let out_dimension = cmp::min(options.max_dimension, largest_dimension);
|
||||
|
||||
let source_aspect_ratio: f32 = source_width as f32 / source_height as f32;
|
||||
let is_almost_square = source_aspect_ratio > 0.8 && source_aspect_ratio < 1.2;
|
||||
|
||||
let mut final_image;
|
||||
if is_almost_square && thumbnailoptions.resize_if_almost_square {
|
||||
final_image = source_image.resize_exact(out_dimension, out_dimension, FilterType::Lanczos3);
|
||||
} else if thumbnailoptions.pad_to_square {
|
||||
let scaled_image = source_image.resize(out_dimension, out_dimension, FilterType::Lanczos3);
|
||||
if is_almost_square && options.resize_if_almost_square {
|
||||
final_image = source_image.thumbnail_exact(out_dimension, out_dimension);
|
||||
} else if options.pad_to_square {
|
||||
let scaled_image = source_image.thumbnail(out_dimension, out_dimension);
|
||||
let (scaled_width, scaled_height) = scaled_image.dimensions();
|
||||
let background = image::Rgb([255, 255 as u8, 255 as u8]);
|
||||
final_image = DynamicImage::ImageRgb8(ImageBuffer::from_pixel(
|
||||
|
@ -33,7 +32,7 @@ pub fn generate_thumbnail(image_path: &Path, thumbnailoptions: &Options) -> Resu
|
|||
(out_dimension - scaled_height) / 2,
|
||||
)?;
|
||||
} else {
|
||||
final_image = source_image.resize(out_dimension, out_dimension, FilterType::Lanczos3);
|
||||
final_image = source_image.thumbnail(out_dimension, out_dimension);
|
||||
}
|
||||
|
||||
Ok(final_image)
|
||||
|
|
|
@ -7,6 +7,7 @@ use std::path::{Path, PathBuf};
|
|||
|
||||
use crate::app::thumbnail::*;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Manager {
|
||||
thumbnails_dir_path: PathBuf,
|
||||
}
|
||||
|
|
|
@ -1,7 +1,17 @@
|
|||
#[derive(thiserror::Error, Debug)]
|
||||
#[derive(thiserror::Error, Debug, PartialEq, Eq)]
|
||||
pub enum Error {
|
||||
#[error("Cannot use empty username")]
|
||||
EmptyUsername,
|
||||
#[error("Cannot use empty password")]
|
||||
EmptyPassword,
|
||||
#[error("Username does not exist")]
|
||||
IncorrectUsername,
|
||||
#[error("Password does not match username")]
|
||||
IncorrectPassword,
|
||||
#[error("Invalid auth token")]
|
||||
InvalidAuthToken,
|
||||
#[error("Incorrect authorization scope")]
|
||||
IncorrectAuthorizationScope,
|
||||
#[error("Unspecified")]
|
||||
Unspecified,
|
||||
}
|
||||
|
|
|
@ -1,33 +1,39 @@
|
|||
use anyhow::anyhow;
|
||||
use diesel;
|
||||
use diesel::prelude::*;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use super::*;
|
||||
use crate::app::settings::AuthSecret;
|
||||
use crate::db::DB;
|
||||
|
||||
const HASH_ITERATIONS: u32 = 10000;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Manager {
|
||||
// TODO make this private and move preferences methods in this file
|
||||
pub db: DB,
|
||||
auth_secret: AuthSecret,
|
||||
}
|
||||
|
||||
impl Manager {
|
||||
pub fn new(db: DB) -> Self {
|
||||
Self { db }
|
||||
pub fn new(db: DB, auth_secret: AuthSecret) -> Self {
|
||||
Self { db, auth_secret }
|
||||
}
|
||||
|
||||
pub fn create_user(&self, username: &str, password: &str) -> Result<(), Error> {
|
||||
if password.is_empty() {
|
||||
return Err(Error::EmptyPassword);
|
||||
pub fn create(&self, new_user: &NewUser) -> Result<(), Error> {
|
||||
if new_user.name.is_empty() {
|
||||
return Err(Error::EmptyUsername);
|
||||
}
|
||||
let password_hash = hash_password(password)?;
|
||||
|
||||
let password_hash = hash_password(&new_user.password)?;
|
||||
let connection = self.db.connect()?;
|
||||
let new_user = User {
|
||||
name: username.to_owned(),
|
||||
name: new_user.name.to_owned(),
|
||||
password_hash,
|
||||
admin: 0,
|
||||
admin: new_user.admin as i32,
|
||||
};
|
||||
|
||||
diesel::insert_into(users::table)
|
||||
.values(&new_user)
|
||||
.execute(&connection)
|
||||
|
@ -35,17 +41,37 @@ impl Manager {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_password(&self, username: &str, password: &str) -> Result<(), Error> {
|
||||
let password_hash = hash_password(password)?;
|
||||
pub fn delete(&self, username: &str) -> Result<(), Error> {
|
||||
use crate::db::users::dsl::*;
|
||||
let connection = self.db.connect()?;
|
||||
diesel::update(users::table.filter(users::name.eq(username)))
|
||||
.set(users::password_hash.eq(password_hash))
|
||||
diesel::delete(users.filter(name.eq(username)))
|
||||
.execute(&connection)
|
||||
.map_err(|_| Error::Unspecified)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn auth(&self, username: &str, password: &str) -> anyhow::Result<bool> {
|
||||
pub fn set_password(&self, username: &str, password: &str) -> Result<(), Error> {
|
||||
let hash = hash_password(password)?;
|
||||
let connection = self.db.connect()?;
|
||||
use crate::db::users::dsl::*;
|
||||
diesel::update(users.filter(name.eq(username)))
|
||||
.set(password_hash.eq(hash))
|
||||
.execute(&connection)
|
||||
.map_err(|_| Error::Unspecified)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_is_admin(&self, username: &str, is_admin: bool) -> Result<(), Error> {
|
||||
use crate::db::users::dsl::*;
|
||||
let connection = self.db.connect()?;
|
||||
diesel::update(users.filter(name.eq(username)))
|
||||
.set(admin.eq(is_admin as i32))
|
||||
.execute(&connection)
|
||||
.map_err(|_| Error::Unspecified)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn login(&self, username: &str, password: &str) -> Result<AuthToken, Error> {
|
||||
use crate::db::users::dsl::*;
|
||||
let connection = self.db.connect()?;
|
||||
match users
|
||||
|
@ -53,15 +79,71 @@ impl Manager {
|
|||
.filter(name.eq(username))
|
||||
.get_result(&connection)
|
||||
{
|
||||
Err(diesel::result::Error::NotFound) => Ok(false),
|
||||
Err(diesel::result::Error::NotFound) => Err(Error::IncorrectUsername),
|
||||
Ok(hash) => {
|
||||
let hash: String = hash;
|
||||
Ok(verify_password(&hash, password))
|
||||
if verify_password(&hash, password) {
|
||||
let authorization = Authorization {
|
||||
username: username.to_owned(),
|
||||
scope: AuthorizationScope::PolarisAuth,
|
||||
};
|
||||
self.generate_auth_token(&authorization)
|
||||
} else {
|
||||
Err(Error::IncorrectPassword)
|
||||
}
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
Err(_) => Err(Error::Unspecified),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn authenticate(
|
||||
&self,
|
||||
auth_token: &AuthToken,
|
||||
scope: AuthorizationScope,
|
||||
) -> Result<Authorization, Error> {
|
||||
let authorization = self.decode_auth_token(auth_token, scope)?;
|
||||
if self.exists(&authorization.username)? {
|
||||
Ok(authorization)
|
||||
} else {
|
||||
Err(Error::IncorrectUsername)
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_auth_token(
|
||||
&self,
|
||||
auth_token: &AuthToken,
|
||||
scope: AuthorizationScope,
|
||||
) -> Result<Authorization, Error> {
|
||||
let AuthToken(data) = auth_token;
|
||||
let ttl = match scope {
|
||||
AuthorizationScope::PolarisAuth => 0, // permanent
|
||||
AuthorizationScope::LastFMLink => 10 * 60, // 10 minutes
|
||||
};
|
||||
let authorization = branca::decode(data, &self.auth_secret.key, ttl)
|
||||
.map_err(|_| Error::InvalidAuthToken)?;
|
||||
let authorization: Authorization =
|
||||
serde_json::from_slice(&authorization[..]).map_err(|_| Error::InvalidAuthToken)?;
|
||||
if authorization.scope != scope {
|
||||
return Err(Error::IncorrectAuthorizationScope);
|
||||
}
|
||||
Ok(authorization)
|
||||
}
|
||||
|
||||
fn generate_auth_token(&self, authorization: &Authorization) -> Result<AuthToken, Error> {
|
||||
let serialized_authorization =
|
||||
serde_json::to_string(&authorization).map_err(|_| Error::Unspecified)?;
|
||||
branca::encode(
|
||||
serialized_authorization.as_bytes(),
|
||||
&self.auth_secret.key,
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_err(|_| Error::Unspecified)?
|
||||
.as_secs() as u32,
|
||||
)
|
||||
.map_err(|_| Error::Unspecified)
|
||||
.map(AuthToken)
|
||||
}
|
||||
|
||||
pub fn count(&self) -> anyhow::Result<i64> {
|
||||
use crate::db::users::dsl::*;
|
||||
let connection = self.db.connect()?;
|
||||
|
@ -69,23 +151,34 @@ impl Manager {
|
|||
Ok(count)
|
||||
}
|
||||
|
||||
pub fn exists(&self, username: &str) -> anyhow::Result<bool> {
|
||||
pub fn list(&self) -> Result<Vec<User>, Error> {
|
||||
use crate::db::users::dsl::*;
|
||||
let connection = self.db.connect()?;
|
||||
users
|
||||
.select((name, password_hash, admin))
|
||||
.get_results(&connection)
|
||||
.map_err(|_| Error::Unspecified)
|
||||
}
|
||||
|
||||
pub fn exists(&self, username: &str) -> Result<bool, Error> {
|
||||
use crate::db::users::dsl::*;
|
||||
let connection = self.db.connect()?;
|
||||
let results: Vec<String> = users
|
||||
.select(name)
|
||||
.filter(name.eq(username))
|
||||
.get_results(&connection)?;
|
||||
.get_results(&connection)
|
||||
.map_err(|_| Error::Unspecified)?;
|
||||
Ok(results.len() > 0)
|
||||
}
|
||||
|
||||
pub fn is_admin(&self, username: &str) -> anyhow::Result<bool> {
|
||||
pub fn is_admin(&self, username: &str) -> Result<bool, Error> {
|
||||
use crate::db::users::dsl::*;
|
||||
let connection = self.db.connect()?;
|
||||
let is_admin: i32 = users
|
||||
.filter(name.eq(username))
|
||||
.select(admin)
|
||||
.get_result(&connection)?;
|
||||
.get_result(&connection)
|
||||
.map_err(|_| Error::Unspecified)?;
|
||||
Ok(is_admin != 0)
|
||||
}
|
||||
|
||||
|
@ -94,7 +187,7 @@ impl Manager {
|
|||
username: &str,
|
||||
lastfm_login: &str,
|
||||
session_key: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
) -> Result<(), Error> {
|
||||
use crate::db::users::dsl::*;
|
||||
let connection = self.db.connect()?;
|
||||
diesel::update(users.filter(name.eq(username)))
|
||||
|
@ -102,10 +195,18 @@ impl Manager {
|
|||
lastfm_username.eq(lastfm_login),
|
||||
lastfm_session_key.eq(session_key),
|
||||
))
|
||||
.execute(&connection)?;
|
||||
.execute(&connection)
|
||||
.map_err(|_| Error::Unspecified)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn generate_lastfm_link_token(&self, username: &str) -> Result<AuthToken, Error> {
|
||||
self.generate_auth_token(&Authorization {
|
||||
username: username.to_owned(),
|
||||
scope: AuthorizationScope::LastFMLink,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_lastfm_session_key(&self, username: &str) -> anyhow::Result<String> {
|
||||
use crate::db::users::dsl::*;
|
||||
let connection = self.db.connect()?;
|
||||
|
@ -126,18 +227,19 @@ impl Manager {
|
|||
pub fn lastfm_unlink(&self, username: &str) -> anyhow::Result<()> {
|
||||
use crate::db::users::dsl::*;
|
||||
let connection = self.db.connect()?;
|
||||
let null: Option<String> = None;
|
||||
diesel::update(users.filter(name.eq(username)))
|
||||
.set((lastfm_session_key.eq(""), lastfm_username.eq("")))
|
||||
.set((lastfm_session_key.eq(&null), lastfm_username.eq(&null)))
|
||||
.execute(&connection)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn hash_password(password: &str) -> anyhow::Result<String> {
|
||||
match pbkdf2::pbkdf2_simple(password, HASH_ITERATIONS) {
|
||||
Ok(hash) => Ok(hash),
|
||||
Err(e) => Err(e.into()),
|
||||
fn hash_password(password: &str) -> Result<String, Error> {
|
||||
if password.is_empty() {
|
||||
return Err(Error::EmptyPassword);
|
||||
}
|
||||
pbkdf2::pbkdf2_simple(password, HASH_ITERATIONS).map_err(|_| Error::Unspecified)
|
||||
}
|
||||
|
||||
fn verify_password(password_hash: &str, attempted_password: &str) -> bool {
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::db::users;
|
||||
|
||||
mod error;
|
||||
|
@ -17,3 +19,31 @@ pub struct User {
|
|||
pub password_hash: String,
|
||||
pub admin: i32,
|
||||
}
|
||||
|
||||
impl User {
|
||||
pub fn is_admin(&self) -> bool {
|
||||
self.admin != 0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct NewUser {
|
||||
pub name: String,
|
||||
pub password: String,
|
||||
pub admin: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct AuthToken(pub String);
|
||||
|
||||
#[derive(Debug, PartialEq, Deserialize, Serialize)]
|
||||
pub enum AuthorizationScope {
|
||||
PolarisAuth,
|
||||
LastFMLink,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Deserialize, Serialize)]
|
||||
pub struct Authorization {
|
||||
pub username: String,
|
||||
pub scope: AuthorizationScope,
|
||||
}
|
||||
|
|
|
@ -1,11 +1,103 @@
|
|||
use super::*;
|
||||
use crate::db;
|
||||
use crate::app::settings;
|
||||
use crate::db::DB;
|
||||
use crate::test_name;
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn get_test_db(name: &str) -> DB {
|
||||
let mut db_path = std::path::PathBuf::new();
|
||||
db_path.push("test-output");
|
||||
std::fs::create_dir_all(&db_path).unwrap();
|
||||
|
||||
db_path.push(name);
|
||||
if db_path.exists() {
|
||||
std::fs::remove_file(&db_path).unwrap();
|
||||
}
|
||||
|
||||
DB::new(&db_path).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_preferences_read_write() {
|
||||
let db = db::get_test_db(&test_name!());
|
||||
let manager = Manager::new(db);
|
||||
fn create_delete_user_golden_path() {
|
||||
let db = get_test_db(&test_name!());
|
||||
let settings_manager = settings::Manager::new(db.clone());
|
||||
let auth_secret = settings_manager.get_auth_secret().unwrap();
|
||||
let user_manager = Manager::new(db, auth_secret);
|
||||
|
||||
let new_user = NewUser {
|
||||
name: "Walter".to_owned(),
|
||||
password: "super_secret!".to_owned(),
|
||||
admin: false,
|
||||
};
|
||||
|
||||
assert_eq!(user_manager.list().unwrap().len(), 0);
|
||||
user_manager.create(&new_user).unwrap();
|
||||
assert_eq!(user_manager.list().unwrap().len(), 1);
|
||||
user_manager.delete(&new_user.name).unwrap();
|
||||
assert_eq!(user_manager.list().unwrap().len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cannot_create_user_with_blank_username() {
|
||||
let db = get_test_db(&test_name!());
|
||||
let settings_manager = settings::Manager::new(db.clone());
|
||||
let auth_secret = settings_manager.get_auth_secret().unwrap();
|
||||
let user_manager = Manager::new(db, auth_secret);
|
||||
|
||||
let new_user = NewUser {
|
||||
name: "".to_owned(),
|
||||
password: "super_secret!".to_owned(),
|
||||
admin: false,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
user_manager.create(&new_user).unwrap_err(),
|
||||
Error::EmptyUsername
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cannot_create_user_with_blank_password() {
|
||||
let db = get_test_db(&test_name!());
|
||||
let settings_manager = settings::Manager::new(db.clone());
|
||||
let auth_secret = settings_manager.get_auth_secret().unwrap();
|
||||
let user_manager = Manager::new(db, auth_secret);
|
||||
|
||||
let new_user = NewUser {
|
||||
name: "Walter".to_owned(),
|
||||
password: "".to_owned(),
|
||||
admin: false,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
user_manager.create(&new_user).unwrap_err(),
|
||||
Error::EmptyPassword
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cannot_create_duplicate_user() {
|
||||
let db = get_test_db(&test_name!());
|
||||
let settings_manager = settings::Manager::new(db.clone());
|
||||
let auth_secret = settings_manager.get_auth_secret().unwrap();
|
||||
let user_manager = Manager::new(db, auth_secret);
|
||||
|
||||
let new_user = NewUser {
|
||||
name: "Walter".to_owned(),
|
||||
password: "super_secret!".to_owned(),
|
||||
admin: false,
|
||||
};
|
||||
|
||||
user_manager.create(&new_user).unwrap();
|
||||
user_manager.create(&new_user).unwrap_err();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn can_read_write_preferences() {
|
||||
let db = get_test_db(&test_name!());
|
||||
let settings_manager = settings::Manager::new(db.clone());
|
||||
let auth_secret = settings_manager.get_auth_secret().unwrap();
|
||||
let user_manager = Manager::new(db, auth_secret);
|
||||
|
||||
let new_preferences = Preferences {
|
||||
web_theme_base: Some("very-dark-theme".to_owned()),
|
||||
|
@ -13,12 +105,140 @@ fn test_preferences_read_write() {
|
|||
lastfm_username: None,
|
||||
};
|
||||
|
||||
manager.create_user("Walter", "super_secret!").unwrap();
|
||||
let new_user = NewUser {
|
||||
name: "Walter".to_owned(),
|
||||
password: "super_secret!".to_owned(),
|
||||
admin: false,
|
||||
};
|
||||
user_manager.create(&new_user).unwrap();
|
||||
|
||||
manager
|
||||
user_manager
|
||||
.write_preferences("Walter", &new_preferences)
|
||||
.unwrap();
|
||||
|
||||
let read_preferences = manager.read_preferences("Walter").unwrap();
|
||||
let read_preferences = user_manager.read_preferences("Walter").unwrap();
|
||||
assert_eq!(new_preferences, read_preferences);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_rejects_bad_password() {
|
||||
let db = get_test_db(&test_name!());
|
||||
let settings_manager = settings::Manager::new(db.clone());
|
||||
let auth_secret = settings_manager.get_auth_secret().unwrap();
|
||||
let user_manager = Manager::new(db, auth_secret);
|
||||
|
||||
let username = "Walter";
|
||||
let password = "super_secret!";
|
||||
|
||||
let new_user = NewUser {
|
||||
name: username.to_owned(),
|
||||
password: password.to_owned(),
|
||||
admin: false,
|
||||
};
|
||||
|
||||
user_manager.create(&new_user).unwrap();
|
||||
assert_eq!(
|
||||
user_manager
|
||||
.login(username, "not the password")
|
||||
.unwrap_err(),
|
||||
Error::IncorrectPassword
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_golden_path() {
|
||||
let db = get_test_db(&test_name!());
|
||||
let settings_manager = settings::Manager::new(db.clone());
|
||||
let auth_secret = settings_manager.get_auth_secret().unwrap();
|
||||
let user_manager = Manager::new(db, auth_secret);
|
||||
|
||||
let username = "Walter";
|
||||
let password = "super_secret!";
|
||||
|
||||
let new_user = NewUser {
|
||||
name: username.to_owned(),
|
||||
password: password.to_owned(),
|
||||
admin: false,
|
||||
};
|
||||
|
||||
user_manager.create(&new_user).unwrap();
|
||||
assert!(user_manager.login(username, password).is_ok())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authenticate_rejects_bad_token() {
|
||||
let db = get_test_db(&test_name!());
|
||||
let settings_manager = settings::Manager::new(db.clone());
|
||||
let auth_secret = settings_manager.get_auth_secret().unwrap();
|
||||
let user_manager = Manager::new(db, auth_secret);
|
||||
|
||||
let username = "Walter";
|
||||
let password = "super_secret!";
|
||||
|
||||
let new_user = NewUser {
|
||||
name: username.to_owned(),
|
||||
password: password.to_owned(),
|
||||
admin: false,
|
||||
};
|
||||
|
||||
user_manager.create(&new_user).unwrap();
|
||||
let fake_token = AuthToken("fake token".to_owned());
|
||||
assert!(user_manager
|
||||
.authenticate(&fake_token, AuthorizationScope::PolarisAuth)
|
||||
.is_err())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authenticate_golden_path() {
|
||||
let db = get_test_db(&test_name!());
|
||||
let settings_manager = settings::Manager::new(db.clone());
|
||||
let auth_secret = settings_manager.get_auth_secret().unwrap();
|
||||
let user_manager = Manager::new(db, auth_secret);
|
||||
|
||||
let username = "Walter";
|
||||
let password = "super_secret!";
|
||||
|
||||
let new_user = NewUser {
|
||||
name: username.to_owned(),
|
||||
password: password.to_owned(),
|
||||
admin: false,
|
||||
};
|
||||
|
||||
user_manager.create(&new_user).unwrap();
|
||||
let token = user_manager.login(username, password).unwrap();
|
||||
let authorization = user_manager
|
||||
.authenticate(&token, AuthorizationScope::PolarisAuth)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
authorization,
|
||||
Authorization {
|
||||
username: username.to_owned(),
|
||||
scope: AuthorizationScope::PolarisAuth,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authenticate_validates_scope() {
|
||||
let db = get_test_db(&test_name!());
|
||||
let settings_manager = settings::Manager::new(db.clone());
|
||||
let auth_secret = settings_manager.get_auth_secret().unwrap();
|
||||
let user_manager = Manager::new(db, auth_secret);
|
||||
|
||||
let username = "Walter";
|
||||
let password = "super_secret!";
|
||||
|
||||
let new_user = NewUser {
|
||||
name: username.to_owned(),
|
||||
password: password.to_owned(),
|
||||
admin: false,
|
||||
};
|
||||
|
||||
user_manager.create(&new_user).unwrap();
|
||||
let token = user_manager.generate_lastfm_link_token(username).unwrap();
|
||||
let authorization = user_manager.authenticate(&token, AuthorizationScope::PolarisAuth);
|
||||
assert_eq!(
|
||||
authorization.unwrap_err(),
|
||||
Error::IncorrectAuthorizationScope
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
use anyhow::*;
|
||||
use diesel::prelude::*;
|
||||
use std::path::Path;
|
||||
|
||||
use super::*;
|
||||
use crate::db::mount_points;
|
||||
|
@ -17,15 +16,27 @@ impl Manager {
|
|||
}
|
||||
|
||||
pub fn get_vfs(&self) -> Result<VFS> {
|
||||
let mount_dirs = self.mount_dirs()?;
|
||||
let mounts = mount_dirs.into_iter().map(|p| p.into()).collect();
|
||||
Ok(VFS::new(mounts))
|
||||
}
|
||||
|
||||
pub fn mount_dirs(&self) -> Result<Vec<MountDir>> {
|
||||
use self::mount_points::dsl::*;
|
||||
let mut vfs = VFS::new();
|
||||
let connection = self.db.connect()?;
|
||||
let points: Vec<MountPoint> = mount_points
|
||||
let mount_dirs: Vec<MountDir> = mount_points
|
||||
.select((source, name))
|
||||
.get_results(&connection)?;
|
||||
for point in points {
|
||||
vfs.mount(&Path::new(&point.source), &point.name)?;
|
||||
}
|
||||
Ok(vfs)
|
||||
Ok(mount_dirs)
|
||||
}
|
||||
|
||||
pub fn set_mount_dirs(&self, mount_dirs: &Vec<MountDir>) -> Result<()> {
|
||||
use self::mount_points::dsl::*;
|
||||
let connection = self.db.connect()?;
|
||||
diesel::delete(mount_points).execute(&connection)?;
|
||||
diesel::insert_into(mount_points)
|
||||
.values(mount_dirs)
|
||||
.execute(&*connection)?; // TODO https://github.com/diesel-rs/diesel/issues/1822
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
use anyhow::*;
|
||||
use core::ops::Deref;
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{self, Path, PathBuf};
|
||||
|
||||
use crate::db::mount_points;
|
||||
|
||||
|
@ -14,32 +14,44 @@ pub use manager::*;
|
|||
|
||||
#[derive(Clone, Debug, Deserialize, Insertable, PartialEq, Queryable, Serialize)]
|
||||
#[table_name = "mount_points"]
|
||||
pub struct MountPoint {
|
||||
pub struct MountDir {
|
||||
pub source: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
pub struct Mount {
|
||||
pub source: PathBuf,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
impl From<MountDir> for Mount {
|
||||
fn from(m: MountDir) -> Self {
|
||||
let separator_regex = Regex::new(r"\\|/").unwrap();
|
||||
let mut correct_separator = String::new();
|
||||
correct_separator.push(path::MAIN_SEPARATOR);
|
||||
let path_string = separator_regex.replace_all(&m.source, correct_separator.as_str());
|
||||
let source = PathBuf::from(path_string.deref());
|
||||
Self {
|
||||
name: m.name,
|
||||
source: source,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct VFS {
|
||||
mount_points: HashMap<String, PathBuf>,
|
||||
mounts: Vec<Mount>,
|
||||
}
|
||||
|
||||
impl VFS {
|
||||
pub fn new() -> VFS {
|
||||
VFS {
|
||||
mount_points: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mount(&mut self, real_path: &Path, name: &str) -> Result<()> {
|
||||
self.mount_points
|
||||
.insert(name.to_owned(), real_path.to_path_buf());
|
||||
Ok(())
|
||||
pub fn new(mounts: Vec<Mount>) -> VFS {
|
||||
VFS { mounts }
|
||||
}
|
||||
|
||||
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.as_ref().strip_prefix(target) {
|
||||
let mount_path = Path::new(&name);
|
||||
for mount in &self.mounts {
|
||||
if let Ok(p) = real_path.as_ref().strip_prefix(&mount.source) {
|
||||
let mount_path = Path::new(&mount.name);
|
||||
return if p.components().count() == 0 {
|
||||
Ok(mount_path.to_path_buf())
|
||||
} else {
|
||||
|
@ -51,20 +63,20 @@ impl VFS {
|
|||
}
|
||||
|
||||
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);
|
||||
for mount in &self.mounts {
|
||||
let mount_path = Path::new(&mount.name);
|
||||
if let Ok(p) = virtual_path.as_ref().strip_prefix(mount_path) {
|
||||
return if p.components().count() == 0 {
|
||||
Ok(target.clone())
|
||||
Ok(mount.source.clone())
|
||||
} else {
|
||||
Ok(target.join(p))
|
||||
Ok(mount.source.join(p))
|
||||
};
|
||||
}
|
||||
}
|
||||
bail!("Virtual path has no match in VFS")
|
||||
}
|
||||
|
||||
pub fn get_mount_points(&self) -> &HashMap<String, PathBuf> {
|
||||
&self.mount_points
|
||||
pub fn mounts(&self) -> &Vec<Mount> {
|
||||
&self.mounts
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,8 +4,10 @@ use super::*;
|
|||
|
||||
#[test]
|
||||
fn test_virtual_to_real() {
|
||||
let mut vfs = VFS::new();
|
||||
vfs.mount(Path::new("test_dir"), "root").unwrap();
|
||||
let vfs = VFS::new(vec![Mount {
|
||||
name: "root".to_owned(),
|
||||
source: Path::new("test_dir").to_owned(),
|
||||
}]);
|
||||
|
||||
let mut correct_path = PathBuf::new();
|
||||
correct_path.push("test_dir");
|
||||
|
@ -23,8 +25,10 @@ fn test_virtual_to_real() {
|
|||
|
||||
#[test]
|
||||
fn test_virtual_to_real_no_trail() {
|
||||
let mut vfs = VFS::new();
|
||||
vfs.mount(Path::new("test_dir"), "root").unwrap();
|
||||
let vfs = VFS::new(vec![Mount {
|
||||
name: "root".to_owned(),
|
||||
source: Path::new("test_dir").to_owned(),
|
||||
}]);
|
||||
let correct_path = Path::new("test_dir");
|
||||
let found_path = vfs.virtual_to_real(Path::new("root")).unwrap();
|
||||
assert!(found_path.to_str() == correct_path.to_str());
|
||||
|
@ -32,8 +36,10 @@ fn test_virtual_to_real_no_trail() {
|
|||
|
||||
#[test]
|
||||
fn test_real_to_virtual() {
|
||||
let mut vfs = VFS::new();
|
||||
vfs.mount(Path::new("test_dir"), "root").unwrap();
|
||||
let vfs = VFS::new(vec![Mount {
|
||||
name: "root".to_owned(),
|
||||
source: Path::new("test_dir").to_owned(),
|
||||
}]);
|
||||
|
||||
let mut correct_path = PathBuf::new();
|
||||
correct_path.push("root");
|
||||
|
@ -48,3 +54,42 @@ fn test_real_to_virtual() {
|
|||
let found_path = vfs.real_to_virtual(real_path.as_path()).unwrap();
|
||||
assert!(found_path == correct_path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clean_path_string() {
|
||||
let mut correct_path = path::PathBuf::new();
|
||||
if cfg!(target_os = "windows") {
|
||||
correct_path.push("C:\\");
|
||||
} else {
|
||||
correct_path.push("/usr");
|
||||
}
|
||||
correct_path.push("some");
|
||||
correct_path.push("path");
|
||||
|
||||
let tests = if cfg!(target_os = "windows") {
|
||||
vec![
|
||||
r#"C:/some/path"#,
|
||||
r#"C:\some\path"#,
|
||||
r#"C:\some\path\"#,
|
||||
r#"C:\some\path\\\\"#,
|
||||
r#"C:\some/path//"#,
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
r#"/usr/some/path"#,
|
||||
r#"/usr\some\path"#,
|
||||
r#"/usr\some\path\"#,
|
||||
r#"/usr\some\path\\\\"#,
|
||||
r#"/usr\some/path//"#,
|
||||
]
|
||||
};
|
||||
|
||||
for test in tests {
|
||||
let mount_dir = MountDir {
|
||||
source: test.to_owned(),
|
||||
name: "name".to_owned(),
|
||||
};
|
||||
let mount: Mount = mount_dir.into();
|
||||
assert_eq!(mount.source, correct_path);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -89,9 +89,7 @@ impl DB {
|
|||
|
||||
#[cfg(test)]
|
||||
pub fn get_test_db(name: &str) -> DB {
|
||||
use crate::app::{config, user};
|
||||
let config_path = Path::new("test-data/config.toml");
|
||||
let config = config::Config::from_path(&config_path).unwrap();
|
||||
use crate::app::{config, ddns, settings, user, vfs};
|
||||
|
||||
let mut db_path = std::path::PathBuf::new();
|
||||
db_path.push("test-output");
|
||||
|
@ -103,10 +101,17 @@ pub fn get_test_db(name: &str) -> DB {
|
|||
}
|
||||
|
||||
let db = DB::new(&db_path).unwrap();
|
||||
let user_manager = user::Manager::new(db.clone());
|
||||
let config_manager = config::Manager::new(db.clone(), user_manager);
|
||||
let settings_manager = settings::Manager::new(db.clone());
|
||||
let auth_secret = settings_manager.get_auth_secret().unwrap();
|
||||
let user_manager = user::Manager::new(db.clone(), auth_secret);
|
||||
let vfs_manager = vfs::Manager::new(db.clone());
|
||||
let ddns_manager = ddns::Manager::new(db.clone());
|
||||
let config_manager =
|
||||
config::Manager::new(settings_manager, user_manager, vfs_manager, ddns_manager);
|
||||
|
||||
config_manager.amend(&config).unwrap();
|
||||
let config_path = Path::new("test-data/config.toml");
|
||||
let config = config::Config::from_path(&config_path).unwrap();
|
||||
config_manager.apply(&config).unwrap();
|
||||
db
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
#![recursion_limit = "256"]
|
||||
#![feature(proc_macro_hygiene, decl_macro)]
|
||||
|
||||
#[macro_use]
|
||||
extern crate diesel;
|
||||
|
@ -149,10 +148,7 @@ fn main() -> Result<()> {
|
|||
context.index.begin_periodic_updates();
|
||||
|
||||
// Start DDNS updates
|
||||
let ddns_manager = app::ddns::Manager::new(context.db.clone());
|
||||
std::thread::spawn(move || {
|
||||
ddns_manager.run();
|
||||
});
|
||||
context.ddns_manager.begin_periodic_updates();
|
||||
|
||||
// Start server
|
||||
info!("Starting up server");
|
||||
|
|
859
src/service/actix/api.rs
Normal file
859
src/service/actix/api.rs
Normal file
|
@ -0,0 +1,859 @@
|
|||
use actix_files::NamedFile;
|
||||
use actix_web::{
|
||||
client::HttpError,
|
||||
delete,
|
||||
dev::{MessageBody, Payload, Service, ServiceRequest, ServiceResponse},
|
||||
error::{BlockingError, ErrorForbidden, ErrorInternalServerError, ErrorUnauthorized},
|
||||
get,
|
||||
http::StatusCode,
|
||||
post, put,
|
||||
web::{self, Data, Json, JsonConfig, ServiceConfig},
|
||||
FromRequest, HttpMessage, HttpRequest, HttpResponse, ResponseError,
|
||||
};
|
||||
use actix_web_httpauth::extractors::{basic::BasicAuth, bearer::BearerAuth};
|
||||
use cookie::{self, *};
|
||||
use futures_util::future::{err, ok};
|
||||
use percent_encoding::percent_decode_str;
|
||||
use std::future::Future;
|
||||
use std::ops::Deref;
|
||||
use std::path::Path;
|
||||
use std::pin::Pin;
|
||||
use std::str;
|
||||
|
||||
use crate::app::{
|
||||
config, ddns,
|
||||
index::{self, Index},
|
||||
lastfm, playlist, settings, thumbnail, user, vfs,
|
||||
};
|
||||
use crate::service::{dto, error::*};
|
||||
|
||||
pub fn make_config() -> impl FnOnce(&mut ServiceConfig) + Clone {
|
||||
move |cfg: &mut ServiceConfig| {
|
||||
let megabyte = 1024 * 1024;
|
||||
cfg.app_data(JsonConfig::default().limit(4 * megabyte)) // 4MB
|
||||
.service(version)
|
||||
.service(initial_setup)
|
||||
.service(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::IncorrectCredentials => StatusCode::UNAUTHORIZED,
|
||||
APIError::EmptyUsername => StatusCode::BAD_REQUEST,
|
||||
APIError::EmptyPassword => StatusCode::BAD_REQUEST,
|
||||
APIError::DeletingOwnAccount => StatusCode::CONFLICT,
|
||||
APIError::OwnAdminPrivilegeRemoval => StatusCode::CONFLICT,
|
||||
APIError::AudioFileIOError => StatusCode::NOT_FOUND,
|
||||
APIError::ThumbnailFileIOError => StatusCode::NOT_FOUND,
|
||||
APIError::LastFMAccountNotLinked => StatusCode::NO_CONTENT,
|
||||
APIError::LastFMLinkContentBase64DecodeError => StatusCode::BAD_REQUEST,
|
||||
APIError::LastFMLinkContentEncodingError => StatusCode::BAD_REQUEST,
|
||||
APIError::UserNotFound => StatusCode::NOT_FOUND,
|
||||
APIError::PlaylistNotFound => StatusCode::NOT_FOUND,
|
||||
APIError::VFSPathNotFound => StatusCode::NOT_FOUND,
|
||||
APIError::Unspecified => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Cookies {
|
||||
jar: CookieJar,
|
||||
key: Key,
|
||||
}
|
||||
|
||||
impl Cookies {
|
||||
fn new(key: Key) -> Self {
|
||||
let jar = CookieJar::new();
|
||||
Self { jar, key }
|
||||
}
|
||||
|
||||
fn add_original(&mut self, cookie: Cookie<'static>) {
|
||||
self.jar.add_original(cookie);
|
||||
}
|
||||
|
||||
fn add(&mut self, cookie: Cookie<'static>) {
|
||||
self.jar.add(cookie);
|
||||
}
|
||||
|
||||
fn add_signed(&mut self, cookie: Cookie<'static>) {
|
||||
self.jar.signed(&self.key).add(cookie);
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn get(&self, name: &str) -> Option<&Cookie> {
|
||||
self.jar.get(name)
|
||||
}
|
||||
|
||||
fn get_signed(&mut self, name: &str) -> Option<Cookie> {
|
||||
self.jar.signed(&self.key).get(name)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromRequest for Cookies {
|
||||
type Error = actix_web::Error;
|
||||
type Future = Pin<Box<dyn Future<Output = Result<Self, Self::Error>>>>;
|
||||
type Config = ();
|
||||
|
||||
fn from_request(request: &HttpRequest, _payload: &mut Payload) -> Self::Future {
|
||||
let request_cookies = match request.cookies() {
|
||||
Ok(c) => c,
|
||||
Err(_) => return Box::pin(err(ErrorInternalServerError(APIError::Unspecified))),
|
||||
};
|
||||
|
||||
let key = match request.app_data::<Data<Key>>() {
|
||||
Some(k) => k.as_ref(),
|
||||
None => return Box::pin(err(ErrorInternalServerError(APIError::Unspecified))),
|
||||
};
|
||||
|
||||
let mut cookies = Cookies::new(key.clone());
|
||||
for cookie in request_cookies.deref() {
|
||||
cookies.add_original(cookie.clone());
|
||||
}
|
||||
|
||||
Box::pin(ok(cookies))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum AuthSource {
|
||||
AuthorizationBasic,
|
||||
AuthorizationBearer,
|
||||
Cookie,
|
||||
QueryParameter,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Auth {
|
||||
username: String,
|
||||
source: AuthSource,
|
||||
}
|
||||
|
||||
impl FromRequest for Auth {
|
||||
type Error = actix_web::Error;
|
||||
type Future = Pin<Box<dyn Future<Output = Result<Self, Self::Error>>>>;
|
||||
type Config = ();
|
||||
|
||||
fn from_request(request: &HttpRequest, payload: &mut Payload) -> Self::Future {
|
||||
let user_manager = match request.app_data::<Data<user::Manager>>() {
|
||||
Some(m) => m.clone(),
|
||||
None => return Box::pin(err(ErrorInternalServerError(APIError::Unspecified))),
|
||||
};
|
||||
|
||||
let cookies_future = Cookies::from_request(request, payload);
|
||||
let basic_auth_future = BasicAuth::from_request(request, payload);
|
||||
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 session cookie
|
||||
{
|
||||
let mut cookies = cookies_future.await?;
|
||||
if let Some(session_cookie) = cookies.get_signed(dto::COOKIE_SESSION) {
|
||||
let username = session_cookie.value().to_string();
|
||||
let exists = block(move || user_manager.exists(&username)).await?;
|
||||
if !exists {
|
||||
return Err(ErrorUnauthorized(APIError::Unspecified));
|
||||
}
|
||||
return Ok(Auth {
|
||||
username: session_cookie.value().to_string(),
|
||||
source: AuthSource::Cookie,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Auth via bearer token in query parameter
|
||||
if let Ok(query) = query_params_future.await {
|
||||
let auth_token = user::AuthToken(query.auth_token.clone());
|
||||
let authorization = block(move || {
|
||||
user_manager.authenticate(&auth_token, user::AuthorizationScope::PolarisAuth)
|
||||
})
|
||||
.await?;
|
||||
return Ok(Auth {
|
||||
username: authorization.username.to_owned(),
|
||||
source: AuthSource::QueryParameter,
|
||||
});
|
||||
}
|
||||
|
||||
// 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());
|
||||
let authorization = block(move || {
|
||||
user_manager.authenticate(&auth_token, user::AuthorizationScope::PolarisAuth)
|
||||
})
|
||||
.await?;
|
||||
return Ok(Auth {
|
||||
username: authorization.username.to_owned(),
|
||||
source: AuthSource::AuthorizationBearer,
|
||||
});
|
||||
}
|
||||
|
||||
// Auth via basic authorization header
|
||||
{
|
||||
let basic_auth = basic_auth_future.await?;
|
||||
let username = basic_auth.user_id().to_string();
|
||||
let password = basic_auth
|
||||
.password()
|
||||
.map(|s| s.as_ref())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let auth_result = block(move || user_manager.login(&username, &password)).await;
|
||||
if auth_result.is_ok() {
|
||||
Ok(Auth {
|
||||
username: basic_auth.user_id().to_string(),
|
||||
source: AuthSource::AuthorizationBasic,
|
||||
})
|
||||
} else {
|
||||
Err(ErrorUnauthorized(APIError::Unspecified))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct AdminRights {
|
||||
auth: Option<Auth>,
|
||||
}
|
||||
|
||||
impl FromRequest for AdminRights {
|
||||
type Error = actix_web::Error;
|
||||
type Future = Pin<Box<dyn Future<Output = Result<Self, Self::Error>>>>;
|
||||
type Config = ();
|
||||
|
||||
fn from_request(request: &HttpRequest, payload: &mut Payload) -> Self::Future {
|
||||
let user_manager = match request.app_data::<Data<user::Manager>>() {
|
||||
Some(m) => m.clone(),
|
||||
None => return Box::pin(err(ErrorInternalServerError(APIError::Unspecified))),
|
||||
};
|
||||
|
||||
let auth_future = Auth::from_request(request, payload);
|
||||
|
||||
Box::pin(async move {
|
||||
let user_manager_count = user_manager.clone();
|
||||
let user_count = block(move || user_manager_count.count()).await;
|
||||
match user_count {
|
||||
Err(_) => return Err(ErrorInternalServerError(APIError::Unspecified)),
|
||||
Ok(0) => return Ok(AdminRights { auth: None }),
|
||||
_ => (),
|
||||
};
|
||||
|
||||
let auth = auth_future.await?;
|
||||
let username = auth.username.clone();
|
||||
let is_admin = block(move || user_manager.is_admin(&username)).await?;
|
||||
if is_admin {
|
||||
Ok(AdminRights { auth: Some(auth) })
|
||||
} else {
|
||||
Err(ErrorForbidden(APIError::Unspecified))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn http_auth_middleware<
|
||||
B: MessageBody + 'static,
|
||||
S: Service<Response = ServiceResponse<B>, Request = ServiceRequest, Error = actix_web::Error>
|
||||
+ 'static,
|
||||
>(
|
||||
request: ServiceRequest,
|
||||
service: &mut S,
|
||||
) -> Pin<Box<dyn Future<Output = Result<ServiceResponse<B>, actix_web::Error>>>> {
|
||||
let user_manager = match request.app_data::<Data<user::Manager>>() {
|
||||
Some(m) => m.clone(),
|
||||
None => return Box::pin(err(ErrorInternalServerError(APIError::Unspecified))),
|
||||
};
|
||||
|
||||
let (request, mut payload) = request.into_parts();
|
||||
let auth_future = Auth::from_request(&request, &mut payload);
|
||||
let cookies_future = Cookies::from_request(&request, &mut payload);
|
||||
let request = match ServiceRequest::from_parts(request, payload) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return Box::pin(err(ErrorInternalServerError(APIError::Unspecified))),
|
||||
};
|
||||
|
||||
let response_future = service.call(request);
|
||||
Box::pin(async move {
|
||||
let mut response = response_future.await?;
|
||||
if let Ok(auth) = auth_future.await {
|
||||
let set_cookies = match auth.source {
|
||||
AuthSource::AuthorizationBasic => true,
|
||||
AuthSource::AuthorizationBearer => false,
|
||||
AuthSource::Cookie => false,
|
||||
AuthSource::QueryParameter => false,
|
||||
};
|
||||
if set_cookies {
|
||||
let cookies = cookies_future.await?;
|
||||
let username = auth.username.clone();
|
||||
let is_admin = block(move || {
|
||||
user_manager
|
||||
.is_admin(&auth.username)
|
||||
.map_err(|_| APIError::Unspecified)
|
||||
})
|
||||
.await?;
|
||||
add_auth_cookies(response.response_mut(), &cookies, &username, is_admin)?;
|
||||
}
|
||||
}
|
||||
Ok(response)
|
||||
})
|
||||
}
|
||||
|
||||
fn add_auth_cookies<T>(
|
||||
response: &mut HttpResponse<T>,
|
||||
cookies: &Cookies,
|
||||
username: &str,
|
||||
is_admin: bool,
|
||||
) -> Result<(), HttpError> {
|
||||
let mut cookies = cookies.clone();
|
||||
|
||||
cookies.add_signed(
|
||||
Cookie::build(dto::COOKIE_SESSION, username.to_owned())
|
||||
.same_site(cookie::SameSite::Lax)
|
||||
.http_only(true)
|
||||
.permanent()
|
||||
.finish(),
|
||||
);
|
||||
|
||||
cookies.add(
|
||||
Cookie::build(dto::COOKIE_USERNAME, username.to_owned())
|
||||
.same_site(cookie::SameSite::Lax)
|
||||
.http_only(false)
|
||||
.permanent()
|
||||
.path("/")
|
||||
.finish(),
|
||||
);
|
||||
|
||||
cookies.add(
|
||||
Cookie::build(dto::COOKIE_ADMIN, format!("{}", is_admin))
|
||||
.same_site(cookie::SameSite::Lax)
|
||||
.http_only(false)
|
||||
.permanent()
|
||||
.path("/")
|
||||
.finish(),
|
||||
);
|
||||
|
||||
let headers = response.headers_mut();
|
||||
for cookie in cookies.jar.delta() {
|
||||
http::HeaderValue::from_str(&cookie.to_string()).map(|c| {
|
||||
headers.append(http::header::SET_COOKIE, c);
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn block<F, I, E>(f: F) -> Result<I, APIError>
|
||||
where
|
||||
F: FnOnce() -> Result<I, E> + Send + 'static,
|
||||
I: Send + 'static,
|
||||
E: Send + std::fmt::Debug + 'static + Into<APIError>,
|
||||
{
|
||||
actix_web::web::block(f).await.map_err(|e| match e {
|
||||
BlockingError::Error(e) => e.into(),
|
||||
BlockingError::Canceled => APIError::Unspecified,
|
||||
})
|
||||
}
|
||||
|
||||
#[get("/version")]
|
||||
async fn version() -> Json<dto::Version> {
|
||||
let current_version = dto::Version {
|
||||
major: dto::API_MAJOR_VERSION,
|
||||
minor: dto::API_MINOR_VERSION,
|
||||
};
|
||||
Json(current_version)
|
||||
}
|
||||
|
||||
#[get("/initial_setup")]
|
||||
async fn initial_setup(
|
||||
user_manager: Data<user::Manager>,
|
||||
) -> Result<Json<dto::InitialSetup>, APIError> {
|
||||
let initial_setup = block(move || -> Result<dto::InitialSetup, APIError> {
|
||||
let users = user_manager.list()?;
|
||||
let has_any_admin = users.iter().any(|u| u.is_admin());
|
||||
Ok(dto::InitialSetup {
|
||||
has_any_users: has_any_admin,
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
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> {
|
||||
block(move || 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 = block(move || 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> {
|
||||
block(move || 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 = block(move || 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 = new_mount_dirs
|
||||
.to_owned()
|
||||
.into_iter()
|
||||
.map(|m| m.into())
|
||||
.collect();
|
||||
block(move || 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 = block(move || 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> {
|
||||
block(move || 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 = block(move || 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();
|
||||
block(move || 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() {
|
||||
if user_update.new_is_admin == Some(false) {
|
||||
return Err(APIError::OwnAdminPrivilegeRemoval);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
block(move || -> Result<(), APIError> {
|
||||
if let Some(password) = &user_update.new_password {
|
||||
user_manager.set_password(&name, password)?;
|
||||
}
|
||||
if let Some(is_admin) = &user_update.new_is_admin {
|
||||
user_manager.set_is_admin(&name, *is_admin)?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.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);
|
||||
}
|
||||
}
|
||||
block(move || 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 = block(move || user_manager.read_preferences(&auth.username)).await?;
|
||||
Ok(Json(preferences))
|
||||
}
|
||||
|
||||
#[put("/preferences")]
|
||||
async fn put_preferences(
|
||||
user_manager: Data<user::Manager>,
|
||||
auth: Auth,
|
||||
preferences: Json<user::Preferences>,
|
||||
) -> Result<HttpResponse, APIError> {
|
||||
block(move || user_manager.write_preferences(&auth.username, &preferences)).await?;
|
||||
Ok(HttpResponse::new(StatusCode::OK))
|
||||
}
|
||||
|
||||
#[post("/trigger_index")]
|
||||
async fn trigger_index(
|
||||
index: Data<Index>,
|
||||
_admin_rights: AdminRights,
|
||||
) -> Result<HttpResponse, APIError> {
|
||||
index.trigger_reindex();
|
||||
Ok(HttpResponse::new(StatusCode::OK))
|
||||
}
|
||||
|
||||
#[post("/auth")]
|
||||
async fn login(
|
||||
user_manager: Data<user::Manager>,
|
||||
credentials: Json<dto::Credentials>,
|
||||
cookies: Cookies,
|
||||
) -> Result<HttpResponse, APIError> {
|
||||
let username = credentials.username.clone();
|
||||
let (user::AuthToken(token), is_admin) =
|
||||
block(move || -> Result<(user::AuthToken, bool), APIError> {
|
||||
let auth_token = user_manager.login(&credentials.username, &credentials.password)?;
|
||||
let is_admin = user_manager.is_admin(&credentials.username)?;
|
||||
Ok((auth_token, is_admin))
|
||||
})
|
||||
.await?;
|
||||
let authorization = dto::Authorization {
|
||||
username: username.clone(),
|
||||
token,
|
||||
is_admin,
|
||||
};
|
||||
let mut response = HttpResponse::Ok().json(authorization);
|
||||
add_auth_cookies(&mut response, &cookies, &username, is_admin)
|
||||
.map_err(|_| APIError::Unspecified)?;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
#[get("/browse")]
|
||||
async fn browse_root(
|
||||
index: Data<Index>,
|
||||
_auth: Auth,
|
||||
) -> Result<Json<Vec<index::CollectionFile>>, APIError> {
|
||||
let result = block(move || index.browse(Path::new(""))).await?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
#[get("/browse/{path:.*}")]
|
||||
async fn browse(
|
||||
index: Data<Index>,
|
||||
_auth: Auth,
|
||||
path: web::Path<String>,
|
||||
) -> Result<Json<Vec<index::CollectionFile>>, APIError> {
|
||||
let result = block(move || {
|
||||
let path = percent_decode_str(&(path.0)).decode_utf8_lossy();
|
||||
index.browse(Path::new(path.as_ref()))
|
||||
})
|
||||
.await?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
#[get("/flatten")]
|
||||
async fn flatten_root(index: Data<Index>, _auth: Auth) -> Result<Json<Vec<index::Song>>, APIError> {
|
||||
let songs = block(move || index.flatten(Path::new(""))).await?;
|
||||
Ok(Json(songs))
|
||||
}
|
||||
|
||||
#[get("/flatten/{path:.*}")]
|
||||
async fn flatten(
|
||||
index: Data<Index>,
|
||||
_auth: Auth,
|
||||
path: web::Path<String>,
|
||||
) -> Result<Json<Vec<index::Song>>, APIError> {
|
||||
let songs = block(move || {
|
||||
let path = percent_decode_str(&(path.0)).decode_utf8_lossy();
|
||||
index.flatten(Path::new(path.as_ref()))
|
||||
})
|
||||
.await?;
|
||||
Ok(Json(songs))
|
||||
}
|
||||
|
||||
#[get("/random")]
|
||||
async fn random(index: Data<Index>, _auth: Auth) -> Result<Json<Vec<index::Directory>>, APIError> {
|
||||
let result = block(move || index.get_random_albums(20)).await?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
#[get("/recent")]
|
||||
async fn recent(index: Data<Index>, _auth: Auth) -> Result<Json<Vec<index::Directory>>, APIError> {
|
||||
let result = block(move || index.get_recent_albums(20)).await?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
#[get("/search")]
|
||||
async fn search_root(
|
||||
index: Data<Index>,
|
||||
_auth: Auth,
|
||||
) -> Result<Json<Vec<index::CollectionFile>>, APIError> {
|
||||
let result = block(move || index.search("")).await?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
#[get("/search/{query:.*}")]
|
||||
async fn search(
|
||||
index: Data<Index>,
|
||||
_auth: Auth,
|
||||
query: web::Path<String>,
|
||||
) -> Result<Json<Vec<index::CollectionFile>>, APIError> {
|
||||
let result = block(move || index.search(&query)).await?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
#[get("/audio/{path:.*}")]
|
||||
async fn get_audio(
|
||||
vfs_manager: Data<vfs::Manager>,
|
||||
_auth: Auth,
|
||||
path: web::Path<String>,
|
||||
) -> Result<NamedFile, APIError> {
|
||||
let audio_path = block(move || {
|
||||
let vfs = vfs_manager.get_vfs()?;
|
||||
let path = percent_decode_str(&(path.0)).decode_utf8_lossy();
|
||||
vfs.virtual_to_real(Path::new(path.as_ref()))
|
||||
.map_err(|_| APIError::VFSPathNotFound)
|
||||
})
|
||||
.await?;
|
||||
|
||||
let named_file = NamedFile::open(&audio_path).map_err(|_| APIError::AudioFileIOError)?;
|
||||
Ok(named_file)
|
||||
}
|
||||
|
||||
#[get("/thumbnail/{path:.*}")]
|
||||
async fn get_thumbnail(
|
||||
vfs_manager: Data<vfs::Manager>,
|
||||
thumbnails_manager: Data<thumbnail::Manager>,
|
||||
_auth: Auth,
|
||||
path: web::Path<String>,
|
||||
options_input: web::Query<dto::ThumbnailOptions>,
|
||||
) -> Result<NamedFile, APIError> {
|
||||
let mut options = thumbnail::Options::default();
|
||||
options.pad_to_square = options_input.pad.unwrap_or(options.pad_to_square);
|
||||
|
||||
let thumbnail_path = block(move || {
|
||||
let vfs = vfs_manager.get_vfs()?;
|
||||
let path = percent_decode_str(&(path.0)).decode_utf8_lossy();
|
||||
let image_path = vfs
|
||||
.virtual_to_real(Path::new(path.as_ref()))
|
||||
.map_err(|_| APIError::VFSPathNotFound)?;
|
||||
thumbnails_manager
|
||||
.get_thumbnail(&image_path, &options)
|
||||
.map_err(|_| APIError::Unspecified)
|
||||
})
|
||||
.await?;
|
||||
|
||||
let named_file =
|
||||
NamedFile::open(&thumbnail_path).map_err(|_| APIError::ThumbnailFileIOError)?;
|
||||
|
||||
Ok(named_file)
|
||||
}
|
||||
|
||||
#[get("/playlists")]
|
||||
async fn list_playlists(
|
||||
playlist_manager: Data<playlist::Manager>,
|
||||
auth: Auth,
|
||||
) -> Result<Json<Vec<dto::ListPlaylistsEntry>>, APIError> {
|
||||
let playlist_names = block(move || playlist_manager.list_playlists(&auth.username)).await?;
|
||||
let playlists: Vec<dto::ListPlaylistsEntry> = playlist_names
|
||||
.into_iter()
|
||||
.map(|p| dto::ListPlaylistsEntry { name: p })
|
||||
.collect();
|
||||
|
||||
Ok(Json(playlists))
|
||||
}
|
||||
|
||||
#[put("/playlist/{name}")]
|
||||
async fn save_playlist(
|
||||
playlist_manager: Data<playlist::Manager>,
|
||||
auth: Auth,
|
||||
name: web::Path<String>,
|
||||
playlist: Json<dto::SavePlaylistInput>,
|
||||
) -> Result<HttpResponse, APIError> {
|
||||
block(move || playlist_manager.save_playlist(&name, &auth.username, &playlist.tracks)).await?;
|
||||
Ok(HttpResponse::new(StatusCode::OK))
|
||||
}
|
||||
|
||||
#[get("/playlist/{name}")]
|
||||
async fn read_playlist(
|
||||
playlist_manager: Data<playlist::Manager>,
|
||||
auth: Auth,
|
||||
name: web::Path<String>,
|
||||
) -> Result<Json<Vec<index::Song>>, APIError> {
|
||||
let songs = block(move || playlist_manager.read_playlist(&name, &auth.username)).await?;
|
||||
Ok(Json(songs))
|
||||
}
|
||||
|
||||
#[delete("/playlist/{name}")]
|
||||
async fn delete_playlist(
|
||||
playlist_manager: Data<playlist::Manager>,
|
||||
auth: Auth,
|
||||
name: web::Path<String>,
|
||||
) -> Result<HttpResponse, APIError> {
|
||||
block(move || playlist_manager.delete_playlist(&name, &auth.username)).await?;
|
||||
Ok(HttpResponse::new(StatusCode::OK))
|
||||
}
|
||||
|
||||
#[put("/lastfm/now_playing/{path:.*}")]
|
||||
async fn lastfm_now_playing(
|
||||
lastfm_manager: Data<lastfm::Manager>,
|
||||
user_manager: Data<user::Manager>,
|
||||
auth: Auth,
|
||||
path: web::Path<String>,
|
||||
) -> Result<HttpResponse, APIError> {
|
||||
block(move || -> Result<(), APIError> {
|
||||
if !user_manager.is_lastfm_linked(&auth.username) {
|
||||
return Err(APIError::LastFMAccountNotLinked);
|
||||
}
|
||||
let path = percent_decode_str(&(path.0)).decode_utf8_lossy();
|
||||
lastfm_manager.now_playing(&auth.username, Path::new(path.as_ref()))?;
|
||||
Ok(())
|
||||
})
|
||||
.await?;
|
||||
Ok(HttpResponse::new(StatusCode::OK))
|
||||
}
|
||||
|
||||
#[post("/lastfm/scrobble/{path:.*}")]
|
||||
async fn lastfm_scrobble(
|
||||
lastfm_manager: Data<lastfm::Manager>,
|
||||
user_manager: Data<user::Manager>,
|
||||
auth: Auth,
|
||||
path: web::Path<String>,
|
||||
) -> Result<HttpResponse, APIError> {
|
||||
block(move || -> Result<(), APIError> {
|
||||
if !user_manager.is_lastfm_linked(&auth.username) {
|
||||
return Err(APIError::LastFMAccountNotLinked);
|
||||
}
|
||||
let path = percent_decode_str(&(path.0)).decode_utf8_lossy();
|
||||
lastfm_manager.scrobble(&auth.username, Path::new(path.as_ref()))?;
|
||||
Ok(())
|
||||
})
|
||||
.await?;
|
||||
Ok(HttpResponse::new(StatusCode::OK))
|
||||
}
|
||||
|
||||
#[get("/lastfm/link_token")]
|
||||
async fn lastfm_link_token(
|
||||
lastfm_manager: Data<lastfm::Manager>,
|
||||
auth: Auth,
|
||||
) -> Result<Json<dto::LastFMLinkToken>, APIError> {
|
||||
let user::AuthToken(value) =
|
||||
block(move || lastfm_manager.generate_link_token(&auth.username)).await?;
|
||||
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 popup_content_string = block(move || {
|
||||
let auth_token = user::AuthToken(payload.auth_token.clone());
|
||||
let authorization =
|
||||
user_manager.authenticate(&auth_token, user::AuthorizationScope::LastFMLink)?;
|
||||
let lastfm_token = &payload.token;
|
||||
lastfm_manager.link(&authorization.username, lastfm_token)?;
|
||||
|
||||
// Percent decode
|
||||
let base64_content = percent_decode_str(&payload.content).decode_utf8_lossy();
|
||||
|
||||
// Base64 decode
|
||||
let popup_content = base64::decode(base64_content.as_bytes())
|
||||
.map_err(|_| APIError::LastFMLinkContentBase64DecodeError)?;
|
||||
|
||||
// UTF-8 decode
|
||||
str::from_utf8(&popup_content)
|
||||
.map_err(|_| APIError::LastFMLinkContentEncodingError)
|
||||
.map(|s| s.to_owned())
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(HttpResponse::build(StatusCode::OK)
|
||||
.content_type("text/html; charset=utf-8")
|
||||
.body(popup_content_string))
|
||||
}
|
||||
|
||||
#[delete("/lastfm/link")]
|
||||
async fn lastfm_unlink(
|
||||
lastfm_manager: Data<lastfm::Manager>,
|
||||
auth: Auth,
|
||||
) -> Result<HttpResponse, APIError> {
|
||||
block(move || lastfm_manager.unlink(&auth.username)).await?;
|
||||
Ok(HttpResponse::new(StatusCode::OK))
|
||||
}
|
65
src/service/actix/mod.rs
Normal file
65
src/service/actix/mod.rs
Normal file
|
@ -0,0 +1,65 @@
|
|||
use actix_web::{
|
||||
middleware::{normalize::TrailingSlash, Compress, Logger, NormalizePath},
|
||||
rt::System,
|
||||
web::{self, ServiceConfig},
|
||||
App, HttpServer,
|
||||
};
|
||||
use anyhow::*;
|
||||
use log::error;
|
||||
|
||||
use crate::service;
|
||||
|
||||
mod api;
|
||||
|
||||
#[cfg(test)]
|
||||
pub mod test;
|
||||
|
||||
pub fn make_config(context: service::Context) -> impl FnOnce(&mut ServiceConfig) + Clone {
|
||||
move |cfg: &mut ServiceConfig| {
|
||||
let encryption_key = cookie::Key::derive_from(&context.auth_secret.key[..]);
|
||||
cfg.app_data(web::Data::new(context.index))
|
||||
.app_data(web::Data::new(context.config_manager))
|
||||
.app_data(web::Data::new(context.ddns_manager))
|
||||
.app_data(web::Data::new(context.lastfm_manager))
|
||||
.app_data(web::Data::new(context.playlist_manager))
|
||||
.app_data(web::Data::new(context.settings_manager))
|
||||
.app_data(web::Data::new(context.thumbnail_manager))
|
||||
.app_data(web::Data::new(context.user_manager))
|
||||
.app_data(web::Data::new(context.vfs_manager))
|
||||
.app_data(web::Data::new(encryption_key))
|
||||
.service(
|
||||
web::scope(&context.api_url)
|
||||
.configure(api::make_config())
|
||||
.wrap_fn(api::http_auth_middleware)
|
||||
.wrap(NormalizePath::new(TrailingSlash::Trim)),
|
||||
)
|
||||
.service(
|
||||
actix_files::Files::new(&context.swagger_url, context.swagger_dir_path)
|
||||
.redirect_to_slash_directory()
|
||||
.index_file("index.html"),
|
||||
)
|
||||
.service(
|
||||
actix_files::Files::new(&context.web_url, context.web_dir_path)
|
||||
.redirect_to_slash_directory()
|
||||
.index_file("index.html"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run(context: service::Context) -> Result<()> {
|
||||
System::run(move || {
|
||||
let address = format!("0.0.0.0:{}", context.port);
|
||||
HttpServer::new(move || {
|
||||
App::new()
|
||||
.wrap(Logger::default())
|
||||
.wrap(Compress::default())
|
||||
.configure(make_config(context.clone()))
|
||||
})
|
||||
.disable_signals()
|
||||
.bind(address)
|
||||
.map(|server| server.run())
|
||||
.map_err(|e| error!("Error starting HTTP server: {:?}", e))
|
||||
.ok();
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
139
src/service/actix/test.rs
Normal file
139
src/service/actix/test.rs
Normal file
|
@ -0,0 +1,139 @@
|
|||
use actix_web::{
|
||||
middleware::{Compress, Logger},
|
||||
rt::{System, SystemRunner},
|
||||
test,
|
||||
test::*,
|
||||
web::Bytes,
|
||||
App,
|
||||
};
|
||||
use http::{response::Builder, Method, Request, Response};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::Serialize;
|
||||
use std::fs;
|
||||
use std::ops::Deref;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::service::actix::*;
|
||||
use crate::service::dto;
|
||||
use crate::service::test::TestService;
|
||||
|
||||
pub struct ActixTestService {
|
||||
system_runner: SystemRunner,
|
||||
authorization: Option<dto::Authorization>,
|
||||
server: TestServer,
|
||||
}
|
||||
|
||||
pub type ServiceType = ActixTestService;
|
||||
|
||||
impl ActixTestService {
|
||||
fn process_internal<T: Serialize + Clone + 'static>(
|
||||
&mut self,
|
||||
request: &Request<T>,
|
||||
) -> (Builder, Option<Bytes>) {
|
||||
let url = request.uri().to_string();
|
||||
let body = request.body().clone();
|
||||
|
||||
let mut actix_request = match *request.method() {
|
||||
Method::GET => self.server.get(url),
|
||||
Method::POST => self.server.post(url),
|
||||
Method::PUT => self.server.put(url),
|
||||
Method::DELETE => self.server.delete(url),
|
||||
_ => unimplemented!(),
|
||||
}
|
||||
.timeout(std::time::Duration::from_secs(30));
|
||||
|
||||
for (name, value) in request.headers() {
|
||||
actix_request = actix_request.set_header(name, value.clone());
|
||||
}
|
||||
|
||||
if let Some(ref authorization) = self.authorization {
|
||||
actix_request = actix_request.bearer_auth(&authorization.token);
|
||||
}
|
||||
|
||||
let mut actix_response = self
|
||||
.system_runner
|
||||
.block_on(async move { actix_request.send_json(&body).await.unwrap() });
|
||||
|
||||
let mut response_builder = Response::builder().status(actix_response.status());
|
||||
let headers = response_builder.headers_mut().unwrap();
|
||||
for (name, value) in actix_response.headers().iter() {
|
||||
headers.append(name, value.clone());
|
||||
}
|
||||
|
||||
let is_success = actix_response.status().is_success();
|
||||
let body = if is_success {
|
||||
Some(
|
||||
self.system_runner
|
||||
.block_on(async move { actix_response.body().await.unwrap() }),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
(response_builder, body)
|
||||
}
|
||||
}
|
||||
|
||||
impl TestService for ActixTestService {
|
||||
fn new(test_name: &str) -> Self {
|
||||
let mut db_path: PathBuf = ["test-output", test_name].iter().collect();
|
||||
fs::create_dir_all(&db_path).unwrap();
|
||||
db_path.push("db.sqlite");
|
||||
|
||||
if db_path.exists() {
|
||||
fs::remove_file(&db_path).unwrap();
|
||||
}
|
||||
|
||||
let context = service::ContextBuilder::new()
|
||||
.port(5050)
|
||||
.database_file_path(db_path)
|
||||
.web_dir_path(Path::new("test-data/web").into())
|
||||
.swagger_dir_path(["docs", "swagger"].iter().collect())
|
||||
.cache_dir_path(["test-output", test_name].iter().collect())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let system_runner = System::new("test");
|
||||
let server = test::start(move || {
|
||||
let config = make_config(context.clone());
|
||||
App::new()
|
||||
.wrap(Logger::default())
|
||||
.wrap(Compress::default())
|
||||
.configure(config)
|
||||
});
|
||||
|
||||
ActixTestService {
|
||||
authorization: None,
|
||||
system_runner,
|
||||
server,
|
||||
}
|
||||
}
|
||||
|
||||
fn fetch<T: Serialize + Clone + 'static>(&mut self, request: &Request<T>) -> Response<()> {
|
||||
let (response_builder, _body) = self.process_internal(request);
|
||||
response_builder.body(()).unwrap()
|
||||
}
|
||||
|
||||
fn fetch_bytes<T: Serialize + Clone + 'static>(
|
||||
&mut self,
|
||||
request: &Request<T>,
|
||||
) -> Response<Vec<u8>> {
|
||||
let (response_builder, body) = self.process_internal(request);
|
||||
response_builder
|
||||
.body(body.unwrap().deref().to_owned())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn fetch_json<T: Serialize + Clone + 'static, U: DeserializeOwned>(
|
||||
&mut self,
|
||||
request: &Request<T>,
|
||||
) -> Response<U> {
|
||||
let (response_builder, body) = self.process_internal(request);
|
||||
let body = serde_json::from_slice(&body.unwrap()).unwrap();
|
||||
response_builder.body(body).unwrap()
|
||||
}
|
||||
|
||||
fn set_authorization(&mut self, authorization: Option<dto::Authorization>) {
|
||||
self.authorization = authorization;
|
||||
}
|
||||
}
|
|
@ -1,6 +1,8 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const API_MAJOR_VERSION: i32 = 5;
|
||||
use crate::app::{config, ddns, settings, user, vfs};
|
||||
|
||||
pub const API_MAJOR_VERSION: i32 = 6;
|
||||
pub const API_MINOR_VERSION: i32 = 0;
|
||||
pub const COOKIE_SESSION: &str = "session";
|
||||
pub const COOKIE_USERNAME: &str = "username";
|
||||
|
@ -17,20 +19,189 @@ pub struct InitialSetup {
|
|||
pub has_any_users: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct AuthCredentials {
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct Credentials {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct Authorization {
|
||||
pub username: String,
|
||||
pub token: String,
|
||||
pub is_admin: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct AuthQueryParameters {
|
||||
pub auth_token: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct ThumbnailOptions {
|
||||
pub pad: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ListPlaylistsEntry {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct SavePlaylistInput {
|
||||
pub tracks: Vec<String>,
|
||||
}
|
||||
|
||||
// TODO: Config, Preferences, CollectionFile, Song and Directory should have dto types
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct LastFMLink {
|
||||
pub auth_token: String, // user::AuthToken emitted by Polaris, valid for LastFMLink scope
|
||||
pub token: String, // LastFM token for use in scrobble calls
|
||||
pub content: String, // Payload to send back to client after successful link
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct LastFMLinkToken {
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct User {
|
||||
pub name: String,
|
||||
pub is_admin: bool,
|
||||
}
|
||||
|
||||
impl From<user::User> for User {
|
||||
fn from(u: user::User) -> Self {
|
||||
Self {
|
||||
name: u.name,
|
||||
is_admin: u.admin != 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct NewUser {
|
||||
pub name: String,
|
||||
pub password: String,
|
||||
pub admin: bool,
|
||||
}
|
||||
|
||||
impl From<NewUser> for user::NewUser {
|
||||
fn from(u: NewUser) -> Self {
|
||||
Self {
|
||||
name: u.name,
|
||||
password: u.password,
|
||||
admin: u.admin,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct UserUpdate {
|
||||
pub new_password: Option<String>,
|
||||
pub new_is_admin: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
pub struct DDNSConfig {
|
||||
pub host: String,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
impl From<DDNSConfig> for ddns::Config {
|
||||
fn from(c: DDNSConfig) -> Self {
|
||||
Self {
|
||||
host: c.host,
|
||||
username: c.username,
|
||||
password: c.password,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ddns::Config> for DDNSConfig {
|
||||
fn from(c: ddns::Config) -> Self {
|
||||
Self {
|
||||
host: c.host,
|
||||
username: c.username,
|
||||
password: c.password,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
pub struct MountDir {
|
||||
pub source: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
impl From<MountDir> for vfs::MountDir {
|
||||
fn from(m: MountDir) -> Self {
|
||||
Self {
|
||||
name: m.name,
|
||||
source: m.source,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<vfs::MountDir> for MountDir {
|
||||
fn from(m: vfs::MountDir) -> Self {
|
||||
Self {
|
||||
name: m.name,
|
||||
source: m.source,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Config {
|
||||
pub settings: Option<NewSettings>,
|
||||
pub users: Option<Vec<NewUser>>,
|
||||
pub mount_dirs: Option<Vec<MountDir>>,
|
||||
pub ydns: Option<DDNSConfig>,
|
||||
}
|
||||
|
||||
impl From<Config> for config::Config {
|
||||
fn from(s: Config) -> Self {
|
||||
Self {
|
||||
settings: s.settings.map(|s| s.into()),
|
||||
mount_dirs: s
|
||||
.mount_dirs
|
||||
.map(|v| v.into_iter().map(|m| m.into()).collect()),
|
||||
users: s.users.map(|v| v.into_iter().map(|u| u.into()).collect()),
|
||||
ydns: s.ydns.map(|c| c.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct NewSettings {
|
||||
pub album_art_pattern: Option<String>,
|
||||
pub reindex_every_n_seconds: Option<i32>,
|
||||
}
|
||||
|
||||
impl From<NewSettings> for settings::NewSettings {
|
||||
fn from(s: NewSettings) -> Self {
|
||||
Self {
|
||||
album_art_pattern: s.album_art_pattern,
|
||||
reindex_every_n_seconds: s.reindex_every_n_seconds,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Settings {
|
||||
pub album_art_pattern: String,
|
||||
pub reindex_every_n_seconds: i32,
|
||||
}
|
||||
|
||||
impl From<settings::Settings> for Settings {
|
||||
fn from(s: settings::Settings) -> Self {
|
||||
Self {
|
||||
album_art_pattern: s.album_art_pattern,
|
||||
reindex_every_n_seconds: s.reindex_every_n_seconds,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Preferences, CollectionFile, Song and Directory should have dto types
|
||||
|
|
|
@ -1,11 +1,30 @@
|
|||
use thiserror::Error;
|
||||
|
||||
use crate::app::index::QueryError;
|
||||
use crate::app::{config, playlist, settings, user};
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum APIError {
|
||||
#[error("Incorrect Credentials")]
|
||||
IncorrectCredentials,
|
||||
#[error("Cannot remove own admin privilege")]
|
||||
#[error("EmptyUsername")]
|
||||
EmptyUsername,
|
||||
#[error("EmptyPassword")]
|
||||
EmptyPassword,
|
||||
#[error("Cannot delete your own account")]
|
||||
DeletingOwnAccount,
|
||||
#[error("Cannot remove your own admin privilege")]
|
||||
OwnAdminPrivilegeRemoval,
|
||||
#[error("Audio file could not be opened")]
|
||||
AudioFileIOError,
|
||||
#[error("Thumbnail file could not be opened")]
|
||||
ThumbnailFileIOError,
|
||||
#[error("No last.fm account has been linked")]
|
||||
LastFMAccountNotLinked,
|
||||
#[error("Could not decode content as base64 after linking last.fm account")]
|
||||
LastFMLinkContentBase64DecodeError,
|
||||
#[error("Could not decode content as UTF-8 after linking last.fm account")]
|
||||
LastFMLinkContentEncodingError,
|
||||
#[error("Path not found in virtual filesystem")]
|
||||
VFSPathNotFound,
|
||||
#[error("User not found")]
|
||||
|
@ -21,3 +40,57 @@ impl From<anyhow::Error> for APIError {
|
|||
APIError::Unspecified
|
||||
}
|
||||
}
|
||||
|
||||
impl From<config::Error> for APIError {
|
||||
fn from(error: config::Error) -> APIError {
|
||||
match error {
|
||||
config::Error::Unspecified => APIError::Unspecified,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<playlist::Error> for APIError {
|
||||
fn from(error: playlist::Error) -> APIError {
|
||||
match error {
|
||||
playlist::Error::PlaylistNotFound => APIError::PlaylistNotFound,
|
||||
playlist::Error::UserNotFound => APIError::UserNotFound,
|
||||
playlist::Error::Unspecified => APIError::Unspecified,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<QueryError> for APIError {
|
||||
fn from(error: QueryError) -> APIError {
|
||||
match error {
|
||||
QueryError::VFSPathNotFound => APIError::VFSPathNotFound,
|
||||
QueryError::Unspecified => APIError::Unspecified,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<settings::Error> for APIError {
|
||||
fn from(error: settings::Error) -> APIError {
|
||||
match error {
|
||||
settings::Error::AuthSecretNotFound => APIError::Unspecified,
|
||||
settings::Error::InvalidAuthSecret => APIError::Unspecified,
|
||||
settings::Error::IndexSleepDurationNotFound => APIError::Unspecified,
|
||||
settings::Error::IndexAlbumArtPatternNotFound => APIError::Unspecified,
|
||||
settings::Error::IndexAlbumArtPatternInvalid => APIError::Unspecified,
|
||||
settings::Error::Unspecified => APIError::Unspecified,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<user::Error> for APIError {
|
||||
fn from(error: user::Error) -> APIError {
|
||||
match error {
|
||||
user::Error::EmptyUsername => APIError::EmptyUsername,
|
||||
user::Error::EmptyPassword => APIError::EmptyPassword,
|
||||
user::Error::IncorrectUsername => APIError::IncorrectCredentials,
|
||||
user::Error::IncorrectPassword => APIError::IncorrectCredentials,
|
||||
user::Error::InvalidAuthToken => APIError::IncorrectCredentials,
|
||||
user::Error::IncorrectAuthorizationScope => APIError::IncorrectCredentials,
|
||||
user::Error::Unspecified => APIError::Unspecified,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::app::{config, index::Index, lastfm, playlist, thumbnail, user, vfs};
|
||||
use crate::app::{config, ddns, index::Index, lastfm, playlist, settings, thumbnail, user, vfs};
|
||||
use crate::db::DB;
|
||||
|
||||
mod dto;
|
||||
|
@ -10,14 +10,13 @@ mod error;
|
|||
#[cfg(test)]
|
||||
mod test;
|
||||
|
||||
#[cfg(feature = "service-rocket")]
|
||||
mod rocket;
|
||||
#[cfg(feature = "service-rocket")]
|
||||
pub use self::rocket::*;
|
||||
mod actix;
|
||||
pub use actix::*;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Context {
|
||||
pub port: u16,
|
||||
pub auth_secret: Vec<u8>,
|
||||
pub auth_secret: settings::AuthSecret,
|
||||
pub web_dir_path: PathBuf,
|
||||
pub swagger_dir_path: PathBuf,
|
||||
pub web_url: String,
|
||||
|
@ -26,8 +25,10 @@ pub struct Context {
|
|||
pub db: DB,
|
||||
pub index: Index,
|
||||
pub config_manager: config::Manager,
|
||||
pub ddns_manager: ddns::Manager,
|
||||
pub lastfm_manager: lastfm::Manager,
|
||||
pub playlist_manager: playlist::Manager,
|
||||
pub settings_manager: settings::Manager,
|
||||
pub thumbnail_manager: thumbnail::Manager,
|
||||
pub user_manager: user::Manager,
|
||||
pub vfs_manager: vfs::Manager,
|
||||
|
@ -82,18 +83,27 @@ impl ContextBuilder {
|
|||
thumbnails_dir_path.push("thumbnails");
|
||||
|
||||
let vfs_manager = vfs::Manager::new(db.clone());
|
||||
let user_manager = user::Manager::new(db.clone());
|
||||
let config_manager = config::Manager::new(db.clone(), user_manager.clone());
|
||||
let index = Index::new(db.clone(), vfs_manager.clone(), config_manager.clone());
|
||||
let settings_manager = settings::Manager::new(db.clone());
|
||||
let auth_secret = settings_manager.get_auth_secret()?;
|
||||
let ddns_manager = ddns::Manager::new(db.clone());
|
||||
let user_manager = user::Manager::new(db.clone(), auth_secret);
|
||||
let index = Index::new(db.clone(), vfs_manager.clone(), settings_manager.clone());
|
||||
let config_manager = config::Manager::new(
|
||||
settings_manager.clone(),
|
||||
user_manager.clone(),
|
||||
vfs_manager.clone(),
|
||||
ddns_manager.clone(),
|
||||
);
|
||||
let playlist_manager = playlist::Manager::new(db.clone(), vfs_manager.clone());
|
||||
let thumbnail_manager = thumbnail::Manager::new(thumbnails_dir_path);
|
||||
let lastfm_manager = lastfm::Manager::new(index.clone(), user_manager.clone());
|
||||
|
||||
if let Some(config_path) = self.config_file_path {
|
||||
let config = config::Config::from_path(&config_path)?;
|
||||
config_manager.amend(&config)?;
|
||||
config_manager.apply(&config)?;
|
||||
}
|
||||
let auth_secret = config_manager.get_auth_secret()?;
|
||||
|
||||
let auth_secret = settings_manager.get_auth_secret()?;
|
||||
|
||||
Ok(Context {
|
||||
port: self.port.unwrap_or(5050),
|
||||
|
@ -105,8 +115,10 @@ impl ContextBuilder {
|
|||
swagger_dir_path,
|
||||
index,
|
||||
config_manager,
|
||||
ddns_manager,
|
||||
lastfm_manager,
|
||||
playlist_manager,
|
||||
settings_manager,
|
||||
thumbnail_manager,
|
||||
user_manager,
|
||||
vfs_manager,
|
||||
|
|
|
@ -1,484 +0,0 @@
|
|||
use anyhow::*;
|
||||
use rocket::http::{Cookie, Cookies, RawStr, Status};
|
||||
use rocket::request::{self, FromParam, FromRequest, Request};
|
||||
use rocket::response::content::Html;
|
||||
use rocket::{delete, get, post, put, routes, Outcome, State};
|
||||
use rocket_contrib::json::Json;
|
||||
use std::default::Default;
|
||||
use std::fs::File;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::str;
|
||||
use std::str::FromStr;
|
||||
use time::Duration;
|
||||
|
||||
use super::serve;
|
||||
use crate::app::index::{self, Index, QueryError};
|
||||
use crate::app::{config, lastfm, playlist, thumbnail, user, vfs};
|
||||
use crate::service::dto;
|
||||
use crate::service::error::APIError;
|
||||
|
||||
pub fn get_routes() -> Vec<rocket::Route> {
|
||||
routes![
|
||||
version,
|
||||
initial_setup,
|
||||
get_settings,
|
||||
put_settings,
|
||||
get_preferences,
|
||||
put_preferences,
|
||||
trigger_index,
|
||||
auth,
|
||||
browse_root,
|
||||
browse,
|
||||
flatten_root,
|
||||
flatten,
|
||||
random,
|
||||
recent,
|
||||
search_root,
|
||||
search,
|
||||
audio,
|
||||
thumbnail,
|
||||
list_playlists,
|
||||
save_playlist,
|
||||
read_playlist,
|
||||
delete_playlist,
|
||||
lastfm_link,
|
||||
lastfm_unlink,
|
||||
lastfm_now_playing,
|
||||
lastfm_scrobble,
|
||||
]
|
||||
}
|
||||
|
||||
impl<'r> rocket::response::Responder<'r> for APIError {
|
||||
fn respond_to(self, _: &rocket::request::Request<'_>) -> rocket::response::Result<'r> {
|
||||
let status = match self {
|
||||
APIError::IncorrectCredentials => rocket::http::Status::Unauthorized,
|
||||
APIError::OwnAdminPrivilegeRemoval => rocket::http::Status::Conflict,
|
||||
APIError::VFSPathNotFound => rocket::http::Status::NotFound,
|
||||
APIError::UserNotFound => rocket::http::Status::NotFound,
|
||||
APIError::PlaylistNotFound => rocket::http::Status::NotFound,
|
||||
APIError::Unspecified => rocket::http::Status::InternalServerError,
|
||||
};
|
||||
rocket::response::Response::build().status(status).ok()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<playlist::Error> for APIError {
|
||||
fn from(error: playlist::Error) -> APIError {
|
||||
match error {
|
||||
playlist::Error::PlaylistNotFound => APIError::PlaylistNotFound,
|
||||
playlist::Error::UserNotFound => APIError::UserNotFound,
|
||||
playlist::Error::Unspecified => APIError::Unspecified,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<QueryError> for APIError {
|
||||
fn from(error: QueryError) -> APIError {
|
||||
match error {
|
||||
QueryError::VFSPathNotFound => APIError::VFSPathNotFound,
|
||||
QueryError::Unspecified => APIError::Unspecified,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Auth {
|
||||
username: String,
|
||||
}
|
||||
|
||||
fn add_session_cookies(cookies: &mut Cookies, username: &str, is_admin: bool) -> () {
|
||||
let duration = Duration::days(1);
|
||||
|
||||
let session_cookie = Cookie::build(dto::COOKIE_SESSION, username.to_owned())
|
||||
.same_site(rocket::http::SameSite::Lax)
|
||||
.http_only(true)
|
||||
.max_age(duration)
|
||||
.finish();
|
||||
|
||||
let username_cookie = Cookie::build(dto::COOKIE_USERNAME, username.to_owned())
|
||||
.same_site(rocket::http::SameSite::Lax)
|
||||
.http_only(false)
|
||||
.max_age(duration)
|
||||
.path("/")
|
||||
.finish();
|
||||
|
||||
let is_admin_cookie = Cookie::build(dto::COOKIE_ADMIN, format!("{}", is_admin))
|
||||
.same_site(rocket::http::SameSite::Lax)
|
||||
.http_only(false)
|
||||
.max_age(duration)
|
||||
.path("/")
|
||||
.finish();
|
||||
|
||||
cookies.add_private(session_cookie);
|
||||
cookies.add(username_cookie);
|
||||
cookies.add(is_admin_cookie);
|
||||
}
|
||||
|
||||
impl<'a, 'r> FromRequest<'a, 'r> for Auth {
|
||||
type Error = ();
|
||||
|
||||
fn from_request(request: &'a Request<'r>) -> request::Outcome<Self, ()> {
|
||||
let mut cookies = request.guard::<Cookies<'_>>().unwrap();
|
||||
let user_manager = match request.guard::<State<'_, user::Manager>>() {
|
||||
Outcome::Success(d) => d,
|
||||
_ => return Outcome::Failure((Status::InternalServerError, ())),
|
||||
};
|
||||
|
||||
if let Some(u) = cookies.get_private(dto::COOKIE_SESSION) {
|
||||
let exists = match user_manager.exists(u.value()) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return Outcome::Failure((Status::InternalServerError, ())),
|
||||
};
|
||||
if !exists {
|
||||
return Outcome::Failure((Status::Unauthorized, ()));
|
||||
}
|
||||
return Outcome::Success(Auth {
|
||||
username: u.value().to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(auth_header_string) = request.headers().get_one("Authorization") {
|
||||
use rocket::http::hyper::header::*;
|
||||
if let Ok(Basic {
|
||||
username,
|
||||
password: Some(password),
|
||||
}) = Basic::from_str(auth_header_string.trim_start_matches("Basic "))
|
||||
{
|
||||
if user_manager.auth(&username, &password).unwrap_or(false) {
|
||||
let is_admin = match user_manager.is_admin(&username) {
|
||||
Ok(a) => a,
|
||||
Err(_) => return Outcome::Failure((Status::InternalServerError, ())),
|
||||
};
|
||||
add_session_cookies(&mut cookies, &username, is_admin);
|
||||
return Outcome::Success(Auth {
|
||||
username: username.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Outcome::Failure((Status::Unauthorized, ()))
|
||||
}
|
||||
}
|
||||
|
||||
struct AdminRights {
|
||||
auth: Option<Auth>,
|
||||
}
|
||||
|
||||
impl<'a, 'r> FromRequest<'a, 'r> for AdminRights {
|
||||
type Error = ();
|
||||
|
||||
fn from_request(request: &'a Request<'r>) -> request::Outcome<Self, ()> {
|
||||
let user_manager = request.guard::<State<'_, user::Manager>>()?;
|
||||
|
||||
match user_manager.count() {
|
||||
Err(_) => return Outcome::Failure((Status::InternalServerError, ())),
|
||||
Ok(0) => return Outcome::Success(AdminRights { auth: None }),
|
||||
_ => (),
|
||||
};
|
||||
|
||||
let auth = request.guard::<Auth>()?;
|
||||
match user_manager.is_admin(&auth.username) {
|
||||
Err(_) => Outcome::Failure((Status::InternalServerError, ())),
|
||||
Ok(true) => Outcome::Success(AdminRights { auth: Some(auth) }),
|
||||
Ok(false) => Outcome::Failure((Status::Forbidden, ())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct VFSPathBuf {
|
||||
path_buf: PathBuf,
|
||||
}
|
||||
|
||||
impl<'r> FromParam<'r> for VFSPathBuf {
|
||||
type Error = &'r RawStr;
|
||||
|
||||
fn from_param(param: &'r RawStr) -> Result<Self, Self::Error> {
|
||||
let decoded_path = param.percent_decode_lossy();
|
||||
Ok(VFSPathBuf {
|
||||
path_buf: PathBuf::from(decoded_path.into_owned()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl From<VFSPathBuf> for PathBuf {
|
||||
fn from(vfs_path_buf: VFSPathBuf) -> Self {
|
||||
vfs_path_buf.path_buf.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[get("/version")]
|
||||
fn version() -> Json<dto::Version> {
|
||||
let current_version = dto::Version {
|
||||
major: dto::API_MAJOR_VERSION,
|
||||
minor: dto::API_MINOR_VERSION,
|
||||
};
|
||||
Json(current_version)
|
||||
}
|
||||
|
||||
#[get("/initial_setup")]
|
||||
fn initial_setup(user_manager: State<'_, user::Manager>) -> Result<Json<dto::InitialSetup>> {
|
||||
let initial_setup = dto::InitialSetup {
|
||||
has_any_users: user_manager.count()? > 0,
|
||||
};
|
||||
Ok(Json(initial_setup))
|
||||
}
|
||||
|
||||
#[get("/settings")]
|
||||
fn get_settings(
|
||||
config_manager: State<'_, config::Manager>,
|
||||
_admin_rights: AdminRights,
|
||||
) -> Result<Json<config::Config>> {
|
||||
let config = config_manager.read()?;
|
||||
Ok(Json(config))
|
||||
}
|
||||
|
||||
#[put("/settings", data = "<config>")]
|
||||
fn put_settings(
|
||||
config_manager: State<'_, config::Manager>,
|
||||
admin_rights: AdminRights,
|
||||
config: Json<config::Config>,
|
||||
) -> Result<(), APIError> {
|
||||
// Do not let users remove their own admin rights
|
||||
if let Some(auth) = &admin_rights.auth {
|
||||
if let Some(users) = &config.users {
|
||||
for user in users {
|
||||
if auth.username == user.name && !user.admin {
|
||||
return Err(APIError::OwnAdminPrivilegeRemoval);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
config_manager.amend(&config)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[get("/preferences")]
|
||||
fn get_preferences(
|
||||
user_manager: State<'_, user::Manager>,
|
||||
auth: Auth,
|
||||
) -> Result<Json<user::Preferences>> {
|
||||
let preferences = user_manager.read_preferences(&auth.username)?;
|
||||
Ok(Json(preferences))
|
||||
}
|
||||
|
||||
#[put("/preferences", data = "<preferences>")]
|
||||
fn put_preferences(
|
||||
user_manager: State<'_, user::Manager>,
|
||||
auth: Auth,
|
||||
preferences: Json<user::Preferences>,
|
||||
) -> Result<()> {
|
||||
user_manager.write_preferences(&auth.username, &preferences)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[post("/trigger_index")]
|
||||
fn trigger_index(index: State<'_, Index>, _admin_rights: AdminRights) -> Result<()> {
|
||||
index.trigger_reindex();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[post("/auth", data = "<credentials>")]
|
||||
fn auth(
|
||||
user_manager: State<'_, user::Manager>,
|
||||
credentials: Json<dto::AuthCredentials>,
|
||||
mut cookies: Cookies<'_>,
|
||||
) -> std::result::Result<(), APIError> {
|
||||
if !user_manager.auth(&credentials.username, &credentials.password)? {
|
||||
return Err(APIError::IncorrectCredentials);
|
||||
}
|
||||
let is_admin = user_manager.is_admin(&credentials.username)?;
|
||||
add_session_cookies(&mut cookies, &credentials.username, is_admin);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[get("/browse")]
|
||||
fn browse_root(index: State<'_, Index>, _auth: Auth) -> Result<Json<Vec<index::CollectionFile>>> {
|
||||
let result = index.browse(&Path::new(""))?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
#[get("/browse/<path>")]
|
||||
fn browse(
|
||||
index: State<'_, Index>,
|
||||
_auth: Auth,
|
||||
path: VFSPathBuf,
|
||||
) -> Result<Json<Vec<index::CollectionFile>>, APIError> {
|
||||
let result = index.browse(&path.into() as &PathBuf)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
#[get("/flatten")]
|
||||
fn flatten_root(index: State<'_, Index>, _auth: Auth) -> Result<Json<Vec<index::Song>>> {
|
||||
let result = index.flatten(&PathBuf::new())?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
#[get("/flatten/<path>")]
|
||||
fn flatten(
|
||||
index: State<'_, Index>,
|
||||
_auth: Auth,
|
||||
path: VFSPathBuf,
|
||||
) -> Result<Json<Vec<index::Song>>, APIError> {
|
||||
let result = index.flatten(&path.into() as &PathBuf)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
#[get("/random")]
|
||||
fn random(index: State<'_, Index>, _auth: Auth) -> Result<Json<Vec<index::Directory>>> {
|
||||
let result = index.get_random_albums(20)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
#[get("/recent")]
|
||||
fn recent(index: State<'_, Index>, _auth: Auth) -> Result<Json<Vec<index::Directory>>> {
|
||||
let result = index.get_recent_albums(20)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
#[get("/search")]
|
||||
fn search_root(index: State<'_, Index>, _auth: Auth) -> Result<Json<Vec<index::CollectionFile>>> {
|
||||
let result = index.search("")?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
#[get("/search/<query>")]
|
||||
fn search(
|
||||
index: State<'_, Index>,
|
||||
_auth: Auth,
|
||||
query: String,
|
||||
) -> Result<Json<Vec<index::CollectionFile>>> {
|
||||
let result = index.search(&query)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
#[get("/audio/<path>")]
|
||||
fn audio(
|
||||
vfs_manager: State<'_, vfs::Manager>,
|
||||
_auth: Auth,
|
||||
path: VFSPathBuf,
|
||||
) -> Result<serve::RangeResponder<File>, APIError> {
|
||||
let vfs = vfs_manager.get_vfs()?;
|
||||
let real_path = vfs
|
||||
.virtual_to_real(&path.into() as &PathBuf)
|
||||
.map_err(|_| APIError::VFSPathNotFound)?;
|
||||
let file = File::open(&real_path).map_err(|_| APIError::Unspecified)?;
|
||||
Ok(serve::RangeResponder::new(file))
|
||||
}
|
||||
|
||||
#[get("/thumbnail/<path>?<pad>")]
|
||||
fn thumbnail(
|
||||
vfs_manager: State<'_, vfs::Manager>,
|
||||
thumbnail_manager: State<'_, thumbnail::Manager>,
|
||||
_auth: Auth,
|
||||
path: VFSPathBuf,
|
||||
pad: Option<bool>,
|
||||
) -> Result<File, APIError> {
|
||||
let vfs = vfs_manager.get_vfs()?;
|
||||
let image_path = vfs
|
||||
.virtual_to_real(&path.into() as &PathBuf)
|
||||
.map_err(|_| APIError::VFSPathNotFound)?;
|
||||
let mut options = thumbnail::Options::default();
|
||||
options.pad_to_square = pad.unwrap_or(options.pad_to_square);
|
||||
let thumbnail_path = thumbnail_manager.get_thumbnail(&image_path, &options)?;
|
||||
let file = File::open(thumbnail_path).map_err(|_| APIError::Unspecified)?;
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
#[get("/playlists")]
|
||||
fn list_playlists(
|
||||
playlist_manager: State<'_, playlist::Manager>,
|
||||
auth: Auth,
|
||||
) -> Result<Json<Vec<dto::ListPlaylistsEntry>>> {
|
||||
let playlist_names = playlist_manager.list_playlists(&auth.username)?;
|
||||
let playlists: Vec<dto::ListPlaylistsEntry> = playlist_names
|
||||
.into_iter()
|
||||
.map(|p| dto::ListPlaylistsEntry { name: p })
|
||||
.collect();
|
||||
|
||||
Ok(Json(playlists))
|
||||
}
|
||||
|
||||
#[put("/playlist/<name>", data = "<playlist>")]
|
||||
fn save_playlist(
|
||||
playlist_manager: State<'_, playlist::Manager>,
|
||||
auth: Auth,
|
||||
name: String,
|
||||
playlist: Json<dto::SavePlaylistInput>,
|
||||
) -> Result<()> {
|
||||
playlist_manager.save_playlist(&name, &auth.username, &playlist.tracks)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[get("/playlist/<name>")]
|
||||
fn read_playlist(
|
||||
playlist_manager: State<'_, playlist::Manager>,
|
||||
auth: Auth,
|
||||
name: String,
|
||||
) -> Result<Json<Vec<index::Song>>, APIError> {
|
||||
let songs = playlist_manager.read_playlist(&name, &auth.username)?;
|
||||
Ok(Json(songs))
|
||||
}
|
||||
|
||||
#[delete("/playlist/<name>")]
|
||||
fn delete_playlist(
|
||||
playlist_manager: State<'_, playlist::Manager>,
|
||||
auth: Auth,
|
||||
name: String,
|
||||
) -> Result<(), APIError> {
|
||||
playlist_manager.delete_playlist(&name, &auth.username)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[put("/lastfm/now_playing/<path>")]
|
||||
fn lastfm_now_playing(
|
||||
user_manager: State<'_, user::Manager>,
|
||||
lastfm_manager: State<'_, lastfm::Manager>,
|
||||
auth: Auth,
|
||||
path: VFSPathBuf,
|
||||
) -> Result<()> {
|
||||
if user_manager.is_lastfm_linked(&auth.username) {
|
||||
lastfm_manager.now_playing(&auth.username, &path.into() as &PathBuf)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[post("/lastfm/scrobble/<path>")]
|
||||
fn lastfm_scrobble(
|
||||
user_manager: State<'_, user::Manager>,
|
||||
lastfm_manager: State<'_, lastfm::Manager>,
|
||||
auth: Auth,
|
||||
path: VFSPathBuf,
|
||||
) -> Result<()> {
|
||||
if user_manager.is_lastfm_linked(&auth.username) {
|
||||
lastfm_manager.scrobble(&auth.username, &path.into() as &PathBuf)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[get("/lastfm/link?<token>&<content>")]
|
||||
fn lastfm_link(
|
||||
lastfm_manager: State<'_, lastfm::Manager>,
|
||||
auth: Auth,
|
||||
token: String,
|
||||
content: String,
|
||||
) -> Result<Html<String>> {
|
||||
lastfm_manager.link(&auth.username, &token)?;
|
||||
|
||||
// Percent decode
|
||||
let base64_content = RawStr::from_str(&content).percent_decode()?;
|
||||
|
||||
// Base64 decode
|
||||
let popup_content = base64::decode(base64_content.as_bytes())?;
|
||||
|
||||
// UTF-8 decode
|
||||
let popup_content_string = str::from_utf8(&popup_content)?;
|
||||
|
||||
Ok(Html(popup_content_string.to_string()))
|
||||
}
|
||||
|
||||
#[delete("/lastfm/link")]
|
||||
fn lastfm_unlink(lastfm_manager: State<'_, lastfm::Manager>, auth: Auth) -> Result<()> {
|
||||
lastfm_manager.unlink(&auth.username)?;
|
||||
Ok(())
|
||||
}
|
|
@ -1,53 +0,0 @@
|
|||
use anyhow::*;
|
||||
use rocket;
|
||||
use rocket::config::{Environment, LoggingLevel};
|
||||
use rocket_contrib::serve::{Options, StaticFiles};
|
||||
|
||||
use crate::service;
|
||||
|
||||
mod api;
|
||||
mod serve;
|
||||
|
||||
#[cfg(test)]
|
||||
pub mod test;
|
||||
|
||||
pub fn get_server(context: service::Context) -> Result<rocket::Rocket> {
|
||||
let mut config = rocket::Config::build(Environment::Production)
|
||||
.log_level(LoggingLevel::Normal)
|
||||
.port(context.port)
|
||||
.keep_alive(0)
|
||||
.finalize()?;
|
||||
|
||||
let encoded = base64::encode(&context.auth_secret);
|
||||
config.set_secret_key(encoded)?;
|
||||
|
||||
let swagger_routes_rank = 0;
|
||||
let web_routes_rank = swagger_routes_rank + 1;
|
||||
let static_file_options = Options::Index | Options::NormalizeDirs;
|
||||
|
||||
Ok(rocket::custom(config)
|
||||
.manage(context.db)
|
||||
.manage(context.index)
|
||||
.manage(context.config_manager)
|
||||
.manage(context.lastfm_manager)
|
||||
.manage(context.playlist_manager)
|
||||
.manage(context.thumbnail_manager)
|
||||
.manage(context.user_manager)
|
||||
.manage(context.vfs_manager)
|
||||
.mount(&context.api_url, api::get_routes())
|
||||
.mount(
|
||||
&context.swagger_url,
|
||||
StaticFiles::new(context.swagger_dir_path, static_file_options)
|
||||
.rank(swagger_routes_rank),
|
||||
)
|
||||
.mount(
|
||||
&context.web_url,
|
||||
StaticFiles::new(context.web_dir_path, static_file_options).rank(web_routes_rank),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn run(context: service::Context) -> Result<()> {
|
||||
let server = get_server(context)?;
|
||||
server.launch();
|
||||
Ok(())
|
||||
}
|
|
@ -1,163 +0,0 @@
|
|||
use log::warn;
|
||||
use rocket;
|
||||
use rocket::http::hyper::header::*;
|
||||
use rocket::http::Status;
|
||||
use rocket::response::{self, Responder};
|
||||
use rocket::Response;
|
||||
use std::cmp;
|
||||
use std::convert::From;
|
||||
use std::fs::File;
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
use std::str::FromStr;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum PartialFileRange {
|
||||
AllFrom(u64),
|
||||
FromTo(u64, u64),
|
||||
Last(u64),
|
||||
}
|
||||
|
||||
impl From<ByteRangeSpec> for PartialFileRange {
|
||||
fn from(b: ByteRangeSpec) -> PartialFileRange {
|
||||
match b {
|
||||
ByteRangeSpec::AllFrom(from) => PartialFileRange::AllFrom(from),
|
||||
ByteRangeSpec::FromTo(from, to) => PartialFileRange::FromTo(from, to),
|
||||
ByteRangeSpec::Last(last) => PartialFileRange::Last(last),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Vec<ByteRangeSpec>> for PartialFileRange {
|
||||
fn from(v: Vec<ByteRangeSpec>) -> PartialFileRange {
|
||||
match v.into_iter().next() {
|
||||
None => PartialFileRange::AllFrom(0),
|
||||
Some(byte_range) => PartialFileRange::from(byte_range),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct RangeResponder<R> {
|
||||
original: R,
|
||||
}
|
||||
|
||||
impl<'r, R: Responder<'r>> RangeResponder<R> {
|
||||
pub fn new(original: R) -> RangeResponder<R> {
|
||||
RangeResponder { original }
|
||||
}
|
||||
|
||||
fn ignore_range(
|
||||
self,
|
||||
request: &rocket::request::Request<'_>,
|
||||
file_length: Option<u64>,
|
||||
) -> response::Result<'r> {
|
||||
let mut response = self.original.respond_to(request)?;
|
||||
if let Some(content_length) = file_length {
|
||||
response.set_header(ContentLength(content_length));
|
||||
}
|
||||
response.set_status(Status::Ok);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn reject_range(self, file_length: Option<u64>) -> response::Result<'r> {
|
||||
let mut response = Response::build()
|
||||
.status(Status::RangeNotSatisfiable)
|
||||
.finalize();
|
||||
if file_length.is_some() {
|
||||
let content_range = ContentRange(ContentRangeSpec::Bytes {
|
||||
range: None,
|
||||
instance_length: file_length,
|
||||
});
|
||||
response.set_header(content_range);
|
||||
}
|
||||
response.set_status(Status::RangeNotSatisfiable);
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate_range(range: &PartialFileRange, file_length: &Option<u64>) -> Option<(u64, u64)> {
|
||||
use self::PartialFileRange::*;
|
||||
|
||||
match (range, file_length) {
|
||||
(FromTo(from, to), Some(file_length)) => {
|
||||
if from <= to && from < file_length {
|
||||
Some((*from, cmp::min(*to, file_length - 1)))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
(AllFrom(from), Some(file_length)) => {
|
||||
if from < file_length {
|
||||
Some((*from, file_length - 1))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
(Last(last), Some(file_length)) => {
|
||||
if last < file_length {
|
||||
Some((file_length - last, file_length - 1))
|
||||
} else {
|
||||
Some((0, file_length - 1))
|
||||
}
|
||||
}
|
||||
(_, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
impl<'r> Responder<'r> for RangeResponder<File> {
|
||||
fn respond_to(mut self, request: &rocket::request::Request<'_>) -> response::Result<'r> {
|
||||
let metadata: Option<_> = self.original.metadata().ok();
|
||||
let file_length: Option<u64> = metadata.map(|m| m.len());
|
||||
|
||||
let range_header = request.headers().get_one("Range");
|
||||
let range_header = match range_header {
|
||||
None => return self.ignore_range(request, file_length),
|
||||
Some(h) => h,
|
||||
};
|
||||
|
||||
let vec_range = match Range::from_str(range_header) {
|
||||
Ok(Range::Bytes(v)) => v,
|
||||
_ => {
|
||||
warn!(
|
||||
"Ignoring range header that could not be parsed {:?}, file length is {:?}",
|
||||
range_header, file_length
|
||||
);
|
||||
return self.ignore_range(request, file_length);
|
||||
}
|
||||
};
|
||||
|
||||
let partial_file_range = match vec_range.into_iter().next() {
|
||||
None => PartialFileRange::AllFrom(0),
|
||||
Some(byte_range) => PartialFileRange::from(byte_range),
|
||||
};
|
||||
|
||||
let range: Option<(u64, u64)> = truncate_range(&partial_file_range, &file_length);
|
||||
|
||||
if let Some((from, to)) = range {
|
||||
let content_range = ContentRange(ContentRangeSpec::Bytes {
|
||||
range: range,
|
||||
instance_length: file_length,
|
||||
});
|
||||
let content_len = to - from + 1;
|
||||
|
||||
match self.original.seek(SeekFrom::Start(from)) {
|
||||
Ok(_) => (),
|
||||
Err(_) => return Err(rocket::http::Status::InternalServerError),
|
||||
}
|
||||
let partial_original = self.original.take(content_len);
|
||||
let response = Response::build()
|
||||
.status(Status::PartialContent)
|
||||
.header(ContentLength(content_len))
|
||||
.header(content_range)
|
||||
.streamed_body(partial_original)
|
||||
.finalize();
|
||||
|
||||
Ok(response)
|
||||
} else {
|
||||
warn!(
|
||||
"Rejecting unsatisfiable range header {:?}, file length is {:?}",
|
||||
&partial_file_range, &file_length
|
||||
);
|
||||
self.reject_range(file_length)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,112 +0,0 @@
|
|||
use http::{header::HeaderName, method::Method, response::Builder, HeaderValue, Request, Response};
|
||||
use rocket;
|
||||
use rocket::local::{Client, LocalResponse};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::Serialize;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::service;
|
||||
use crate::service::test::{protocol, TestService};
|
||||
|
||||
pub struct RocketTestService {
|
||||
client: Client,
|
||||
request_builder: protocol::RequestBuilder,
|
||||
}
|
||||
|
||||
pub type ServiceType = RocketTestService;
|
||||
|
||||
impl RocketTestService {
|
||||
fn process_internal<T: Serialize>(&mut self, request: &Request<T>) -> (LocalResponse, Builder) {
|
||||
let rocket_response = {
|
||||
let url = request.uri().to_string();
|
||||
let mut rocket_request = match *request.method() {
|
||||
Method::GET => self.client.get(url),
|
||||
Method::POST => self.client.post(url),
|
||||
Method::PUT => self.client.put(url),
|
||||
Method::DELETE => self.client.delete(url),
|
||||
_ => unimplemented!(),
|
||||
};
|
||||
|
||||
for (name, value) in request.headers() {
|
||||
rocket_request.add_header(rocket::http::Header::new(
|
||||
name.as_str().to_owned(),
|
||||
value.to_str().unwrap().to_owned(),
|
||||
));
|
||||
}
|
||||
|
||||
let payload = request.body();
|
||||
let body = serde_json::to_string(payload).unwrap();
|
||||
rocket_request.set_body(body);
|
||||
|
||||
let content_type = rocket::http::ContentType::JSON;
|
||||
rocket_request.add_header(content_type);
|
||||
|
||||
rocket_request.dispatch()
|
||||
};
|
||||
|
||||
let mut builder = Response::builder().status(rocket_response.status().code);
|
||||
let headers = builder.headers_mut().unwrap();
|
||||
for header in rocket_response.headers().iter() {
|
||||
headers.append(
|
||||
HeaderName::from_bytes(header.name.as_str().as_bytes()).unwrap(),
|
||||
HeaderValue::from_str(header.value.as_ref()).unwrap(),
|
||||
);
|
||||
}
|
||||
|
||||
(rocket_response, builder)
|
||||
}
|
||||
}
|
||||
|
||||
impl TestService for RocketTestService {
|
||||
fn new(test_name: &str) -> Self {
|
||||
let mut db_path: PathBuf = ["test-output", test_name].iter().collect();
|
||||
fs::create_dir_all(&db_path).unwrap();
|
||||
db_path.push("db.sqlite");
|
||||
|
||||
if db_path.exists() {
|
||||
fs::remove_file(&db_path).unwrap();
|
||||
}
|
||||
|
||||
let context = service::ContextBuilder::new()
|
||||
.database_file_path(db_path)
|
||||
.web_dir_path(Path::new("test-data/web").into())
|
||||
.swagger_dir_path(["docs", "swagger"].iter().collect())
|
||||
.cache_dir_path(["test-output", test_name].iter().collect())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let server = service::get_server(context).unwrap();
|
||||
let client = Client::new(server).unwrap();
|
||||
let request_builder = protocol::RequestBuilder::new();
|
||||
RocketTestService {
|
||||
request_builder,
|
||||
client,
|
||||
}
|
||||
}
|
||||
|
||||
fn request_builder(&self) -> &protocol::RequestBuilder {
|
||||
&self.request_builder
|
||||
}
|
||||
|
||||
fn fetch<T: Serialize>(&mut self, request: &Request<T>) -> Response<()> {
|
||||
let (_, builder) = self.process_internal(request);
|
||||
builder.body(()).unwrap()
|
||||
}
|
||||
|
||||
fn fetch_bytes<T: Serialize>(&mut self, request: &Request<T>) -> Response<Vec<u8>> {
|
||||
let (mut rocket_response, builder) = self.process_internal(request);
|
||||
let body = rocket_response.body().unwrap().into_bytes().unwrap();
|
||||
builder.body(body).unwrap()
|
||||
}
|
||||
|
||||
fn fetch_json<T: Serialize, U: DeserializeOwned>(
|
||||
&mut self,
|
||||
request: &Request<T>,
|
||||
) -> Response<U> {
|
||||
let (mut rocket_response, builder) = self.process_internal(request);
|
||||
let body = rocket_response.body_string().unwrap();
|
||||
let body = serde_json::from_str(&body).unwrap();
|
||||
builder.body(body).unwrap()
|
||||
}
|
||||
}
|
|
@ -2,13 +2,13 @@ use http::StatusCode;
|
|||
|
||||
use crate::app::index;
|
||||
use crate::service::dto;
|
||||
use crate::service::test::{ServiceType, TestService};
|
||||
use crate::service::test::{protocol, ServiceType, TestService};
|
||||
use crate::test_name;
|
||||
|
||||
#[test]
|
||||
fn test_returns_api_version() {
|
||||
let mut service = ServiceType::new(&test_name!());
|
||||
let request = service.request_builder().version();
|
||||
let request = protocol::version();
|
||||
let response = service.fetch_json::<_, dto::Version>(&request);
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
@ -16,7 +16,7 @@ fn test_returns_api_version() {
|
|||
#[test]
|
||||
fn test_initial_setup_golden_path() {
|
||||
let mut service = ServiceType::new(&test_name!());
|
||||
let request = service.request_builder().initial_setup();
|
||||
let request = protocol::initial_setup();
|
||||
{
|
||||
let response = service.fetch_json::<_, dto::InitialSetup>(&request);
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
@ -48,7 +48,7 @@ fn test_trigger_index_golden_path() {
|
|||
service.complete_initial_setup();
|
||||
service.login_admin();
|
||||
|
||||
let request = service.request_builder().random();
|
||||
let request = protocol::random();
|
||||
|
||||
let response = service.fetch_json::<_, Vec<index::Directory>>(&request);
|
||||
let entries = response.body();
|
||||
|
@ -65,7 +65,7 @@ fn test_trigger_index_golden_path() {
|
|||
fn test_trigger_index_requires_auth() {
|
||||
let mut service = ServiceType::new(&test_name!());
|
||||
service.complete_initial_setup();
|
||||
let request = service.request_builder().trigger_index();
|
||||
let request = protocol::trigger_index();
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
@ -75,7 +75,7 @@ fn test_trigger_index_requires_admin() {
|
|||
let mut service = ServiceType::new(&test_name!());
|
||||
service.complete_initial_setup();
|
||||
service.login();
|
||||
let request = service.request_builder().trigger_index();
|
||||
let request = protocol::trigger_index();
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
|
|
@ -1,32 +1,42 @@
|
|||
use cookie::Cookie;
|
||||
use headers::{self, HeaderMapExt};
|
||||
use http::{Response, StatusCode};
|
||||
use time::Duration;
|
||||
|
||||
use crate::service::dto;
|
||||
use crate::service::test::{constants::*, ServiceType, TestService};
|
||||
use crate::service::test::{constants::*, protocol, ServiceType, TestService};
|
||||
use crate::test_name;
|
||||
|
||||
fn validate_cookies<T>(response: &Response<T>) {
|
||||
fn validate_added_cookies<T>(response: &Response<T>) {
|
||||
let twenty_years = Duration::days(365 * 20);
|
||||
|
||||
let cookies: Vec<Cookie> = response
|
||||
.headers()
|
||||
.get_all(http::header::SET_COOKIE)
|
||||
.iter()
|
||||
.map(|c| Cookie::parse(c.to_str().unwrap()).unwrap())
|
||||
.collect();
|
||||
|
||||
let session = cookies
|
||||
.iter()
|
||||
.find_map(|c| {
|
||||
if c.name() == dto::COOKIE_SESSION {
|
||||
Some(c.value())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.find(|c| c.name() == dto::COOKIE_SESSION)
|
||||
.unwrap();
|
||||
assert_ne!(session, TEST_USERNAME);
|
||||
assert_ne!(session, TEST_USERNAME_ADMIN);
|
||||
assert!(cookies.iter().any(|c| c.name() == dto::COOKIE_USERNAME));
|
||||
assert!(cookies.iter().any(|c| c.name() == dto::COOKIE_ADMIN));
|
||||
assert_ne!(session.value(), TEST_USERNAME);
|
||||
assert!(session.max_age().unwrap() >= twenty_years);
|
||||
|
||||
let username = cookies
|
||||
.iter()
|
||||
.find(|c| c.name() == dto::COOKIE_USERNAME)
|
||||
.unwrap();
|
||||
assert_eq!(username.value(), TEST_USERNAME);
|
||||
assert!(session.max_age().unwrap() >= twenty_years);
|
||||
|
||||
let is_admin = cookies
|
||||
.iter()
|
||||
.find(|c| c.name() == dto::COOKIE_ADMIN)
|
||||
.unwrap();
|
||||
assert_eq!(is_admin.value(), false.to_string());
|
||||
assert!(session.max_age().unwrap() >= twenty_years);
|
||||
}
|
||||
|
||||
fn validate_no_cookies<T>(response: &Response<T>) {
|
||||
|
@ -46,7 +56,7 @@ fn test_login_rejects_bad_username() {
|
|||
let mut service = ServiceType::new(&test_name!());
|
||||
service.complete_initial_setup();
|
||||
|
||||
let request = service.request_builder().login("garbage", TEST_PASSWORD);
|
||||
let request = protocol::login("garbage", TEST_PASSWORD);
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
@ -56,7 +66,7 @@ fn test_login_rejects_bad_password() {
|
|||
let mut service = ServiceType::new(&test_name!());
|
||||
service.complete_initial_setup();
|
||||
|
||||
let request = service.request_builder().login(TEST_USERNAME, "garbage");
|
||||
let request = protocol::login(TEST_USERNAME, "garbage");
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
@ -66,13 +76,16 @@ fn test_login_golden_path() {
|
|||
let mut service = ServiceType::new(&test_name!());
|
||||
service.complete_initial_setup();
|
||||
|
||||
let request = service
|
||||
.request_builder()
|
||||
.login(TEST_USERNAME, TEST_PASSWORD);
|
||||
let response = service.fetch(&request);
|
||||
let request = protocol::login(TEST_USERNAME, TEST_PASSWORD);
|
||||
let response = service.fetch_json::<_, dto::Authorization>(&request);
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
validate_cookies(&response);
|
||||
let authorization = response.body();
|
||||
assert_eq!(authorization.username, TEST_USERNAME);
|
||||
assert_eq!(authorization.is_admin, false);
|
||||
assert!(!authorization.token.is_empty());
|
||||
|
||||
validate_added_cookies(&response);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
@ -81,7 +94,7 @@ fn test_requests_without_auth_header_do_not_set_cookies() {
|
|||
service.complete_initial_setup();
|
||||
service.login();
|
||||
|
||||
let request = service.request_builder().random();
|
||||
let request = protocol::random();
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
|
@ -89,11 +102,11 @@ fn test_requests_without_auth_header_do_not_set_cookies() {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn test_authentication_via_http_header_rejects_bad_username() {
|
||||
fn test_authentication_via_basic_http_header_rejects_bad_username() {
|
||||
let mut service = ServiceType::new(&test_name!());
|
||||
service.complete_initial_setup();
|
||||
|
||||
let mut request = service.request_builder().random();
|
||||
let mut request = protocol::random();
|
||||
let basic = headers::Authorization::basic("garbage", TEST_PASSWORD);
|
||||
request.headers_mut().typed_insert(basic);
|
||||
|
||||
|
@ -102,11 +115,11 @@ fn test_authentication_via_http_header_rejects_bad_username() {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn test_authentication_via_http_header_rejects_bad_password() {
|
||||
fn test_authentication_via_basic_http_header_rejects_bad_password() {
|
||||
let mut service = ServiceType::new(&test_name!());
|
||||
service.complete_initial_setup();
|
||||
|
||||
let mut request = service.request_builder().random();
|
||||
let mut request = protocol::random();
|
||||
let basic = headers::Authorization::basic(TEST_PASSWORD, "garbage");
|
||||
request.headers_mut().typed_insert(basic);
|
||||
|
||||
|
@ -115,16 +128,91 @@ fn test_authentication_via_http_header_rejects_bad_password() {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn test_authentication_via_http_header_golden_path() {
|
||||
fn test_authentication_via_basic_http_header_golden_path() {
|
||||
let mut service = ServiceType::new(&test_name!());
|
||||
service.complete_initial_setup();
|
||||
|
||||
let mut request = service.request_builder().random();
|
||||
let mut request = protocol::random();
|
||||
let basic = headers::Authorization::basic(TEST_USERNAME, TEST_PASSWORD);
|
||||
request.headers_mut().typed_insert(basic);
|
||||
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
validate_cookies(&response);
|
||||
validate_added_cookies(&response);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_authentication_via_bearer_http_header_rejects_bad_token() {
|
||||
let mut service = ServiceType::new(&test_name!());
|
||||
service.complete_initial_setup();
|
||||
|
||||
let mut request = protocol::random();
|
||||
let bearer = headers::Authorization::bearer("garbage").unwrap();
|
||||
request.headers_mut().typed_insert(bearer);
|
||||
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_authentication_via_bearer_http_header_golden_path() {
|
||||
let mut service = ServiceType::new(&test_name!());
|
||||
service.complete_initial_setup();
|
||||
|
||||
let authorization = {
|
||||
let request = protocol::login(TEST_USERNAME, TEST_PASSWORD);
|
||||
let response = service.fetch_json::<_, dto::Authorization>(&request);
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
response.into_body()
|
||||
};
|
||||
|
||||
service.logout();
|
||||
|
||||
let mut request = protocol::random();
|
||||
let bearer = headers::Authorization::bearer(&authorization.token).unwrap();
|
||||
request.headers_mut().typed_insert(bearer);
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
validate_no_cookies(&response);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_authentication_via_query_param_rejects_bad_token() {
|
||||
let mut service = ServiceType::new(&test_name!());
|
||||
service.complete_initial_setup();
|
||||
|
||||
let mut request = protocol::random();
|
||||
*request.uri_mut() = (request.uri().to_string() + "?auth_token=garbage-token")
|
||||
.parse()
|
||||
.unwrap();
|
||||
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_authentication_via_query_param_golden_path() {
|
||||
let mut service = ServiceType::new(&test_name!());
|
||||
service.complete_initial_setup();
|
||||
|
||||
let authorization = {
|
||||
let request = protocol::login(TEST_USERNAME, TEST_PASSWORD);
|
||||
let response = service.fetch_json::<_, dto::Authorization>(&request);
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
response.into_body()
|
||||
};
|
||||
|
||||
service.logout();
|
||||
|
||||
let mut request = protocol::random();
|
||||
*request.uri_mut() = format!("{}?auth_token={}", request.uri(), authorization.token)
|
||||
.parse()
|
||||
.unwrap();
|
||||
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
validate_no_cookies(&response);
|
||||
}
|
||||
|
|
|
@ -2,13 +2,13 @@ use http::StatusCode;
|
|||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::app::index;
|
||||
use crate::service::test::{add_trailing_slash, constants::*, ServiceType, TestService};
|
||||
use crate::service::test::{add_trailing_slash, constants::*, protocol, ServiceType, TestService};
|
||||
use crate::test_name;
|
||||
|
||||
#[test]
|
||||
fn test_browse_requires_auth() {
|
||||
let mut service = ServiceType::new(&test_name!());
|
||||
let request = service.request_builder().browse(&PathBuf::new());
|
||||
let request = protocol::browse(&PathBuf::new());
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
@ -21,7 +21,7 @@ fn test_browse_root() {
|
|||
service.index();
|
||||
service.login();
|
||||
|
||||
let request = service.request_builder().browse(&PathBuf::new());
|
||||
let request = protocol::browse(&PathBuf::new());
|
||||
let response = service.fetch_json::<_, Vec<index::CollectionFile>>(&request);
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let entries = response.body();
|
||||
|
@ -37,7 +37,7 @@ fn test_browse_directory() {
|
|||
service.login();
|
||||
|
||||
let path: PathBuf = [TEST_MOUNT_NAME, "Khemmis", "Hunted"].iter().collect();
|
||||
let request = service.request_builder().browse(&path);
|
||||
let request = protocol::browse(&path);
|
||||
let response = service.fetch_json::<_, Vec<index::CollectionFile>>(&request);
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let entries = response.body();
|
||||
|
@ -51,7 +51,7 @@ fn test_browse_bad_directory() {
|
|||
service.login();
|
||||
|
||||
let path: PathBuf = ["not_my_collection"].iter().collect();
|
||||
let request = service.request_builder().browse(&path);
|
||||
let request = protocol::browse(&path);
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
@ -59,7 +59,7 @@ fn test_browse_bad_directory() {
|
|||
#[test]
|
||||
fn test_flatten_requires_auth() {
|
||||
let mut service = ServiceType::new(&test_name!());
|
||||
let request = service.request_builder().flatten(&PathBuf::new());
|
||||
let request = protocol::flatten(&PathBuf::new());
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
@ -72,7 +72,7 @@ fn test_flatten_root() {
|
|||
service.index();
|
||||
service.login();
|
||||
|
||||
let request = service.request_builder().flatten(&PathBuf::new());
|
||||
let request = protocol::flatten(&PathBuf::new());
|
||||
let response = service.fetch_json::<_, Vec<index::Song>>(&request);
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let entries = response.body();
|
||||
|
@ -87,9 +87,7 @@ fn test_flatten_directory() {
|
|||
service.index();
|
||||
service.login();
|
||||
|
||||
let request = service
|
||||
.request_builder()
|
||||
.flatten(Path::new(TEST_MOUNT_NAME));
|
||||
let request = protocol::flatten(Path::new(TEST_MOUNT_NAME));
|
||||
let response = service.fetch_json::<_, Vec<index::Song>>(&request);
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let entries = response.body();
|
||||
|
@ -103,7 +101,7 @@ fn test_flatten_bad_directory() {
|
|||
service.login();
|
||||
|
||||
let path: PathBuf = ["not_my_collection"].iter().collect();
|
||||
let request = service.request_builder().flatten(&path);
|
||||
let request = protocol::flatten(&path);
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
@ -111,7 +109,7 @@ fn test_flatten_bad_directory() {
|
|||
#[test]
|
||||
fn test_random_requires_auth() {
|
||||
let mut service = ServiceType::new(&test_name!());
|
||||
let request = service.request_builder().random();
|
||||
let request = protocol::random();
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
@ -124,7 +122,7 @@ fn test_random_golden_path() {
|
|||
service.index();
|
||||
service.login();
|
||||
|
||||
let request = service.request_builder().random();
|
||||
let request = protocol::random();
|
||||
let response = service.fetch_json::<_, Vec<index::Directory>>(&request);
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let entries = response.body();
|
||||
|
@ -139,7 +137,7 @@ fn test_random_with_trailing_slash() {
|
|||
service.index();
|
||||
service.login();
|
||||
|
||||
let mut request = service.request_builder().random();
|
||||
let mut request = protocol::random();
|
||||
add_trailing_slash(&mut request);
|
||||
let response = service.fetch_json::<_, Vec<index::Directory>>(&request);
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
@ -150,7 +148,7 @@ fn test_random_with_trailing_slash() {
|
|||
#[test]
|
||||
fn test_recent_requires_auth() {
|
||||
let mut service = ServiceType::new(&test_name!());
|
||||
let request = service.request_builder().recent();
|
||||
let request = protocol::recent();
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
@ -163,7 +161,7 @@ fn test_recent_golden_path() {
|
|||
service.index();
|
||||
service.login();
|
||||
|
||||
let request = service.request_builder().recent();
|
||||
let request = protocol::recent();
|
||||
let response = service.fetch_json::<_, Vec<index::Directory>>(&request);
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let entries = response.body();
|
||||
|
@ -178,7 +176,7 @@ fn test_recent_with_trailing_slash() {
|
|||
service.index();
|
||||
service.login();
|
||||
|
||||
let mut request = service.request_builder().recent();
|
||||
let mut request = protocol::recent();
|
||||
add_trailing_slash(&mut request);
|
||||
let response = service.fetch_json::<_, Vec<index::Directory>>(&request);
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
@ -189,7 +187,7 @@ fn test_recent_with_trailing_slash() {
|
|||
#[test]
|
||||
fn test_search_requires_auth() {
|
||||
let mut service = ServiceType::new(&test_name!());
|
||||
let request = service.request_builder().search("");
|
||||
let request = protocol::search("");
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
@ -200,7 +198,7 @@ fn test_search_without_query() {
|
|||
service.complete_initial_setup();
|
||||
service.login();
|
||||
|
||||
let request = service.request_builder().search("");
|
||||
let request = protocol::search("");
|
||||
let response = service.fetch_json::<_, Vec<index::CollectionFile>>(&request);
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
@ -213,7 +211,7 @@ fn test_search_with_query() {
|
|||
service.index();
|
||||
service.login();
|
||||
|
||||
let request = service.request_builder().search("door");
|
||||
let request = protocol::search("door");
|
||||
let response = service.fetch_json::<_, Vec<index::CollectionFile>>(&request);
|
||||
let results = response.body();
|
||||
assert_eq!(results.len(), 1);
|
||||
|
|
63
src/service/test/ddns.rs
Normal file
63
src/service/test/ddns.rs
Normal file
|
@ -0,0 +1,63 @@
|
|||
use http::StatusCode;
|
||||
|
||||
use crate::service::dto;
|
||||
use crate::service::test::{protocol, ServiceType, TestService};
|
||||
use crate::test_name;
|
||||
|
||||
#[test]
|
||||
fn test_get_ddns_config_requires_admin() {
|
||||
let mut service = ServiceType::new(&test_name!());
|
||||
let request = protocol::get_ddns_config();
|
||||
service.complete_initial_setup();
|
||||
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
service.login();
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_ddns_config_golden_path() {
|
||||
let mut service = ServiceType::new(&test_name!());
|
||||
service.complete_initial_setup();
|
||||
service.login_admin();
|
||||
|
||||
let request = protocol::get_ddns_config();
|
||||
let response = service.fetch_json::<_, dto::DDNSConfig>(&request);
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_put_ddns_config_requires_admin() {
|
||||
let mut service = ServiceType::new(&test_name!());
|
||||
let request = protocol::put_ddns_config(dto::DDNSConfig {
|
||||
host: "test".to_owned(),
|
||||
username: "test".to_owned(),
|
||||
password: "test".to_owned(),
|
||||
});
|
||||
service.complete_initial_setup();
|
||||
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
service.login();
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_put_ddns_config_golden_path() {
|
||||
let mut service = ServiceType::new(&test_name!());
|
||||
service.complete_initial_setup();
|
||||
service.login_admin();
|
||||
|
||||
let request = protocol::put_ddns_config(dto::DDNSConfig {
|
||||
host: "test".to_owned(),
|
||||
username: "test".to_owned(),
|
||||
password: "test".to_owned(),
|
||||
});
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
62
src/service/test/lastfm.rs
Normal file
62
src/service/test/lastfm.rs
Normal file
|
@ -0,0 +1,62 @@
|
|||
use http::StatusCode;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::service::dto;
|
||||
use crate::service::test::{constants::*, protocol, ServiceType, TestService};
|
||||
use crate::test_name;
|
||||
|
||||
#[test]
|
||||
fn test_lastfm_scrobble_ignores_unlinked_user() {
|
||||
let mut service = ServiceType::new(&test_name!());
|
||||
service.complete_initial_setup();
|
||||
service.login_admin();
|
||||
service.index();
|
||||
service.login();
|
||||
|
||||
let path: PathBuf = [TEST_MOUNT_NAME, "Khemmis", "Hunted", "02 - Candlelight.mp3"]
|
||||
.iter()
|
||||
.collect();
|
||||
|
||||
let request = protocol::lastfm_scrobble(&path);
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lastfm_now_playing_ignores_unlinked_user() {
|
||||
let mut service = ServiceType::new(&test_name!());
|
||||
service.complete_initial_setup();
|
||||
service.login_admin();
|
||||
service.index();
|
||||
service.login();
|
||||
|
||||
let path: PathBuf = [TEST_MOUNT_NAME, "Khemmis", "Hunted", "02 - Candlelight.mp3"]
|
||||
.iter()
|
||||
.collect();
|
||||
|
||||
let request = protocol::lastfm_now_playing(&path);
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lastfm_link_token_requires_auth() {
|
||||
let mut service = ServiceType::new(&test_name!());
|
||||
let request = protocol::lastfm_link_token();
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lastfm_link_token_golden_path() {
|
||||
let mut service = ServiceType::new(&test_name!());
|
||||
service.complete_initial_setup();
|
||||
service.login();
|
||||
|
||||
let request = protocol::lastfm_link_token();
|
||||
let response = service.fetch_json::<_, dto::LastFMLinkToken>(&request);
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let link_token = response.body();
|
||||
assert!(!link_token.value.is_empty());
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
use http::{header, HeaderValue, StatusCode};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::service::test::{constants::*, ServiceType, TestService};
|
||||
use crate::service::test::{constants::*, protocol, ServiceType, TestService};
|
||||
use crate::test_name;
|
||||
|
||||
#[test]
|
||||
|
@ -12,7 +12,7 @@ fn test_audio_requires_auth() {
|
|||
.iter()
|
||||
.collect();
|
||||
|
||||
let request = service.request_builder().audio(&path);
|
||||
let request = protocol::audio(&path);
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
@ -29,7 +29,7 @@ fn test_audio_golden_path() {
|
|||
.iter()
|
||||
.collect();
|
||||
|
||||
let request = service.request_builder().audio(&path);
|
||||
let request = protocol::audio(&path);
|
||||
let response = service.fetch_bytes(&request);
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(response.body().len(), 24_142);
|
||||
|
@ -47,7 +47,7 @@ fn test_audio_partial_content() {
|
|||
.iter()
|
||||
.collect();
|
||||
|
||||
let mut request = service.request_builder().audio(&path);
|
||||
let mut request = protocol::audio(&path);
|
||||
let headers = request.headers_mut();
|
||||
headers.append(
|
||||
header::RANGE,
|
||||
|
@ -71,7 +71,7 @@ fn test_audio_bad_path_returns_not_found() {
|
|||
|
||||
let path: PathBuf = ["not_my_collection"].iter().collect();
|
||||
|
||||
let request = service.request_builder().audio(&path);
|
||||
let request = protocol::audio(&path);
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
@ -85,7 +85,7 @@ fn test_thumbnail_requires_auth() {
|
|||
.collect();
|
||||
|
||||
let pad = None;
|
||||
let request = service.request_builder().thumbnail(&path, pad);
|
||||
let request = protocol::thumbnail(&path, pad);
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
@ -103,7 +103,7 @@ fn test_thumbnail_golden_path() {
|
|||
.collect();
|
||||
|
||||
let pad = None;
|
||||
let request = service.request_builder().thumbnail(&path, pad);
|
||||
let request = protocol::thumbnail(&path, pad);
|
||||
let response = service.fetch_bytes(&request);
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
@ -117,7 +117,7 @@ fn test_thumbnail_bad_path_returns_not_found() {
|
|||
let path: PathBuf = ["not_my_collection"].iter().collect();
|
||||
|
||||
let pad = None;
|
||||
let request = service.request_builder().thumbnail(&path, pad);
|
||||
let request = protocol::thumbnail(&path, pad);
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
|
|
@ -10,77 +10,87 @@ pub mod protocol;
|
|||
mod admin;
|
||||
mod auth;
|
||||
mod collection;
|
||||
mod ddns;
|
||||
mod lastfm;
|
||||
mod media;
|
||||
mod playlist;
|
||||
mod preferences;
|
||||
mod settings;
|
||||
mod swagger;
|
||||
mod user;
|
||||
mod web;
|
||||
|
||||
use crate::app::{config, index, vfs};
|
||||
use crate::app::index;
|
||||
use crate::service::dto;
|
||||
use crate::service::test::constants::*;
|
||||
|
||||
#[cfg(feature = "service-rocket")]
|
||||
pub use crate::service::rocket::test::ServiceType;
|
||||
pub use crate::service::actix::test::ServiceType;
|
||||
|
||||
pub trait TestService {
|
||||
fn new(test_name: &str) -> Self;
|
||||
fn request_builder(&self) -> &protocol::RequestBuilder;
|
||||
fn fetch<T: Serialize>(&mut self, request: &Request<T>) -> Response<()>;
|
||||
fn fetch_bytes<T: Serialize>(&mut self, request: &Request<T>) -> Response<Vec<u8>>;
|
||||
fn fetch_json<T: Serialize, U: DeserializeOwned>(
|
||||
fn fetch<T: Serialize + Clone + 'static>(&mut self, request: &Request<T>) -> Response<()>;
|
||||
fn fetch_bytes<T: Serialize + Clone + 'static>(
|
||||
&mut self,
|
||||
request: &Request<T>,
|
||||
) -> Response<Vec<u8>>;
|
||||
fn fetch_json<T: Serialize + Clone + 'static, U: DeserializeOwned>(
|
||||
&mut self,
|
||||
request: &Request<T>,
|
||||
) -> Response<U>;
|
||||
|
||||
fn complete_initial_setup(&mut self) {
|
||||
let configuration = config::Config {
|
||||
album_art_pattern: None,
|
||||
reindex_every_n_seconds: None,
|
||||
ydns: None,
|
||||
let configuration = dto::Config {
|
||||
users: Some(vec![
|
||||
config::ConfigUser {
|
||||
dto::NewUser {
|
||||
name: TEST_USERNAME_ADMIN.into(),
|
||||
password: TEST_PASSWORD_ADMIN.into(),
|
||||
admin: true,
|
||||
},
|
||||
config::ConfigUser {
|
||||
dto::NewUser {
|
||||
name: TEST_USERNAME.into(),
|
||||
password: TEST_PASSWORD.into(),
|
||||
admin: false,
|
||||
},
|
||||
]),
|
||||
mount_dirs: Some(vec![vfs::MountPoint {
|
||||
mount_dirs: Some(vec![dto::MountDir {
|
||||
name: TEST_MOUNT_NAME.into(),
|
||||
source: TEST_MOUNT_SOURCE.into(),
|
||||
}]),
|
||||
..Default::default()
|
||||
};
|
||||
let request = self.request_builder().put_settings(configuration);
|
||||
let request = protocol::apply_config(configuration);
|
||||
let response = self.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
fn login_internal(&mut self, username: &str, password: &str) {
|
||||
let request = protocol::login(username, password);
|
||||
let response = self.fetch_json::<_, dto::Authorization>(&request);
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let authorization = response.into_body();
|
||||
self.set_authorization(Some(authorization));
|
||||
}
|
||||
|
||||
fn login_admin(&mut self) {
|
||||
let request = self
|
||||
.request_builder()
|
||||
.login(TEST_USERNAME_ADMIN, TEST_PASSWORD_ADMIN);
|
||||
let response = self.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
self.login_internal(TEST_USERNAME_ADMIN, TEST_PASSWORD_ADMIN);
|
||||
}
|
||||
|
||||
fn login(&mut self) {
|
||||
let request = self.request_builder().login(TEST_USERNAME, TEST_PASSWORD);
|
||||
let response = self.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
self.login_internal(TEST_USERNAME, TEST_PASSWORD);
|
||||
}
|
||||
|
||||
fn logout(&mut self) {
|
||||
self.set_authorization(None);
|
||||
}
|
||||
|
||||
fn set_authorization(&mut self, authorization: Option<dto::Authorization>);
|
||||
|
||||
fn index(&mut self) {
|
||||
let request = self.request_builder().trigger_index();
|
||||
let request = protocol::trigger_index();
|
||||
let response = self.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
loop {
|
||||
let browse_request = self.request_builder().browse(Path::new(""));
|
||||
let browse_request = protocol::browse(Path::new(""));
|
||||
let response = self.fetch_json::<(), Vec<index::CollectionFile>>(&browse_request);
|
||||
let entries = response.body();
|
||||
if entries.len() > 0 {
|
||||
|
@ -90,7 +100,7 @@ pub trait TestService {
|
|||
}
|
||||
|
||||
loop {
|
||||
let flatten_request = self.request_builder().flatten(Path::new(""));
|
||||
let flatten_request = protocol::flatten(Path::new(""));
|
||||
let response = self.fetch_json::<_, Vec<index::Song>>(&flatten_request);
|
||||
let entries = response.body();
|
||||
if entries.len() > 0 {
|
||||
|
|
|
@ -2,13 +2,13 @@ use http::StatusCode;
|
|||
|
||||
use crate::app::index;
|
||||
use crate::service::dto;
|
||||
use crate::service::test::{constants::*, ServiceType, TestService};
|
||||
use crate::service::test::{constants::*, protocol, ServiceType, TestService};
|
||||
use crate::test_name;
|
||||
|
||||
#[test]
|
||||
fn test_list_playlists_requires_auth() {
|
||||
let mut service = ServiceType::new(&test_name!());
|
||||
let request = service.request_builder().playlists();
|
||||
let request = protocol::playlists();
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
@ -18,7 +18,7 @@ fn test_list_playlists_golden_path() {
|
|||
let mut service = ServiceType::new(&test_name!());
|
||||
service.complete_initial_setup();
|
||||
service.login();
|
||||
let request = service.request_builder().playlists();
|
||||
let request = protocol::playlists();
|
||||
let response = service.fetch_json::<_, Vec<dto::ListPlaylistsEntry>>(&request);
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
@ -27,9 +27,7 @@ fn test_list_playlists_golden_path() {
|
|||
fn test_save_playlist_requires_auth() {
|
||||
let mut service = ServiceType::new(&test_name!());
|
||||
let my_playlist = dto::SavePlaylistInput { tracks: Vec::new() };
|
||||
let request = service
|
||||
.request_builder()
|
||||
.save_playlist(TEST_PLAYLIST_NAME, my_playlist);
|
||||
let request = protocol::save_playlist(TEST_PLAYLIST_NAME, my_playlist);
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
@ -41,9 +39,22 @@ fn test_save_playlist_golden_path() {
|
|||
service.login();
|
||||
|
||||
let my_playlist = dto::SavePlaylistInput { tracks: Vec::new() };
|
||||
let request = service
|
||||
.request_builder()
|
||||
.save_playlist(TEST_PLAYLIST_NAME, my_playlist);
|
||||
let request = protocol::save_playlist(TEST_PLAYLIST_NAME, my_playlist);
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_playlist_large() {
|
||||
let mut service = ServiceType::new(&test_name!());
|
||||
service.complete_initial_setup();
|
||||
service.login();
|
||||
|
||||
let tracks = (0..100_000)
|
||||
.map(|_| "My Super Cool Song".to_string())
|
||||
.collect();
|
||||
let my_playlist = dto::SavePlaylistInput { tracks };
|
||||
let request = protocol::save_playlist(TEST_PLAYLIST_NAME, my_playlist);
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
@ -51,7 +62,7 @@ fn test_save_playlist_golden_path() {
|
|||
#[test]
|
||||
fn test_get_playlist_requires_auth() {
|
||||
let mut service = ServiceType::new(&test_name!());
|
||||
let request = service.request_builder().read_playlist(TEST_PLAYLIST_NAME);
|
||||
let request = protocol::read_playlist(TEST_PLAYLIST_NAME);
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
@ -64,14 +75,12 @@ fn test_get_playlist_golden_path() {
|
|||
|
||||
{
|
||||
let my_playlist = dto::SavePlaylistInput { tracks: Vec::new() };
|
||||
let request = service
|
||||
.request_builder()
|
||||
.save_playlist(TEST_PLAYLIST_NAME, my_playlist);
|
||||
let request = protocol::save_playlist(TEST_PLAYLIST_NAME, my_playlist);
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
let request = service.request_builder().read_playlist(TEST_PLAYLIST_NAME);
|
||||
let request = protocol::read_playlist(TEST_PLAYLIST_NAME);
|
||||
let response = service.fetch_json::<_, Vec<index::Song>>(&request);
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
@ -82,7 +91,7 @@ fn test_get_playlist_bad_name_returns_not_found() {
|
|||
service.complete_initial_setup();
|
||||
service.login();
|
||||
|
||||
let request = service.request_builder().read_playlist(TEST_PLAYLIST_NAME);
|
||||
let request = protocol::read_playlist(TEST_PLAYLIST_NAME);
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
@ -90,9 +99,7 @@ fn test_get_playlist_bad_name_returns_not_found() {
|
|||
#[test]
|
||||
fn test_delete_playlist_requires_auth() {
|
||||
let mut service = ServiceType::new(&test_name!());
|
||||
let request = service
|
||||
.request_builder()
|
||||
.delete_playlist(TEST_PLAYLIST_NAME);
|
||||
let request = protocol::delete_playlist(TEST_PLAYLIST_NAME);
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
@ -105,16 +112,12 @@ fn test_delete_playlist_golden_path() {
|
|||
|
||||
{
|
||||
let my_playlist = dto::SavePlaylistInput { tracks: Vec::new() };
|
||||
let request = service
|
||||
.request_builder()
|
||||
.save_playlist(TEST_PLAYLIST_NAME, my_playlist);
|
||||
let request = protocol::save_playlist(TEST_PLAYLIST_NAME, my_playlist);
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
let request = service
|
||||
.request_builder()
|
||||
.delete_playlist(TEST_PLAYLIST_NAME);
|
||||
let request = protocol::delete_playlist(TEST_PLAYLIST_NAME);
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
@ -125,9 +128,7 @@ fn test_delete_playlist_bad_name_returns_not_found() {
|
|||
service.complete_initial_setup();
|
||||
service.login();
|
||||
|
||||
let request = service
|
||||
.request_builder()
|
||||
.delete_playlist(TEST_PLAYLIST_NAME);
|
||||
let request = protocol::delete_playlist(TEST_PLAYLIST_NAME);
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
|
|
@ -1,47 +0,0 @@
|
|||
use http::StatusCode;
|
||||
|
||||
use crate::app::user;
|
||||
use crate::service::test::{ServiceType, TestService};
|
||||
use crate::test_name;
|
||||
|
||||
#[test]
|
||||
fn test_get_preferences_requires_auth() {
|
||||
let mut service = ServiceType::new(&test_name!());
|
||||
let request = service.request_builder().get_preferences();
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_preferences_golden_path() {
|
||||
let mut service = ServiceType::new(&test_name!());
|
||||
service.complete_initial_setup();
|
||||
service.login();
|
||||
|
||||
let request = service.request_builder().get_preferences();
|
||||
let response = service.fetch_json::<_, user::Preferences>(&request);
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_put_preferences_requires_auth() {
|
||||
let mut service = ServiceType::new(&test_name!());
|
||||
let request = service
|
||||
.request_builder()
|
||||
.put_preferences(user::Preferences::default());
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_put_preferences_golden_path() {
|
||||
let mut service = ServiceType::new(&test_name!());
|
||||
service.complete_initial_setup();
|
||||
service.login();
|
||||
|
||||
let request = service
|
||||
.request_builder()
|
||||
.put_preferences(user::Preferences::default());
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
|
@ -2,208 +2,283 @@ use http::{method::Method, Request};
|
|||
use percent_encoding::{percent_encode, NON_ALPHANUMERIC};
|
||||
use std::path::Path;
|
||||
|
||||
use crate::app::{config, user};
|
||||
use crate::app::user;
|
||||
use crate::service::dto;
|
||||
|
||||
pub struct RequestBuilder {}
|
||||
pub fn web_index() -> Request<()> {
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/")
|
||||
.body(())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
impl RequestBuilder {
|
||||
pub fn new() -> Self {
|
||||
Self {}
|
||||
}
|
||||
pub fn swagger_index() -> Request<()> {
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/swagger")
|
||||
.body(())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub fn web_index(&self) -> Request<()> {
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/")
|
||||
.body(())
|
||||
.unwrap()
|
||||
}
|
||||
pub fn version() -> Request<()> {
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/api/version")
|
||||
.body(())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub fn swagger_index(&self) -> Request<()> {
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/swagger")
|
||||
.body(())
|
||||
.unwrap()
|
||||
}
|
||||
pub fn initial_setup() -> Request<()> {
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/api/initial_setup")
|
||||
.body(())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub fn version(&self) -> Request<()> {
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/api/version")
|
||||
.body(())
|
||||
.unwrap()
|
||||
}
|
||||
pub fn login(username: &str, password: &str) -> Request<dto::Credentials> {
|
||||
let credentials = dto::Credentials {
|
||||
username: username.into(),
|
||||
password: password.into(),
|
||||
};
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/api/auth")
|
||||
.body(credentials)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub fn initial_setup(&self) -> Request<()> {
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/api/initial_setup")
|
||||
.body(())
|
||||
.unwrap()
|
||||
}
|
||||
pub fn apply_config(config: dto::Config) -> Request<dto::Config> {
|
||||
Request::builder()
|
||||
.method(Method::PUT)
|
||||
.uri("/api/config")
|
||||
.body(config)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub fn login(&self, username: &str, password: &str) -> Request<dto::AuthCredentials> {
|
||||
let credentials = dto::AuthCredentials {
|
||||
username: username.into(),
|
||||
password: password.into(),
|
||||
};
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/api/auth")
|
||||
.body(credentials)
|
||||
.unwrap()
|
||||
}
|
||||
pub fn get_settings() -> Request<()> {
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/api/settings")
|
||||
.body(())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub fn get_settings(&self) -> Request<()> {
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/api/settings")
|
||||
.body(())
|
||||
.unwrap()
|
||||
}
|
||||
pub fn put_settings(settings: dto::NewSettings) -> Request<dto::NewSettings> {
|
||||
Request::builder()
|
||||
.method(Method::PUT)
|
||||
.uri("/api/settings")
|
||||
.body(settings)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub fn put_settings(&self, configuration: config::Config) -> Request<config::Config> {
|
||||
Request::builder()
|
||||
.method(Method::PUT)
|
||||
.uri("/api/settings")
|
||||
.body(configuration)
|
||||
.unwrap()
|
||||
}
|
||||
pub fn get_ddns_config() -> Request<()> {
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/api/ddns")
|
||||
.body(())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub fn get_preferences(&self) -> Request<()> {
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/api/preferences")
|
||||
.body(())
|
||||
.unwrap()
|
||||
}
|
||||
pub fn put_ddns_config(ddns_config: dto::DDNSConfig) -> Request<dto::DDNSConfig> {
|
||||
Request::builder()
|
||||
.method(Method::PUT)
|
||||
.uri("/api/ddns")
|
||||
.body(ddns_config)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub fn put_preferences(&self, preferences: user::Preferences) -> Request<user::Preferences> {
|
||||
Request::builder()
|
||||
.method(Method::PUT)
|
||||
.uri("/api/preferences")
|
||||
.body(preferences)
|
||||
.unwrap()
|
||||
}
|
||||
pub fn list_users() -> Request<()> {
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/api/users")
|
||||
.body(())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub fn trigger_index(&self) -> Request<()> {
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/api/trigger_index")
|
||||
.body(())
|
||||
.unwrap()
|
||||
}
|
||||
pub fn create_user(new_user: dto::NewUser) -> Request<dto::NewUser> {
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/api/user")
|
||||
.body(new_user)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub fn browse(&self, path: &Path) -> Request<()> {
|
||||
let path = path.to_string_lossy();
|
||||
let uri = format!("/api/browse/{}", url_encode(path.as_ref()));
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri(uri)
|
||||
.body(())
|
||||
.unwrap()
|
||||
}
|
||||
pub fn update_user(username: &str, user_update: dto::UserUpdate) -> Request<dto::UserUpdate> {
|
||||
Request::builder()
|
||||
.method(Method::PUT)
|
||||
.uri(format!("/api/user/{}", username))
|
||||
.body(user_update)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub fn flatten(&self, path: &Path) -> Request<()> {
|
||||
let path = path.to_string_lossy();
|
||||
let uri = format!("/api/flatten/{}", url_encode(path.as_ref()));
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri(uri)
|
||||
.body(())
|
||||
.unwrap()
|
||||
}
|
||||
pub fn delete_user(username: &str) -> Request<()> {
|
||||
Request::builder()
|
||||
.method(Method::DELETE)
|
||||
.uri(format!("/api/user/{}", username))
|
||||
.body(())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub fn random(&self) -> Request<()> {
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/api/random")
|
||||
.body(())
|
||||
.unwrap()
|
||||
}
|
||||
pub fn get_preferences() -> Request<()> {
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/api/preferences")
|
||||
.body(())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub fn recent(&self) -> Request<()> {
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/api/recent")
|
||||
.body(())
|
||||
.unwrap()
|
||||
}
|
||||
pub fn put_preferences(preferences: user::Preferences) -> Request<user::Preferences> {
|
||||
Request::builder()
|
||||
.method(Method::PUT)
|
||||
.uri("/api/preferences")
|
||||
.body(preferences)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub fn search(&self, query: &str) -> Request<()> {
|
||||
let uri = format!("/api/search/{}", url_encode(query));
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri(uri)
|
||||
.body(())
|
||||
.unwrap()
|
||||
}
|
||||
pub fn trigger_index() -> Request<()> {
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/api/trigger_index")
|
||||
.body(())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub fn audio(&self, path: &Path) -> Request<()> {
|
||||
let path = path.to_string_lossy();
|
||||
let uri = format!("/api/audio/{}", url_encode(path.as_ref()));
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri(uri)
|
||||
.body(())
|
||||
.unwrap()
|
||||
}
|
||||
pub fn browse(path: &Path) -> Request<()> {
|
||||
let path = path.to_string_lossy();
|
||||
let endpoint = format!("/api/browse/{}", url_encode(path.as_ref()));
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri(&endpoint)
|
||||
.body(())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub fn thumbnail(&self, path: &Path, pad: Option<bool>) -> Request<()> {
|
||||
let path = path.to_string_lossy();
|
||||
let mut uri = format!("/api/thumbnail/{}", url_encode(path.as_ref()));
|
||||
match pad {
|
||||
Some(true) => uri.push_str("?pad=true"),
|
||||
Some(false) => uri.push_str("?pad=false"),
|
||||
None => (),
|
||||
};
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri(uri)
|
||||
.body(())
|
||||
.unwrap()
|
||||
}
|
||||
pub fn flatten(path: &Path) -> Request<()> {
|
||||
let path = path.to_string_lossy();
|
||||
let endpoint = format!("/api/flatten/{}", url_encode(path.as_ref()));
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri(&endpoint)
|
||||
.body(())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub fn playlists(&self) -> Request<()> {
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/api/playlists")
|
||||
.body(())
|
||||
.unwrap()
|
||||
}
|
||||
pub fn random() -> Request<()> {
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/api/random")
|
||||
.body(())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub fn save_playlist(
|
||||
&self,
|
||||
name: &str,
|
||||
playlist: dto::SavePlaylistInput,
|
||||
) -> Request<dto::SavePlaylistInput> {
|
||||
let uri = format!("/api/playlist/{}", url_encode(name));
|
||||
Request::builder()
|
||||
.method(Method::PUT)
|
||||
.uri(uri)
|
||||
.body(playlist)
|
||||
.unwrap()
|
||||
}
|
||||
pub fn recent() -> Request<()> {
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/api/recent")
|
||||
.body(())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub fn read_playlist(&self, name: &str) -> Request<()> {
|
||||
let uri = format!("/api/playlist/{}", url_encode(name));
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri(uri)
|
||||
.body(())
|
||||
.unwrap()
|
||||
}
|
||||
pub fn search(query: &str) -> Request<()> {
|
||||
let endpoint = format!("/api/search/{}", url_encode(query));
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri(&endpoint)
|
||||
.body(())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub fn delete_playlist(&self, name: &str) -> Request<()> {
|
||||
let uri = format!("/api/playlist/{}", url_encode(name));
|
||||
Request::builder()
|
||||
.method(Method::DELETE)
|
||||
.uri(uri)
|
||||
.body(())
|
||||
.unwrap()
|
||||
}
|
||||
pub fn audio(path: &Path) -> Request<()> {
|
||||
let path = path.to_string_lossy();
|
||||
let endpoint = format!("/api/audio/{}", url_encode(path.as_ref()));
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri(&endpoint)
|
||||
.body(())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub fn thumbnail(path: &Path, pad: Option<bool>) -> Request<()> {
|
||||
let path = path.to_string_lossy();
|
||||
let mut endpoint = format!("/api/thumbnail/{}", url_encode(path.as_ref()));
|
||||
match pad {
|
||||
Some(true) => endpoint.push_str("?pad=true"),
|
||||
Some(false) => endpoint.push_str("?pad=false"),
|
||||
None => (),
|
||||
};
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri(&endpoint)
|
||||
.body(())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub fn playlists() -> Request<()> {
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/api/playlists")
|
||||
.body(())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub fn save_playlist(
|
||||
name: &str,
|
||||
playlist: dto::SavePlaylistInput,
|
||||
) -> Request<dto::SavePlaylistInput> {
|
||||
let endpoint = format!("/api/playlist/{}", url_encode(name));
|
||||
Request::builder()
|
||||
.method(Method::PUT)
|
||||
.uri(&endpoint)
|
||||
.body(playlist)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub fn read_playlist(name: &str) -> Request<()> {
|
||||
let endpoint = format!("/api/playlist/{}", url_encode(name));
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri(&endpoint)
|
||||
.body(())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub fn delete_playlist(name: &str) -> Request<()> {
|
||||
let endpoint = format!("/api/playlist/{}", url_encode(name));
|
||||
Request::builder()
|
||||
.method(Method::DELETE)
|
||||
.uri(&endpoint)
|
||||
.body(())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub fn lastfm_link_token() -> Request<()> {
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/api/lastfm/link_token")
|
||||
.body(())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub fn lastfm_now_playing(path: &Path) -> Request<()> {
|
||||
let path = path.to_string_lossy();
|
||||
let endpoint = format!("/api/lastfm/now_playing/{}", url_encode(path.as_ref()));
|
||||
Request::builder()
|
||||
.method(Method::PUT)
|
||||
.uri(&endpoint)
|
||||
.body(())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub fn lastfm_scrobble(path: &Path) -> Request<()> {
|
||||
let path = path.to_string_lossy();
|
||||
let endpoint = format!("/api/lastfm/scrobble/{}", url_encode(path.as_ref()));
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(&endpoint)
|
||||
.body(())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn url_encode(input: &str) -> String {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
use http::StatusCode;
|
||||
|
||||
use crate::app::config;
|
||||
use crate::service::test::{constants::*, ServiceType, TestService};
|
||||
use crate::service::dto;
|
||||
use crate::service::test::{protocol, ServiceType, TestService};
|
||||
use crate::test_name;
|
||||
|
||||
#[test]
|
||||
|
@ -9,7 +9,7 @@ fn test_get_settings_requires_auth() {
|
|||
let mut service = ServiceType::new(&test_name!());
|
||||
service.complete_initial_setup();
|
||||
|
||||
let request = service.request_builder().get_settings();
|
||||
let request = protocol::get_settings();
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
@ -20,7 +20,7 @@ fn test_get_settings_requires_admin() {
|
|||
service.complete_initial_setup();
|
||||
service.login();
|
||||
|
||||
let request = service.request_builder().get_settings();
|
||||
let request = protocol::get_settings();
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
@ -31,8 +31,8 @@ fn test_get_settings_golden_path() {
|
|||
service.complete_initial_setup();
|
||||
service.login_admin();
|
||||
|
||||
let request = service.request_builder().get_settings();
|
||||
let response = service.fetch_json::<_, config::Config>(&request);
|
||||
let request = protocol::get_settings();
|
||||
let response = service.fetch_json::<_, dto::Settings>(&request);
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
|
@ -40,9 +40,7 @@ fn test_get_settings_golden_path() {
|
|||
fn test_put_settings_requires_auth() {
|
||||
let mut service = ServiceType::new(&test_name!());
|
||||
service.complete_initial_setup();
|
||||
let request = service
|
||||
.request_builder()
|
||||
.put_settings(config::Config::default());
|
||||
let request = protocol::put_settings(dto::NewSettings::default());
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
@ -52,9 +50,7 @@ fn test_put_settings_requires_admin() {
|
|||
let mut service = ServiceType::new(&test_name!());
|
||||
service.complete_initial_setup();
|
||||
service.login();
|
||||
let request = service
|
||||
.request_builder()
|
||||
.put_settings(config::Config::default());
|
||||
let request = protocol::put_settings(dto::NewSettings::default());
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
@ -65,26 +61,7 @@ fn test_put_settings_golden_path() {
|
|||
service.complete_initial_setup();
|
||||
service.login_admin();
|
||||
|
||||
let request = service
|
||||
.request_builder()
|
||||
.put_settings(config::Config::default());
|
||||
let request = protocol::put_settings(dto::NewSettings::default());
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_put_settings_cannot_unadmin_self() {
|
||||
let mut service = ServiceType::new(&test_name!());
|
||||
service.complete_initial_setup();
|
||||
service.login_admin();
|
||||
|
||||
let mut configuration = config::Config::default();
|
||||
configuration.users = Some(vec![config::ConfigUser {
|
||||
name: TEST_USERNAME_ADMIN.into(),
|
||||
password: "".into(),
|
||||
admin: false,
|
||||
}]);
|
||||
let request = service.request_builder().put_settings(configuration);
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::CONFLICT);
|
||||
}
|
||||
|
|
|
@ -1,21 +1,21 @@
|
|||
use http::StatusCode;
|
||||
|
||||
use crate::service::test::{add_trailing_slash, ServiceType, TestService};
|
||||
use crate::service::test::{add_trailing_slash, protocol, ServiceType, TestService};
|
||||
use crate::test_name;
|
||||
|
||||
#[test]
|
||||
fn test_swagger_can_get_index() {
|
||||
let mut service = ServiceType::new(&test_name!());
|
||||
let request = service.request_builder().swagger_index();
|
||||
let request = protocol::swagger_index();
|
||||
let response = service.fetch(&request);
|
||||
let status = response.status();
|
||||
assert!(status == StatusCode::OK || status == StatusCode::PERMANENT_REDIRECT);
|
||||
assert_eq!(status, StatusCode::FOUND);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_swagger_can_get_index_with_trailing_slash() {
|
||||
let mut service = ServiceType::new(&test_name!());
|
||||
let mut request = service.request_builder().swagger_index();
|
||||
let mut request = protocol::swagger_index();
|
||||
add_trailing_slash(&mut request);
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
|
180
src/service/test/user.rs
Normal file
180
src/service/test/user.rs
Normal file
|
@ -0,0 +1,180 @@
|
|||
use http::StatusCode;
|
||||
use std::default::Default;
|
||||
|
||||
use crate::app::user;
|
||||
use crate::service::dto;
|
||||
use crate::service::test::{constants::*, protocol, ServiceType, TestService};
|
||||
use crate::test_name;
|
||||
|
||||
#[test]
|
||||
fn list_users_requires_admin() {
|
||||
let mut service = ServiceType::new(&test_name!());
|
||||
service.complete_initial_setup();
|
||||
let request = protocol::list_users();
|
||||
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
service.login();
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_users_golden_path() {
|
||||
let mut service = ServiceType::new(&test_name!());
|
||||
service.complete_initial_setup();
|
||||
service.login_admin();
|
||||
let request = protocol::list_users();
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_user_requires_admin() {
|
||||
let mut service = ServiceType::new(&test_name!());
|
||||
service.complete_initial_setup();
|
||||
let request = protocol::create_user(dto::NewUser {
|
||||
name: "Walter".into(),
|
||||
password: "secret".into(),
|
||||
admin: false,
|
||||
});
|
||||
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
service.login();
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_user_golden_path() {
|
||||
let mut service = ServiceType::new(&test_name!());
|
||||
service.complete_initial_setup();
|
||||
service.login_admin();
|
||||
|
||||
let new_user = dto::NewUser {
|
||||
name: "Walter".into(),
|
||||
password: "secret".into(),
|
||||
admin: false,
|
||||
};
|
||||
let request = protocol::create_user(new_user);
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_user_requires_admin() {
|
||||
let mut service = ServiceType::new(&test_name!());
|
||||
service.complete_initial_setup();
|
||||
let request = protocol::update_user("Walter", dto::UserUpdate::default());
|
||||
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
service.login();
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_user_golden_path() {
|
||||
let mut service = ServiceType::new(&test_name!());
|
||||
service.complete_initial_setup();
|
||||
let request = protocol::update_user("Walter", dto::UserUpdate::default());
|
||||
|
||||
service.login_admin();
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_user_cannot_unadmin_self() {
|
||||
let mut service = ServiceType::new(&test_name!());
|
||||
service.complete_initial_setup();
|
||||
let request = protocol::update_user(
|
||||
TEST_USERNAME_ADMIN,
|
||||
dto::UserUpdate {
|
||||
new_is_admin: Some(false),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
service.login_admin();
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::CONFLICT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_user_requires_admin() {
|
||||
let mut service = ServiceType::new(&test_name!());
|
||||
service.complete_initial_setup();
|
||||
let request = protocol::delete_user("Walter");
|
||||
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
service.login();
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_user_golden_path() {
|
||||
let mut service = ServiceType::new(&test_name!());
|
||||
service.complete_initial_setup();
|
||||
let request = protocol::delete_user("Walter");
|
||||
|
||||
service.login_admin();
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_user_cannot_delete_self() {
|
||||
let mut service = ServiceType::new(&test_name!());
|
||||
service.complete_initial_setup();
|
||||
let request = protocol::delete_user(TEST_USERNAME_ADMIN);
|
||||
service.login_admin();
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::CONFLICT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_preferences_requires_auth() {
|
||||
let mut service = ServiceType::new(&test_name!());
|
||||
let request = protocol::get_preferences();
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_preferences_golden_path() {
|
||||
let mut service = ServiceType::new(&test_name!());
|
||||
service.complete_initial_setup();
|
||||
service.login();
|
||||
|
||||
let request = protocol::get_preferences();
|
||||
let response = service.fetch_json::<_, user::Preferences>(&request);
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn put_preferences_requires_auth() {
|
||||
let mut service = ServiceType::new(&test_name!());
|
||||
let request = protocol::put_preferences(user::Preferences::default());
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn put_preferences_golden_path() {
|
||||
let mut service = ServiceType::new(&test_name!());
|
||||
service.complete_initial_setup();
|
||||
service.login();
|
||||
|
||||
let request = protocol::put_preferences(user::Preferences::default());
|
||||
let response = service.fetch(&request);
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
|
@ -1,12 +1,12 @@
|
|||
use http::StatusCode;
|
||||
|
||||
use crate::service::test::{ServiceType, TestService};
|
||||
use crate::service::test::{protocol, ServiceType, TestService};
|
||||
use crate::test_name;
|
||||
|
||||
#[test]
|
||||
fn test_serves_web_client() {
|
||||
let mut service = ServiceType::new(&test_name!());
|
||||
let request = service.request_builder().web_index();
|
||||
let request = protocol::web_index();
|
||||
let response = service.fetch_bytes(&request);
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
[settings]
|
||||
album_art_pattern = '^Folder\.(png|jpg|jpeg)$'
|
||||
|
||||
[[mount_dirs]]
|
||||
|
|
Loading…
Add table
Reference in a new issue