init
This commit is contained in:
5
.cargo/config.toml
Normal file
5
.cargo/config.toml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
[alias]
|
||||||
|
loco = "run --"
|
||||||
|
loco-tool = "run --"
|
||||||
|
|
||||||
|
playground = "run --example playground"
|
102
.github/workflows/ci.yaml
vendored
Normal file
102
.github/workflows/ci.yaml
vendored
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
name: CI
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
env:
|
||||||
|
RUST_TOOLCHAIN: stable
|
||||||
|
TOOLCHAIN_PROFILE: minimal
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
rustfmt:
|
||||||
|
name: Check Style
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout the code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
toolchain: ${{ env.RUST_TOOLCHAIN }}
|
||||||
|
components: rustfmt
|
||||||
|
- name: Run cargo fmt
|
||||||
|
uses: actions-rs/cargo@v1
|
||||||
|
with:
|
||||||
|
command: fmt
|
||||||
|
args: --all -- --check
|
||||||
|
|
||||||
|
clippy:
|
||||||
|
name: Run Clippy
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout the code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
toolchain: ${{ env.RUST_TOOLCHAIN }}
|
||||||
|
- name: Setup Rust cache
|
||||||
|
uses: Swatinem/rust-cache@v2
|
||||||
|
- name: Run cargo clippy
|
||||||
|
uses: actions-rs/cargo@v1
|
||||||
|
with:
|
||||||
|
command: clippy
|
||||||
|
args: --all-features -- -D warnings -W clippy::pedantic -W clippy::nursery -W rust-2018-idioms
|
||||||
|
|
||||||
|
test:
|
||||||
|
name: Run Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
services:
|
||||||
|
redis:
|
||||||
|
image: redis
|
||||||
|
options: >-
|
||||||
|
--health-cmd "redis-cli ping"
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
postgres:
|
||||||
|
image: postgres
|
||||||
|
env:
|
||||||
|
POSTGRES_DB: postgres_test
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
# Set health checks to wait until postgres has started
|
||||||
|
options: --health-cmd pg_isready
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout the code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
toolchain: ${{ env.RUST_TOOLCHAIN }}
|
||||||
|
- name: Setup Rust cache
|
||||||
|
uses: Swatinem/rust-cache@v2
|
||||||
|
- name: Run cargo test
|
||||||
|
uses: actions-rs/cargo@v1
|
||||||
|
with:
|
||||||
|
command: test
|
||||||
|
args: --all-features --all
|
||||||
|
env:
|
||||||
|
REDIS_URL: redis://localhost:${{job.services.redis.ports[6379]}}
|
||||||
|
DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres_test
|
||||||
|
|
20
.gitignore
vendored
Normal file
20
.gitignore
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
**/config/local.yaml
|
||||||
|
**/config/*.local.yaml
|
||||||
|
**/config/production.yaml
|
||||||
|
|
||||||
|
# Generated by Cargo
|
||||||
|
# will have compiled files and executables
|
||||||
|
debug/
|
||||||
|
target/
|
||||||
|
|
||||||
|
# include cargo lock
|
||||||
|
!Cargo.lock
|
||||||
|
|
||||||
|
# These are backup files generated by rustfmt
|
||||||
|
**/*.rs.bk
|
||||||
|
|
||||||
|
# MSVC Windows builds of rustc generate these, which store debugging information
|
||||||
|
*.pdb
|
||||||
|
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite-*
|
2
.rustfmt.toml
Normal file
2
.rustfmt.toml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
max_width = 100
|
||||||
|
use_small_heuristics = "Default"
|
6426
Cargo.lock
generated
Normal file
6426
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
53
Cargo.toml
Normal file
53
Cargo.toml
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
[workspace]
|
||||||
|
|
||||||
|
[package]
|
||||||
|
name = "music-metadata-manager"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
publish = false
|
||||||
|
default-run = "music_metadata_manager-cli"
|
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
loco-rs = { version = "0.15" }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
loco-rs = { workspace = true }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
tokio = { version = "1.33.0", default-features = false, features = [
|
||||||
|
"rt-multi-thread",
|
||||||
|
] }
|
||||||
|
async-trait = { version = "0.1.74" }
|
||||||
|
axum = { version = "0.8.1" }
|
||||||
|
tracing = { version = "0.1.40" }
|
||||||
|
tracing-subscriber = { version = "0.3.17", features = ["env-filter", "json"] }
|
||||||
|
regex = { version = "1.11.1" }
|
||||||
|
migration = { path = "migration" }
|
||||||
|
sea-orm = { version = "1.1.0", features = [
|
||||||
|
"sqlx-sqlite",
|
||||||
|
"sqlx-postgres",
|
||||||
|
"runtime-tokio-rustls",
|
||||||
|
"macros",
|
||||||
|
] }
|
||||||
|
chrono = { version = "0.4" }
|
||||||
|
validator = { version = "0.20" }
|
||||||
|
uuid = { version = "1.6.0", features = ["v4"] }
|
||||||
|
include_dir = { version = "0.7" }
|
||||||
|
reqwest = { version = "0.12.22", features = ["json"] }
|
||||||
|
lofty = "0.22.4"
|
||||||
|
walkdir = "2.5.0"
|
||||||
|
musicbrainz_rs = "0.12.0"
|
||||||
|
strsim = "0.11.1"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "music_metadata_manager-cli"
|
||||||
|
path = "src/bin/main.rs"
|
||||||
|
required-features = []
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
loco-rs = { workspace = true, features = ["testing"] }
|
||||||
|
serial_test = { version = "3.1.1" }
|
||||||
|
rstest = { version = "0.21.0" }
|
||||||
|
insta = { version = "1.34.0", features = ["redactions", "yaml", "filters"] }
|
58
README.md
Normal file
58
README.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# Welcome to Loco :train:
|
||||||
|
|
||||||
|
[Loco](https://loco.rs) is a web and API framework running on Rust.
|
||||||
|
|
||||||
|
This is the **SaaS starter** which includes a `User` model and authentication based on JWT.
|
||||||
|
It also include configuration sections that help you pick either a frontend or a server-side template set up for your fullstack server.
|
||||||
|
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo loco start
|
||||||
|
```
|
||||||
|
|
||||||
|
```sh
|
||||||
|
$ cargo loco start
|
||||||
|
Finished dev [unoptimized + debuginfo] target(s) in 21.63s
|
||||||
|
Running `target/debug/myapp start`
|
||||||
|
|
||||||
|
:
|
||||||
|
:
|
||||||
|
:
|
||||||
|
|
||||||
|
controller/app_routes.rs:203: [Middleware] Adding log trace id
|
||||||
|
|
||||||
|
▄ ▀
|
||||||
|
▀ ▄
|
||||||
|
▄ ▀ ▄ ▄ ▄▀
|
||||||
|
▄ ▀▄▄
|
||||||
|
▄ ▀ ▀ ▀▄▀█▄
|
||||||
|
▀█▄
|
||||||
|
▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄▄ ▀▀█
|
||||||
|
██████ █████ ███ █████ ███ █████ ███ ▀█
|
||||||
|
██████ █████ ███ █████ ▀▀▀ █████ ███ ▄█▄
|
||||||
|
██████ █████ ███ █████ █████ ███ ████▄
|
||||||
|
██████ █████ ███ █████ ▄▄▄ █████ ███ █████
|
||||||
|
██████ █████ ███ ████ ███ █████ ███ ████▀
|
||||||
|
▀▀▀██▄ ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ██▀
|
||||||
|
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||||
|
https://loco.rs
|
||||||
|
|
||||||
|
environment: development
|
||||||
|
database: automigrate
|
||||||
|
logger: debug
|
||||||
|
compilation: debug
|
||||||
|
modes: server
|
||||||
|
|
||||||
|
listening on http://localhost:5150
|
||||||
|
```
|
||||||
|
|
||||||
|
## Full Stack Serving
|
||||||
|
|
||||||
|
You can check your [configuration](config/development.yaml) to pick either frontend setup or server-side rendered template, and activate the relevant configuration sections.
|
||||||
|
|
||||||
|
|
||||||
|
## Getting help
|
||||||
|
|
||||||
|
Check out [a quick tour](https://loco.rs/docs/getting-started/tour/) or [the complete guide](https://loco.rs/docs/getting-started/guide/).
|
91
config/development.yaml
Normal file
91
config/development.yaml
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
# Loco configuration file documentation
|
||||||
|
|
||||||
|
# Application logging configuration
|
||||||
|
logger:
|
||||||
|
# Enable or disable logging.
|
||||||
|
enable: true
|
||||||
|
# Enable pretty backtrace (sets RUST_BACKTRACE=1)
|
||||||
|
pretty_backtrace: true
|
||||||
|
# Log level, options: trace, debug, info, warn or error.
|
||||||
|
level: debug
|
||||||
|
# Define the logging format. options: compact, pretty or json
|
||||||
|
format: compact
|
||||||
|
# By default the logger has filtering only logs that came from your code or logs that came from `loco` framework. to see all third party libraries
|
||||||
|
# Uncomment the line below to override to see all third party libraries you can enable this config and override the logger filters.
|
||||||
|
# override_filter: trace
|
||||||
|
|
||||||
|
# Web server configuration
|
||||||
|
server:
|
||||||
|
# Port on which the server will listen. the server binding is 0.0.0.0:{PORT}
|
||||||
|
port: 5150
|
||||||
|
# Binding for the server (which interface to bind to)
|
||||||
|
binding: localhost
|
||||||
|
# The UI hostname or IP address that mailers will point to.
|
||||||
|
host: http://localhost
|
||||||
|
# Out of the box middleware configuration. to disable middleware you can changed the `enable` field to `false` of comment the middleware block
|
||||||
|
middlewares:
|
||||||
|
|
||||||
|
# Worker Configuration
|
||||||
|
workers:
|
||||||
|
# specifies the worker mode. Options:
|
||||||
|
# - BackgroundQueue - Workers operate asynchronously in the background, processing queued.
|
||||||
|
# - ForegroundBlocking - Workers operate in the foreground and block until tasks are completed.
|
||||||
|
# - BackgroundAsync - Workers operate asynchronously in the background, processing tasks with async capabilities.
|
||||||
|
mode: BackgroundAsync
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Mailer Configuration.
|
||||||
|
mailer:
|
||||||
|
# SMTP mailer configuration.
|
||||||
|
smtp:
|
||||||
|
# Enable/Disable smtp mailer.
|
||||||
|
enable: true
|
||||||
|
# SMTP server host. e.x localhost, smtp.gmail.com
|
||||||
|
host: localhost
|
||||||
|
# SMTP server port
|
||||||
|
port: 1025
|
||||||
|
# Use secure connection (SSL/TLS).
|
||||||
|
secure: false
|
||||||
|
# auth:
|
||||||
|
# user:
|
||||||
|
# password:
|
||||||
|
# Override the SMTP hello name (default is the machine's hostname)
|
||||||
|
# hello_name:
|
||||||
|
|
||||||
|
# Initializers Configuration
|
||||||
|
# initializers:
|
||||||
|
# oauth2:
|
||||||
|
# authorization_code: # Authorization code grant type
|
||||||
|
# - client_identifier: google # Identifier for the OAuth2 provider. Replace 'google' with your provider's name if different, must be unique within the oauth2 config.
|
||||||
|
# ... other fields
|
||||||
|
|
||||||
|
# Database Configuration
|
||||||
|
database:
|
||||||
|
# Database connection URI
|
||||||
|
uri: {{ get_env(name="DATABASE_URL", default="sqlite://music-metadata-manager_development.sqlite?mode=rwc") }}
|
||||||
|
# When enabled, the sql query will be logged.
|
||||||
|
enable_logging: false
|
||||||
|
# Set the timeout duration when acquiring a connection.
|
||||||
|
connect_timeout: {{ get_env(name="DB_CONNECT_TIMEOUT", default="500") }}
|
||||||
|
# Set the idle duration before closing a connection.
|
||||||
|
idle_timeout: {{ get_env(name="DB_IDLE_TIMEOUT", default="500") }}
|
||||||
|
# Minimum number of connections for a pool.
|
||||||
|
min_connections: {{ get_env(name="DB_MIN_CONNECTIONS", default="1") }}
|
||||||
|
# Maximum number of connections for a pool.
|
||||||
|
max_connections: {{ get_env(name="DB_MAX_CONNECTIONS", default="1") }}
|
||||||
|
# Run migration up when application loaded
|
||||||
|
auto_migrate: true
|
||||||
|
# Truncate database when application loaded. This is a dangerous operation, make sure that you using this flag only on dev environments or test mode
|
||||||
|
dangerously_truncate: false
|
||||||
|
# Recreating schema when application loaded. This is a dangerous operation, make sure that you using this flag only on dev environments or test mode
|
||||||
|
dangerously_recreate: false
|
||||||
|
|
||||||
|
# Authentication Configuration
|
||||||
|
auth:
|
||||||
|
# JWT authentication
|
||||||
|
jwt:
|
||||||
|
# Secret key for token generation and verification
|
||||||
|
secret: 4VEpsjezUiURJAzN1rfA
|
||||||
|
# Token expiration time in seconds
|
||||||
|
expiration: 604800 # 7 days
|
88
config/test.yaml
Normal file
88
config/test.yaml
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# Loco configuration file documentation
|
||||||
|
|
||||||
|
# Application logging configuration
|
||||||
|
logger:
|
||||||
|
# Enable or disable logging.
|
||||||
|
enable: false
|
||||||
|
# Enable pretty backtrace (sets RUST_BACKTRACE=1)
|
||||||
|
pretty_backtrace: true
|
||||||
|
# Log level, options: trace, debug, info, warn or error.
|
||||||
|
level: debug
|
||||||
|
# Define the logging format. options: compact, pretty or json
|
||||||
|
format: compact
|
||||||
|
# By default the logger has filtering only logs that came from your code or logs that came from `loco` framework. to see all third party libraries
|
||||||
|
# Uncomment the line below to override to see all third party libraries you can enable this config and override the logger filters.
|
||||||
|
# override_filter: trace
|
||||||
|
|
||||||
|
# Web server configuration
|
||||||
|
server:
|
||||||
|
# Port on which the server will listen. the server binding is 0.0.0.0:{PORT}
|
||||||
|
port: 5150
|
||||||
|
# The UI hostname or IP address that mailers will point to.
|
||||||
|
host: http://localhost
|
||||||
|
# Out of the box middleware configuration. to disable middleware you can changed the `enable` field to `false` of comment the middleware block
|
||||||
|
middlewares:
|
||||||
|
|
||||||
|
# Worker Configuration
|
||||||
|
workers:
|
||||||
|
# specifies the worker mode. Options:
|
||||||
|
# - BackgroundQueue - Workers operate asynchronously in the background, processing queued.
|
||||||
|
# - ForegroundBlocking - Workers operate in the foreground and block until tasks are completed.
|
||||||
|
# - BackgroundAsync - Workers operate asynchronously in the background, processing tasks with async capabilities.
|
||||||
|
mode: ForegroundBlocking
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Mailer Configuration.
|
||||||
|
mailer:
|
||||||
|
stub: true
|
||||||
|
# SMTP mailer configuration.
|
||||||
|
smtp:
|
||||||
|
# Enable/Disable smtp mailer.
|
||||||
|
enable: true
|
||||||
|
# SMTP server host. e.x localhost, smtp.gmail.com
|
||||||
|
host: localhost
|
||||||
|
# SMTP server port
|
||||||
|
port: 1025
|
||||||
|
# Use secure connection (SSL/TLS).
|
||||||
|
secure: false
|
||||||
|
# auth:
|
||||||
|
# user:
|
||||||
|
# password:
|
||||||
|
|
||||||
|
# Initializers Configuration
|
||||||
|
# initializers:
|
||||||
|
# oauth2:
|
||||||
|
# authorization_code: # Authorization code grant type
|
||||||
|
# - client_identifier: google # Identifier for the OAuth2 provider. Replace 'google' with your provider's name if different, must be unique within the oauth2 config.
|
||||||
|
# ... other fields
|
||||||
|
|
||||||
|
# Database Configuration
|
||||||
|
database:
|
||||||
|
# Database connection URI
|
||||||
|
uri: {{ get_env(name="DATABASE_URL", default="sqlite://music-metadata-manager_test.sqlite?mode=rwc") }}
|
||||||
|
# When enabled, the sql query will be logged.
|
||||||
|
enable_logging: false
|
||||||
|
# Set the timeout duration when acquiring a connection.
|
||||||
|
connect_timeout: {{ get_env(name="DB_CONNECT_TIMEOUT", default="500") }}
|
||||||
|
# Set the idle duration before closing a connection.
|
||||||
|
idle_timeout: {{ get_env(name="DB_IDLE_TIMEOUT", default="500") }}
|
||||||
|
# Minimum number of connections for a pool.
|
||||||
|
min_connections: {{ get_env(name="DB_MIN_CONNECTIONS", default="1") }}
|
||||||
|
# Maximum number of connections for a pool.
|
||||||
|
max_connections: {{ get_env(name="DB_MAX_CONNECTIONS", default="1") }}
|
||||||
|
# Run migration up when application loaded
|
||||||
|
auto_migrate: true
|
||||||
|
# Truncate database when application loaded. This is a dangerous operation, make sure that you using this flag only on dev environments or test mode
|
||||||
|
dangerously_truncate: true
|
||||||
|
# Recreating schema when application loaded. This is a dangerous operation, make sure that you using this flag only on dev environments or test mode
|
||||||
|
dangerously_recreate: true
|
||||||
|
|
||||||
|
# Authentication Configuration
|
||||||
|
auth:
|
||||||
|
# JWT authentication
|
||||||
|
jwt:
|
||||||
|
# Secret key for token generation and verification
|
||||||
|
secret: 0vTEUigOEKzKRKXgjfHx
|
||||||
|
# Token expiration time in seconds
|
||||||
|
expiration: 604800 # 7 days
|
21
examples/playground.rs
Normal file
21
examples/playground.rs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
#[allow(unused_imports)]
|
||||||
|
use loco_rs::{cli::playground, prelude::*};
|
||||||
|
use music_metadata_manager::app::App;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> loco_rs::Result<()> {
|
||||||
|
let _ctx = playground::<App>().await?;
|
||||||
|
|
||||||
|
// let active_model: articles::ActiveModel = articles::ActiveModel {
|
||||||
|
// title: Set(Some("how to build apps in 3 steps".to_string())),
|
||||||
|
// content: Set(Some("use Loco: https://loco.rs".to_string())),
|
||||||
|
// ..Default::default()
|
||||||
|
// };
|
||||||
|
// active_model.insert(&ctx.db).await.unwrap();
|
||||||
|
|
||||||
|
// let res = articles::Entity::find().all(&ctx.db).await.unwrap();
|
||||||
|
// println!("{:?}", res);
|
||||||
|
println!("welcome to playground. edit me at `examples/playground.rs`");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
23
migration/Cargo.toml
Normal file
23
migration/Cargo.toml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
[package]
|
||||||
|
name = "migration"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "migration"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
async-std = { version = "1", features = ["attributes", "tokio1"] }
|
||||||
|
loco-rs = { workspace = true }
|
||||||
|
|
||||||
|
|
||||||
|
[dependencies.sea-orm-migration]
|
||||||
|
version = "1.1.0"
|
||||||
|
features = [
|
||||||
|
# Enable at least one `ASYNC_RUNTIME` and `DATABASE_DRIVER` feature if you want to run migration via CLI.
|
||||||
|
# View the list of supported features at https://www.sea-ql.org/SeaORM/docs/install-and-config/database-and-async-runtime.
|
||||||
|
# e.g.
|
||||||
|
"runtime-tokio-rustls", # `ASYNC_RUNTIME` feature
|
||||||
|
]
|
22
migration/src/lib.rs
Normal file
22
migration/src/lib.rs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
#![allow(elided_lifetimes_in_paths)]
|
||||||
|
#![allow(clippy::wildcard_imports)]
|
||||||
|
pub use sea_orm_migration::prelude::*;
|
||||||
|
mod m20220101_000001_users;
|
||||||
|
|
||||||
|
mod m20250724_214338_music_libraries;
|
||||||
|
mod m20250724_214844_music_files;
|
||||||
|
mod m20250724_223717_add_idx_unique_path_per_library;
|
||||||
|
pub struct Migrator;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigratorTrait for Migrator {
|
||||||
|
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
|
||||||
|
vec![
|
||||||
|
Box::new(m20220101_000001_users::Migration),
|
||||||
|
Box::new(m20250724_214338_music_libraries::Migration),
|
||||||
|
Box::new(m20250724_214844_music_files::Migration),
|
||||||
|
Box::new(m20250724_223717_add_idx_unique_path_per_library::Migration),
|
||||||
|
// inject-above (do not remove this comment)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
41
migration/src/m20220101_000001_users.rs
Normal file
41
migration/src/m20220101_000001_users.rs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
use loco_rs::schema::*;
|
||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
create_table(
|
||||||
|
m,
|
||||||
|
"users",
|
||||||
|
&[
|
||||||
|
("id", ColType::PkAuto),
|
||||||
|
("pid", ColType::Uuid),
|
||||||
|
("email", ColType::StringUniq),
|
||||||
|
("password", ColType::String),
|
||||||
|
("api_key", ColType::StringUniq),
|
||||||
|
("name", ColType::String),
|
||||||
|
("reset_token", ColType::StringNull),
|
||||||
|
("reset_sent_at", ColType::TimestampWithTimeZoneNull),
|
||||||
|
("email_verification_token", ColType::StringNull),
|
||||||
|
(
|
||||||
|
"email_verification_sent_at",
|
||||||
|
ColType::TimestampWithTimeZoneNull,
|
||||||
|
),
|
||||||
|
("email_verified_at", ColType::TimestampWithTimeZoneNull),
|
||||||
|
("magic_link_token", ColType::StringNull),
|
||||||
|
("magic_link_expiration", ColType::TimestampWithTimeZoneNull),
|
||||||
|
],
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
drop_table(m, "users").await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
26
migration/src/m20250724_214338_music_libraries.rs
Normal file
26
migration/src/m20250724_214338_music_libraries.rs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
use loco_rs::schema::*;
|
||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
create_table(m, "music_libraries",
|
||||||
|
&[
|
||||||
|
|
||||||
|
("id", ColType::PkAuto),
|
||||||
|
|
||||||
|
("path", ColType::StringNull),
|
||||||
|
],
|
||||||
|
&[
|
||||||
|
("user", ""),
|
||||||
|
]
|
||||||
|
).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
drop_table(m, "music_libraries").await
|
||||||
|
}
|
||||||
|
}
|
29
migration/src/m20250724_214844_music_files.rs
Normal file
29
migration/src/m20250724_214844_music_files.rs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
use loco_rs::schema::*;
|
||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
create_table(
|
||||||
|
m,
|
||||||
|
"music_files",
|
||||||
|
&[
|
||||||
|
("id", ColType::PkAuto),
|
||||||
|
("path", ColType::String),
|
||||||
|
("title", ColType::StringNull),
|
||||||
|
("artist", ColType::StringNull),
|
||||||
|
("album", ColType::StringNull),
|
||||||
|
("metadata", ColType::JsonNull),
|
||||||
|
],
|
||||||
|
&[("music_library", "")],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
drop_table(m, "music_files").await
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,33 @@
|
|||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum MusicFiles {
|
||||||
|
Table,
|
||||||
|
MusicLibraryId,
|
||||||
|
Path,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
m.create_index(
|
||||||
|
Index::create()
|
||||||
|
.name("idx_unique_path_per_library")
|
||||||
|
.table(MusicFiles::Table)
|
||||||
|
.col(MusicFiles::MusicLibraryId)
|
||||||
|
.col(MusicFiles::Path)
|
||||||
|
.unique()
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
m.drop_index(Index::drop().name("idx_unique_path_per_library").to_owned())
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
75
src/app.rs
Normal file
75
src/app.rs
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use loco_rs::{
|
||||||
|
app::{AppContext, Hooks, Initializer},
|
||||||
|
bgworker::{BackgroundWorker, Queue},
|
||||||
|
boot::{create_app, BootResult, StartMode},
|
||||||
|
config::Config,
|
||||||
|
controller::AppRoutes,
|
||||||
|
db::{self, truncate_table},
|
||||||
|
environment::Environment,
|
||||||
|
task::Tasks,
|
||||||
|
Result,
|
||||||
|
};
|
||||||
|
use migration::Migrator;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
use crate::{controllers, models::_entities::users, tasks, workers::downloader::DownloadWorker};
|
||||||
|
|
||||||
|
pub struct App;
|
||||||
|
#[async_trait]
|
||||||
|
impl Hooks for App {
|
||||||
|
fn app_name() -> &'static str {
|
||||||
|
env!("CARGO_CRATE_NAME")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn app_version() -> String {
|
||||||
|
format!(
|
||||||
|
"{} ({})",
|
||||||
|
env!("CARGO_PKG_VERSION"),
|
||||||
|
option_env!("BUILD_SHA")
|
||||||
|
.or(option_env!("GITHUB_SHA"))
|
||||||
|
.unwrap_or("dev")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn boot(
|
||||||
|
mode: StartMode,
|
||||||
|
environment: &Environment,
|
||||||
|
config: Config,
|
||||||
|
) -> Result<BootResult> {
|
||||||
|
create_app::<Self, Migrator>(mode, environment, config).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn initializers(_ctx: &AppContext) -> Result<Vec<Box<dyn Initializer>>> {
|
||||||
|
Ok(vec![])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn routes(_ctx: &AppContext) -> AppRoutes {
|
||||||
|
AppRoutes::with_default_routes() // controller routes below
|
||||||
|
.add_route(controllers::musicbrainz::routes())
|
||||||
|
.add_route(controllers::music_file::routes())
|
||||||
|
.add_route(controllers::music_library::routes())
|
||||||
|
.add_route(controllers::auth::routes())
|
||||||
|
}
|
||||||
|
async fn connect_workers(ctx: &AppContext, queue: &Queue) -> Result<()> {
|
||||||
|
queue.register(crate::workers::scan_library_worker::Worker::build(ctx)).await?;
|
||||||
|
queue.register(DownloadWorker::build(ctx)).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(unused_variables)]
|
||||||
|
fn register_tasks(tasks: &mut Tasks) {
|
||||||
|
tasks.register(tasks::create_user::CreateUser);
|
||||||
|
// tasks-inject (do not remove)
|
||||||
|
}
|
||||||
|
async fn truncate(ctx: &AppContext) -> Result<()> {
|
||||||
|
truncate_table(&ctx.db, users::Entity).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
async fn seed(ctx: &AppContext, base: &Path) -> Result<()> {
|
||||||
|
db::seed::<users::ActiveModel>(&ctx.db, &base.join("users.yaml").display().to_string())
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
8
src/bin/main.rs
Normal file
8
src/bin/main.rs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
use loco_rs::cli;
|
||||||
|
use migration::Migrator;
|
||||||
|
use music_metadata_manager::app::App;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> loco_rs::Result<()> {
|
||||||
|
cli::main::<App, Migrator>().await
|
||||||
|
}
|
228
src/controllers/auth.rs
Normal file
228
src/controllers/auth.rs
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
use crate::{
|
||||||
|
mailers::auth::AuthMailer,
|
||||||
|
models::{
|
||||||
|
_entities::users,
|
||||||
|
users::{LoginParams, RegisterParams},
|
||||||
|
},
|
||||||
|
views::auth::{CurrentResponse, LoginResponse},
|
||||||
|
};
|
||||||
|
use axum::debug_handler;
|
||||||
|
use loco_rs::prelude::*;
|
||||||
|
use regex::Regex;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
pub static EMAIL_DOMAIN_RE: OnceLock<Regex> = OnceLock::new();
|
||||||
|
|
||||||
|
fn get_allow_email_domain_re() -> &'static Regex {
|
||||||
|
EMAIL_DOMAIN_RE.get_or_init(|| {
|
||||||
|
Regex::new(r"@example\.com$|@gmail\.com$").expect("Failed to compile regex")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
pub struct ForgotParams {
|
||||||
|
pub email: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
pub struct ResetParams {
|
||||||
|
pub token: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
pub struct MagicLinkParams {
|
||||||
|
pub email: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register function creates a new user with the given parameters and sends a
|
||||||
|
/// welcome email to the user
|
||||||
|
#[debug_handler]
|
||||||
|
async fn register(
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Json(params): Json<RegisterParams>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let res = users::Model::create_with_password(&ctx.db, ¶ms).await;
|
||||||
|
|
||||||
|
let user = match res {
|
||||||
|
Ok(user) => user,
|
||||||
|
Err(err) => {
|
||||||
|
tracing::info!(
|
||||||
|
message = err.to_string(),
|
||||||
|
user_email = ¶ms.email,
|
||||||
|
"could not register user",
|
||||||
|
);
|
||||||
|
return format::json(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let user = user
|
||||||
|
.into_active_model()
|
||||||
|
.set_email_verification_sent(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
AuthMailer::send_welcome(&ctx, &user).await?;
|
||||||
|
|
||||||
|
format::json(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify register user. if the user not verified his email, he can't login to
|
||||||
|
/// the system.
|
||||||
|
#[debug_handler]
|
||||||
|
async fn verify(State(ctx): State<AppContext>, Path(token): Path<String>) -> Result<Response> {
|
||||||
|
let user = users::Model::find_by_verification_token(&ctx.db, &token).await?;
|
||||||
|
|
||||||
|
if user.email_verified_at.is_some() {
|
||||||
|
tracing::info!(pid = user.pid.to_string(), "user already verified");
|
||||||
|
} else {
|
||||||
|
let active_model = user.into_active_model();
|
||||||
|
let user = active_model.verified(&ctx.db).await?;
|
||||||
|
tracing::info!(pid = user.pid.to_string(), "user verified");
|
||||||
|
}
|
||||||
|
|
||||||
|
format::json(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In case the user forgot his password this endpoints generate a forgot token
|
||||||
|
/// and send email to the user. In case the email not found in our DB, we are
|
||||||
|
/// returning a valid request for for security reasons (not exposing users DB
|
||||||
|
/// list).
|
||||||
|
#[debug_handler]
|
||||||
|
async fn forgot(
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Json(params): Json<ForgotParams>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let Ok(user) = users::Model::find_by_email(&ctx.db, ¶ms.email).await else {
|
||||||
|
// we don't want to expose our users email. if the email is invalid we still
|
||||||
|
// returning success to the caller
|
||||||
|
return format::json(());
|
||||||
|
};
|
||||||
|
|
||||||
|
let user = user
|
||||||
|
.into_active_model()
|
||||||
|
.set_forgot_password_sent(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
AuthMailer::forgot_password(&ctx, &user).await?;
|
||||||
|
|
||||||
|
format::json(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// reset user password by the given parameters
|
||||||
|
#[debug_handler]
|
||||||
|
async fn reset(State(ctx): State<AppContext>, Json(params): Json<ResetParams>) -> Result<Response> {
|
||||||
|
let Ok(user) = users::Model::find_by_reset_token(&ctx.db, ¶ms.token).await else {
|
||||||
|
// we don't want to expose our users email. if the email is invalid we still
|
||||||
|
// returning success to the caller
|
||||||
|
tracing::info!("reset token not found");
|
||||||
|
|
||||||
|
return format::json(());
|
||||||
|
};
|
||||||
|
user.into_active_model()
|
||||||
|
.reset_password(&ctx.db, ¶ms.password)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
format::json(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a user login and returns a token
|
||||||
|
#[debug_handler]
|
||||||
|
async fn login(State(ctx): State<AppContext>, Json(params): Json<LoginParams>) -> Result<Response> {
|
||||||
|
let user = users::Model::find_by_email(&ctx.db, ¶ms.email).await?;
|
||||||
|
|
||||||
|
let valid = user.verify_password(¶ms.password);
|
||||||
|
|
||||||
|
if !valid {
|
||||||
|
return unauthorized("unauthorized!");
|
||||||
|
}
|
||||||
|
|
||||||
|
let jwt_secret = ctx.config.get_jwt_config()?;
|
||||||
|
|
||||||
|
let token = user
|
||||||
|
.generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
|
||||||
|
.or_else(|_| unauthorized("unauthorized!"))?;
|
||||||
|
|
||||||
|
format::json(LoginResponse::new(&user, &token))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn current(auth: auth::JWT, State(ctx): State<AppContext>) -> Result<Response> {
|
||||||
|
let user = users::Model::find_by_pid(&ctx.db, &auth.claims.pid).await?;
|
||||||
|
format::json(CurrentResponse::new(&user))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Magic link authentication provides a secure and passwordless way to log in to the application.
|
||||||
|
///
|
||||||
|
/// # Flow
|
||||||
|
/// 1. **Request a Magic Link**:
|
||||||
|
/// A registered user sends a POST request to `/magic-link` with their email.
|
||||||
|
/// If the email exists, a short-lived, one-time-use token is generated and sent to the user's email.
|
||||||
|
/// For security and to avoid exposing whether an email exists, the response always returns 200, even if the email is invalid.
|
||||||
|
///
|
||||||
|
/// 2. **Click the Magic Link**:
|
||||||
|
/// The user clicks the link (/magic-link/{token}), which validates the token and its expiration.
|
||||||
|
/// If valid, the server generates a JWT and responds with a [`LoginResponse`].
|
||||||
|
/// If invalid or expired, an unauthorized response is returned.
|
||||||
|
///
|
||||||
|
/// This flow enhances security by avoiding traditional passwords and providing a seamless login experience.
|
||||||
|
async fn magic_link(
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Json(params): Json<MagicLinkParams>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let email_regex = get_allow_email_domain_re();
|
||||||
|
if !email_regex.is_match(¶ms.email) {
|
||||||
|
tracing::debug!(
|
||||||
|
email = params.email,
|
||||||
|
"The provided email is invalid or does not match the allowed domains"
|
||||||
|
);
|
||||||
|
return bad_request("invalid request");
|
||||||
|
}
|
||||||
|
|
||||||
|
let Ok(user) = users::Model::find_by_email(&ctx.db, ¶ms.email).await else {
|
||||||
|
// we don't want to expose our users email. if the email is invalid we still
|
||||||
|
// returning success to the caller
|
||||||
|
tracing::debug!(email = params.email, "user not found by email");
|
||||||
|
return format::empty_json();
|
||||||
|
};
|
||||||
|
|
||||||
|
let user = user.into_active_model().create_magic_link(&ctx.db).await?;
|
||||||
|
AuthMailer::send_magic_link(&ctx, &user).await?;
|
||||||
|
|
||||||
|
format::empty_json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verifies a magic link token and authenticates the user.
|
||||||
|
async fn magic_link_verify(
|
||||||
|
Path(token): Path<String>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let Ok(user) = users::Model::find_by_magic_token(&ctx.db, &token).await else {
|
||||||
|
// we don't want to expose our users email. if the email is invalid we still
|
||||||
|
// returning success to the caller
|
||||||
|
return unauthorized("unauthorized!");
|
||||||
|
};
|
||||||
|
|
||||||
|
let user = user.into_active_model().clear_magic_link(&ctx.db).await?;
|
||||||
|
|
||||||
|
let jwt_secret = ctx.config.get_jwt_config()?;
|
||||||
|
|
||||||
|
let token = user
|
||||||
|
.generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
|
||||||
|
.or_else(|_| unauthorized("unauthorized!"))?;
|
||||||
|
|
||||||
|
format::json(LoginResponse::new(&user, &token))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn routes() -> Routes {
|
||||||
|
Routes::new()
|
||||||
|
.prefix("/api/auth")
|
||||||
|
.add("/register", post(register))
|
||||||
|
.add("/verify/{token}", get(verify))
|
||||||
|
.add("/login", post(login))
|
||||||
|
.add("/forgot", post(forgot))
|
||||||
|
.add("/reset", post(reset))
|
||||||
|
.add("/current", get(current))
|
||||||
|
.add("/magic-link", post(magic_link))
|
||||||
|
.add("/magic-link/{token}", get(magic_link_verify))
|
||||||
|
}
|
5
src/controllers/mod.rs
Normal file
5
src/controllers/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
pub mod auth;
|
||||||
|
|
||||||
|
pub mod music_library;
|
||||||
|
pub mod music_file;
|
||||||
|
pub mod musicbrainz;
|
82
src/controllers/music_file.rs
Normal file
82
src/controllers/music_file.rs
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
#![allow(clippy::missing_errors_doc)]
|
||||||
|
#![allow(clippy::unnecessary_struct_initialization)]
|
||||||
|
#![allow(clippy::unused_async)]
|
||||||
|
use loco_rs::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use axum::debug_handler;
|
||||||
|
|
||||||
|
use crate::models::_entities::music_files::{ActiveModel, Entity, Model};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Params {
|
||||||
|
pub path: String,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub artist: Option<String>,
|
||||||
|
pub album: Option<String>,
|
||||||
|
pub metadata: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Params {
|
||||||
|
fn update(&self, item: &mut ActiveModel) {
|
||||||
|
item.path = Set(self.path.clone());
|
||||||
|
item.title = Set(self.title.clone());
|
||||||
|
item.artist = Set(self.artist.clone());
|
||||||
|
item.album = Set(self.album.clone());
|
||||||
|
item.metadata = Set(self.metadata.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_item(ctx: &AppContext, id: i32) -> Result<Model> {
|
||||||
|
let item = Entity::find_by_id(id).one(&ctx.db).await?;
|
||||||
|
item.ok_or_else(|| Error::NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn list(State(ctx): State<AppContext>) -> Result<Response> {
|
||||||
|
format::json(Entity::find().all(&ctx.db).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn add(State(ctx): State<AppContext>, Json(params): Json<Params>) -> Result<Response> {
|
||||||
|
let mut item = ActiveModel {
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
params.update(&mut item);
|
||||||
|
let item = item.insert(&ctx.db).await?;
|
||||||
|
format::json(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn update(
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Json(params): Json<Params>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let item = load_item(&ctx, id).await?;
|
||||||
|
let mut item = item.into_active_model();
|
||||||
|
params.update(&mut item);
|
||||||
|
let item = item.update(&ctx.db).await?;
|
||||||
|
format::json(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn remove(Path(id): Path<i32>, State(ctx): State<AppContext>) -> Result<Response> {
|
||||||
|
load_item(&ctx, id).await?.delete(&ctx.db).await?;
|
||||||
|
format::empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn get_one(Path(id): Path<i32>, State(ctx): State<AppContext>) -> Result<Response> {
|
||||||
|
format::json(load_item(&ctx, id).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn routes() -> Routes {
|
||||||
|
Routes::new()
|
||||||
|
.prefix("api/music_files/")
|
||||||
|
.add("/", get(list))
|
||||||
|
.add("/", post(add))
|
||||||
|
.add("{id}", get(get_one))
|
||||||
|
.add("{id}", delete(remove))
|
||||||
|
.add("{id}", put(update))
|
||||||
|
.add("{id}", patch(update))
|
||||||
|
}
|
130
src/controllers/music_library.rs
Normal file
130
src/controllers/music_library.rs
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
#![allow(clippy::missing_errors_doc)]
|
||||||
|
#![allow(clippy::unnecessary_struct_initialization)]
|
||||||
|
#![allow(clippy::unused_async)]
|
||||||
|
use axum::debug_handler;
|
||||||
|
use loco_rs::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::models::{
|
||||||
|
_entities::music_libraries::{ActiveModel, Entity, Model},
|
||||||
|
users,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::workers::scan_library_worker::Worker as ScanLibraryWorker;
|
||||||
|
use crate::workers::scan_library_worker::WorkerArgs as ScanLibraryWorkerArgs;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Params {
|
||||||
|
pub path: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Params {
|
||||||
|
fn update(&self, item: &mut ActiveModel) {
|
||||||
|
item.path = Set(self.path.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_item(ctx: &AppContext, id: i32) -> Result<Model> {
|
||||||
|
let item = Entity::find_by_id(id).one(&ctx.db).await?;
|
||||||
|
item.ok_or_else(|| Error::NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn list(State(ctx): State<AppContext>) -> Result<Response> {
|
||||||
|
format::json(Entity::find().all(&ctx.db).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn add(
|
||||||
|
auth: auth::JWT,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Json(params): Json<Params>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let user = users::Model::find_by_pid(&ctx.db, &auth.claims.pid).await?;
|
||||||
|
|
||||||
|
let mut item = ActiveModel {
|
||||||
|
user_id: Set(user.id),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
params.update(&mut item);
|
||||||
|
let item = item.insert(&ctx.db).await?;
|
||||||
|
|
||||||
|
ScanLibraryWorker::perform_later(
|
||||||
|
&ctx,
|
||||||
|
ScanLibraryWorkerArgs {
|
||||||
|
library_id: item.id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
format::json(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn update(
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Json(params): Json<Params>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let item = load_item(&ctx, id).await?;
|
||||||
|
let mut item = item.into_active_model();
|
||||||
|
params.update(&mut item);
|
||||||
|
let item = item.update(&ctx.db).await?;
|
||||||
|
format::json(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn remove(Path(id): Path<i32>, State(ctx): State<AppContext>) -> Result<Response> {
|
||||||
|
load_item(&ctx, id).await?.delete(&ctx.db).await?;
|
||||||
|
format::empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn get_one(Path(id): Path<i32>, State(ctx): State<AppContext>) -> Result<Response> {
|
||||||
|
format::json(load_item(&ctx, id).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn scan_library(
|
||||||
|
auth: auth::JWT,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let user = users::Model::find_by_pid(&ctx.db, &auth.claims.pid).await?;
|
||||||
|
let library = load_item(&ctx, id).await?;
|
||||||
|
|
||||||
|
if library.user_id != user.id {
|
||||||
|
return Err(Error::Unauthorized(
|
||||||
|
"You are not the owner of this library".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
ScanLibraryWorker::perform_later(
|
||||||
|
&ctx,
|
||||||
|
ScanLibraryWorkerArgs {
|
||||||
|
library_id: library.id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let response = json!(
|
||||||
|
{
|
||||||
|
"message": "Scan library started",
|
||||||
|
"library_id": id,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
format::json(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn routes() -> Routes {
|
||||||
|
Routes::new()
|
||||||
|
.prefix("api/music_libraries/")
|
||||||
|
.add("/", get(list))
|
||||||
|
.add("/", post(add))
|
||||||
|
.add("{id}", get(get_one))
|
||||||
|
.add("{id}", delete(remove))
|
||||||
|
.add("{id}", put(update))
|
||||||
|
.add("{id}", patch(update))
|
||||||
|
.add("{id}/scan", post(scan_library))
|
||||||
|
}
|
155
src/controllers/musicbrainz.rs
Normal file
155
src/controllers/musicbrainz.rs
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
#![allow(clippy::missing_errors_doc)]
|
||||||
|
#![allow(clippy::unnecessary_struct_initialization)]
|
||||||
|
#![allow(clippy::unused_async)]
|
||||||
|
use axum::extract::Query;
|
||||||
|
use loco_rs::prelude::*;
|
||||||
|
use lofty::{
|
||||||
|
config::WriteOptions,
|
||||||
|
file::{AudioFile, TaggedFileExt},
|
||||||
|
read_from_path,
|
||||||
|
tag::Accessor,
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use tracing::error;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
models::{_entities::music_files as music_files_entity, music_files},
|
||||||
|
services::{
|
||||||
|
musicbrainz::{self, get_release_with_tracks, search_album},
|
||||||
|
suggestion::match_album_metadata,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct QueryParams {
|
||||||
|
pub file_id: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct SuggestAlbumQueryParams {
|
||||||
|
library_id: i32,
|
||||||
|
album: String,
|
||||||
|
// artist: Option<String>,
|
||||||
|
// strict: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct FixParams {
|
||||||
|
pub file_id: i32,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub artist: Option<String>,
|
||||||
|
pub album: Option<String>,
|
||||||
|
pub track: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn suggest_album(
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Query(params): Query<SuggestAlbumQueryParams>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let music_files = music_files::Entity::find()
|
||||||
|
.filter(music_files_entity::Column::MusicLibraryId.eq(params.library_id))
|
||||||
|
.filter(music_files_entity::Column::Album.eq(¶ms.album))
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let first_artist = music_files
|
||||||
|
.iter()
|
||||||
|
.next()
|
||||||
|
.map(|f| f.artist.clone())
|
||||||
|
.ok_or_else(|| Error::NotFound)?;
|
||||||
|
|
||||||
|
if let Some(artist) = first_artist {
|
||||||
|
let releases = search_album(&artist, ¶ms.album).await?;
|
||||||
|
let best = releases.first();
|
||||||
|
if let Some(release) = best {
|
||||||
|
let full = get_release_with_tracks(&release.id).await?;
|
||||||
|
let media = full.media.unwrap_or_default();
|
||||||
|
let tracks = media
|
||||||
|
.iter()
|
||||||
|
.flat_map(|m| m.tracks.clone().unwrap_or_default())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let suggestions = match_album_metadata(&music_files, &tracks, &release.title);
|
||||||
|
|
||||||
|
return format::json(suggestions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
format::empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn suggest(
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Query(params): Query<QueryParams>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let file = music_files::Entity::find_by_id(params.file_id)
|
||||||
|
.one(&ctx.db)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| Error::NotFound)?;
|
||||||
|
|
||||||
|
let artist = file.artist.clone().unwrap_or_default();
|
||||||
|
let album = file.album.clone().unwrap_or_default();
|
||||||
|
|
||||||
|
let releases = musicbrainz::search_album(&artist, &album).await?;
|
||||||
|
|
||||||
|
if let Some(first) = releases.first() {
|
||||||
|
let detailed = get_release_with_tracks(&first.id).await?;
|
||||||
|
let media = detailed.media.ok_or_else(|| Error::InternalServerError)?;
|
||||||
|
|
||||||
|
if let Some(media) = media.first() {
|
||||||
|
if let Some(tracks) = &media.tracks {
|
||||||
|
return format::json(tracks);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
format::empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn apply_fix(
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Json(params): Json<FixParams>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let file = music_files::Entity::find_by_id(params.file_id)
|
||||||
|
.one(&ctx.db)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| Error::NotFound)?;
|
||||||
|
|
||||||
|
let path = std::path::Path::new(&file.path);
|
||||||
|
let mut tagged = read_from_path(path).map_err(|e| {
|
||||||
|
error!("Failed to read file from path: {}", e.to_string(),);
|
||||||
|
Error::InternalServerError
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if let Some(tag) = tagged.primary_tag_mut() {
|
||||||
|
if let Some(title) = ¶ms.title {
|
||||||
|
tag.set_title(title.clone());
|
||||||
|
}
|
||||||
|
if let Some(artist) = ¶ms.artist {
|
||||||
|
tag.set_artist(artist.clone());
|
||||||
|
}
|
||||||
|
if let Some(album) = ¶ms.album {
|
||||||
|
tag.set_album(album.clone());
|
||||||
|
}
|
||||||
|
if let Some(track) = ¶ms.track {
|
||||||
|
tag.set_track(track.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tagged
|
||||||
|
.save_to_path(path, WriteOptions::default())
|
||||||
|
.map_err(|e| {
|
||||||
|
error!("Failed to save file to path: {}", e.to_string(),);
|
||||||
|
Error::InternalServerError
|
||||||
|
})?;
|
||||||
|
|
||||||
|
format::empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn routes() -> Routes {
|
||||||
|
Routes::new()
|
||||||
|
.prefix("api/musicbrainz/")
|
||||||
|
.add("/suggest", post(suggest))
|
||||||
|
.add("/suggest_album", post(suggest_album))
|
||||||
|
.add("/apply", post(apply_fix))
|
||||||
|
}
|
1
src/data/mod.rs
Normal file
1
src/data/mod.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
17
src/fixtures/users.yaml
Normal file
17
src/fixtures/users.yaml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
- id: 1
|
||||||
|
pid: 11111111-1111-1111-1111-111111111111
|
||||||
|
email: user1@example.com
|
||||||
|
password: "$argon2id$v=19$m=19456,t=2,p=1$ETQBx4rTgNAZhSaeYZKOZg$eYTdH26CRT6nUJtacLDEboP0li6xUwUF/q5nSlQ8uuc"
|
||||||
|
api_key: lo-95ec80d7-cb60-4b70-9b4b-9ef74cb88758
|
||||||
|
name: user1
|
||||||
|
created_at: "2023-11-12T12:34:56.789Z"
|
||||||
|
updated_at: "2023-11-12T12:34:56.789Z"
|
||||||
|
- id: 2
|
||||||
|
pid: 22222222-2222-2222-2222-222222222222
|
||||||
|
email: user2@example.com
|
||||||
|
password: "$argon2id$v=19$m=19456,t=2,p=1$ETQBx4rTgNAZhSaeYZKOZg$eYTdH26CRT6nUJtacLDEboP0li6xUwUF/q5nSlQ8uuc"
|
||||||
|
api_key: lo-153561ca-fa84-4e1b-813a-c62526d0a77e
|
||||||
|
name: user2
|
||||||
|
created_at: "2023-11-12T12:34:56.789Z"
|
||||||
|
updated_at: "2023-11-12T12:34:56.789Z"
|
1
src/initializers/mod.rs
Normal file
1
src/initializers/mod.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
10
src/lib.rs
Normal file
10
src/lib.rs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
pub mod app;
|
||||||
|
pub mod controllers;
|
||||||
|
pub mod data;
|
||||||
|
pub mod initializers;
|
||||||
|
pub mod mailers;
|
||||||
|
pub mod models;
|
||||||
|
pub mod services;
|
||||||
|
pub mod tasks;
|
||||||
|
pub mod views;
|
||||||
|
pub mod workers;
|
92
src/mailers/auth.rs
Normal file
92
src/mailers/auth.rs
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
// auth mailer
|
||||||
|
#![allow(non_upper_case_globals)]
|
||||||
|
|
||||||
|
use loco_rs::prelude::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::models::users;
|
||||||
|
|
||||||
|
static welcome: Dir<'_> = include_dir!("src/mailers/auth/welcome");
|
||||||
|
static forgot: Dir<'_> = include_dir!("src/mailers/auth/forgot");
|
||||||
|
static magic_link: Dir<'_> = include_dir!("src/mailers/auth/magic_link");
|
||||||
|
// #[derive(Mailer)] // -- disabled for faster build speed. it works. but lets
|
||||||
|
// move on for now.
|
||||||
|
|
||||||
|
#[allow(clippy::module_name_repetitions)]
|
||||||
|
pub struct AuthMailer {}
|
||||||
|
impl Mailer for AuthMailer {}
|
||||||
|
impl AuthMailer {
|
||||||
|
/// Sending welcome email the the given user
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// When email sending is failed
|
||||||
|
pub async fn send_welcome(ctx: &AppContext, user: &users::Model) -> Result<()> {
|
||||||
|
Self::mail_template(
|
||||||
|
ctx,
|
||||||
|
&welcome,
|
||||||
|
mailer::Args {
|
||||||
|
to: user.email.to_string(),
|
||||||
|
locals: json!({
|
||||||
|
"name": user.name,
|
||||||
|
"verifyToken": user.email_verification_token,
|
||||||
|
"domain": ctx.config.server.full_url()
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sending forgot password email
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// When email sending is failed
|
||||||
|
pub async fn forgot_password(ctx: &AppContext, user: &users::Model) -> Result<()> {
|
||||||
|
Self::mail_template(
|
||||||
|
ctx,
|
||||||
|
&forgot,
|
||||||
|
mailer::Args {
|
||||||
|
to: user.email.to_string(),
|
||||||
|
locals: json!({
|
||||||
|
"name": user.name,
|
||||||
|
"resetToken": user.reset_token,
|
||||||
|
"domain": ctx.config.server.full_url()
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends a magic link authentication email to the user.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// When email sending is failed
|
||||||
|
pub async fn send_magic_link(ctx: &AppContext, user: &users::Model) -> Result<()> {
|
||||||
|
Self::mail_template(
|
||||||
|
ctx,
|
||||||
|
&magic_link,
|
||||||
|
mailer::Args {
|
||||||
|
to: user.email.to_string(),
|
||||||
|
locals: json!({
|
||||||
|
"name": user.name,
|
||||||
|
"token": user.magic_link_token.clone().ok_or_else(|| Error::string(
|
||||||
|
"the user model not contains magic link token",
|
||||||
|
))?,
|
||||||
|
"host": ctx.config.server.full_url()
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
11
src/mailers/auth/forgot/html.t
Normal file
11
src/mailers/auth/forgot/html.t
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
;<html>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
Hey {{name}},
|
||||||
|
Forgot your password? No worries! You can reset it by clicking the link below:
|
||||||
|
<a href="http://{{domain}}/reset#{{resetToken}}">Reset Your Password</a>
|
||||||
|
If you didn't request a password reset, please ignore this email.
|
||||||
|
Best regards,<br>The Loco Team</br>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
1
src/mailers/auth/forgot/subject.t
Normal file
1
src/mailers/auth/forgot/subject.t
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Your reset password link
|
3
src/mailers/auth/forgot/text.t
Normal file
3
src/mailers/auth/forgot/text.t
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
Reset your password with this link:
|
||||||
|
|
||||||
|
http://localhost/reset#{{resetToken}}
|
8
src/mailers/auth/magic_link/html.t
Normal file
8
src/mailers/auth/magic_link/html.t
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
;<html>
|
||||||
|
<body>
|
||||||
|
<p>Magic link example:</p>
|
||||||
|
<a href="{{host}}/api/auth/magic-link/{{token}}">
|
||||||
|
Verify Your Account
|
||||||
|
</a>
|
||||||
|
</body>
|
||||||
|
</html>
|
1
src/mailers/auth/magic_link/subject.t
Normal file
1
src/mailers/auth/magic_link/subject.t
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Magic link example
|
2
src/mailers/auth/magic_link/text.t
Normal file
2
src/mailers/auth/magic_link/text.t
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
Magic link with this link:
|
||||||
|
{{host}}/api/auth/magic-link/{{token}}
|
13
src/mailers/auth/welcome/html.t
Normal file
13
src/mailers/auth/welcome/html.t
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
;<html>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
Dear {{name}},
|
||||||
|
Welcome to Loco! You can now log in to your account.
|
||||||
|
Before you get started, please verify your account by clicking the link below:
|
||||||
|
<a href="{{domain}}/api/auth/verify/{{verifyToken}}">
|
||||||
|
Verify Your Account
|
||||||
|
</a>
|
||||||
|
<p>Best regards,<br>The Loco Team</p>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
1
src/mailers/auth/welcome/subject.t
Normal file
1
src/mailers/auth/welcome/subject.t
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Welcome {{name}}
|
4
src/mailers/auth/welcome/text.t
Normal file
4
src/mailers/auth/welcome/text.t
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
Welcome {{name}}, you can now log in.
|
||||||
|
Verify your account with the link below:
|
||||||
|
|
||||||
|
{{domain}}/api/auth/verify/{{verifyToken}}
|
1
src/mailers/mod.rs
Normal file
1
src/mailers/mod.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pub mod auth;
|
7
src/models/_entities/mod.rs
Normal file
7
src/models/_entities/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.8
|
||||||
|
|
||||||
|
pub mod prelude;
|
||||||
|
|
||||||
|
pub mod music_files;
|
||||||
|
pub mod music_libraries;
|
||||||
|
pub mod users;
|
37
src/models/_entities/music_files.rs
Normal file
37
src/models/_entities/music_files.rs
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.8
|
||||||
|
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "music_files")]
|
||||||
|
pub struct Model {
|
||||||
|
pub created_at: DateTimeWithTimeZone,
|
||||||
|
pub updated_at: DateTimeWithTimeZone,
|
||||||
|
#[sea_orm(primary_key)]
|
||||||
|
pub id: i32,
|
||||||
|
pub path: String,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub artist: Option<String>,
|
||||||
|
pub album: Option<String>,
|
||||||
|
pub metadata: Option<Json>,
|
||||||
|
pub music_library_id: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::music_libraries::Entity",
|
||||||
|
from = "Column::MusicLibraryId",
|
||||||
|
to = "super::music_libraries::Column::Id",
|
||||||
|
on_update = "Cascade",
|
||||||
|
on_delete = "Cascade"
|
||||||
|
)]
|
||||||
|
MusicLibraries,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::music_libraries::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::MusicLibraries.def()
|
||||||
|
}
|
||||||
|
}
|
41
src/models/_entities/music_libraries.rs
Normal file
41
src/models/_entities/music_libraries.rs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.8
|
||||||
|
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "music_libraries")]
|
||||||
|
pub struct Model {
|
||||||
|
pub created_at: DateTimeWithTimeZone,
|
||||||
|
pub updated_at: DateTimeWithTimeZone,
|
||||||
|
#[sea_orm(primary_key)]
|
||||||
|
pub id: i32,
|
||||||
|
pub path: Option<String>,
|
||||||
|
pub user_id: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(has_many = "super::music_files::Entity")]
|
||||||
|
MusicFiles,
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::users::Entity",
|
||||||
|
from = "Column::UserId",
|
||||||
|
to = "super::users::Column::Id",
|
||||||
|
on_update = "Cascade",
|
||||||
|
on_delete = "Cascade"
|
||||||
|
)]
|
||||||
|
Users,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::music_files::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::MusicFiles.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::users::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::Users.def()
|
||||||
|
}
|
||||||
|
}
|
5
src/models/_entities/prelude.rs
Normal file
5
src/models/_entities/prelude.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.8
|
||||||
|
|
||||||
|
pub use super::music_files::Entity as MusicFiles;
|
||||||
|
pub use super::music_libraries::Entity as MusicLibraries;
|
||||||
|
pub use super::users::Entity as Users;
|
39
src/models/_entities/users.rs
Normal file
39
src/models/_entities/users.rs
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.8
|
||||||
|
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "users")]
|
||||||
|
pub struct Model {
|
||||||
|
pub created_at: DateTimeWithTimeZone,
|
||||||
|
pub updated_at: DateTimeWithTimeZone,
|
||||||
|
#[sea_orm(primary_key)]
|
||||||
|
pub id: i32,
|
||||||
|
pub pid: Uuid,
|
||||||
|
#[sea_orm(unique)]
|
||||||
|
pub email: String,
|
||||||
|
pub password: String,
|
||||||
|
#[sea_orm(unique)]
|
||||||
|
pub api_key: String,
|
||||||
|
pub name: String,
|
||||||
|
pub reset_token: Option<String>,
|
||||||
|
pub reset_sent_at: Option<DateTimeWithTimeZone>,
|
||||||
|
pub email_verification_token: Option<String>,
|
||||||
|
pub email_verification_sent_at: Option<DateTimeWithTimeZone>,
|
||||||
|
pub email_verified_at: Option<DateTimeWithTimeZone>,
|
||||||
|
pub magic_link_token: Option<String>,
|
||||||
|
pub magic_link_expiration: Option<DateTimeWithTimeZone>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(has_many = "super::music_libraries::Entity")]
|
||||||
|
MusicLibraries,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::music_libraries::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::MusicLibraries.def()
|
||||||
|
}
|
||||||
|
}
|
4
src/models/mod.rs
Normal file
4
src/models/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
pub mod _entities;
|
||||||
|
pub mod users;
|
||||||
|
pub mod music_libraries;
|
||||||
|
pub mod music_files;
|
28
src/models/music_files.rs
Normal file
28
src/models/music_files.rs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
pub use super::_entities::music_files::{ActiveModel, Model, Entity};
|
||||||
|
pub type MusicFiles = Entity;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl ActiveModelBehavior for ActiveModel {
|
||||||
|
async fn before_save<C>(self, _db: &C, insert: bool) -> std::result::Result<Self, DbErr>
|
||||||
|
where
|
||||||
|
C: ConnectionTrait,
|
||||||
|
{
|
||||||
|
if !insert && self.updated_at.is_unchanged() {
|
||||||
|
let mut this = self;
|
||||||
|
this.updated_at = sea_orm::ActiveValue::Set(chrono::Utc::now().into());
|
||||||
|
Ok(this)
|
||||||
|
} else {
|
||||||
|
Ok(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// implement your read-oriented logic here
|
||||||
|
impl Model {}
|
||||||
|
|
||||||
|
// implement your write-oriented logic here
|
||||||
|
impl ActiveModel {}
|
||||||
|
|
||||||
|
// implement your custom finders, selectors oriented logic here
|
||||||
|
impl Entity {}
|
28
src/models/music_libraries.rs
Normal file
28
src/models/music_libraries.rs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
pub use super::_entities::music_libraries::{ActiveModel, Model, Entity};
|
||||||
|
pub type MusicLibraries = Entity;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl ActiveModelBehavior for ActiveModel {
|
||||||
|
async fn before_save<C>(self, _db: &C, insert: bool) -> std::result::Result<Self, DbErr>
|
||||||
|
where
|
||||||
|
C: ConnectionTrait,
|
||||||
|
{
|
||||||
|
if !insert && self.updated_at.is_unchanged() {
|
||||||
|
let mut this = self;
|
||||||
|
this.updated_at = sea_orm::ActiveValue::Set(chrono::Utc::now().into());
|
||||||
|
Ok(this)
|
||||||
|
} else {
|
||||||
|
Ok(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// implement your read-oriented logic here
|
||||||
|
impl Model {}
|
||||||
|
|
||||||
|
// implement your write-oriented logic here
|
||||||
|
impl ActiveModel {}
|
||||||
|
|
||||||
|
// implement your custom finders, selectors oriented logic here
|
||||||
|
impl Entity {}
|
367
src/models/users.rs
Normal file
367
src/models/users.rs
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::{offset::Local, Duration};
|
||||||
|
use loco_rs::{auth::jwt, hash, prelude::*};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Map;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
pub use super::_entities::users::{self, ActiveModel, Entity, Model};
|
||||||
|
|
||||||
|
pub const MAGIC_LINK_LENGTH: i8 = 32;
|
||||||
|
pub const MAGIC_LINK_EXPIRATION_MIN: i8 = 5;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
pub struct LoginParams {
|
||||||
|
pub email: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
pub struct RegisterParams {
|
||||||
|
pub email: String,
|
||||||
|
pub password: String,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Validate, Deserialize)]
|
||||||
|
pub struct Validator {
|
||||||
|
#[validate(length(min = 2, message = "Name must be at least 2 characters long."))]
|
||||||
|
pub name: String,
|
||||||
|
#[validate(custom(function = "validation::is_valid_email"))]
|
||||||
|
pub email: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Validatable for ActiveModel {
|
||||||
|
fn validator(&self) -> Box<dyn Validate> {
|
||||||
|
Box::new(Validator {
|
||||||
|
name: self.name.as_ref().to_owned(),
|
||||||
|
email: self.email.as_ref().to_owned(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl ActiveModelBehavior for super::_entities::users::ActiveModel {
|
||||||
|
async fn before_save<C>(self, _db: &C, insert: bool) -> Result<Self, DbErr>
|
||||||
|
where
|
||||||
|
C: ConnectionTrait,
|
||||||
|
{
|
||||||
|
self.validate()?;
|
||||||
|
if insert {
|
||||||
|
let mut this = self;
|
||||||
|
this.pid = ActiveValue::Set(Uuid::new_v4());
|
||||||
|
this.api_key = ActiveValue::Set(format!("lo-{}", Uuid::new_v4()));
|
||||||
|
Ok(this)
|
||||||
|
} else {
|
||||||
|
Ok(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Authenticable for Model {
|
||||||
|
async fn find_by_api_key(db: &DatabaseConnection, api_key: &str) -> ModelResult<Self> {
|
||||||
|
let user = users::Entity::find()
|
||||||
|
.filter(
|
||||||
|
model::query::condition()
|
||||||
|
.eq(users::Column::ApiKey, api_key)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.one(db)
|
||||||
|
.await?;
|
||||||
|
user.ok_or_else(|| ModelError::EntityNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_by_claims_key(db: &DatabaseConnection, claims_key: &str) -> ModelResult<Self> {
|
||||||
|
Self::find_by_pid(db, claims_key).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Model {
|
||||||
|
/// finds a user by the provided email
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// When could not find user by the given token or DB query error
|
||||||
|
pub async fn find_by_email(db: &DatabaseConnection, email: &str) -> ModelResult<Self> {
|
||||||
|
let user = users::Entity::find()
|
||||||
|
.filter(
|
||||||
|
model::query::condition()
|
||||||
|
.eq(users::Column::Email, email)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.one(db)
|
||||||
|
.await?;
|
||||||
|
user.ok_or_else(|| ModelError::EntityNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// finds a user by the provided verification token
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// When could not find user by the given token or DB query error
|
||||||
|
pub async fn find_by_verification_token(
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
token: &str,
|
||||||
|
) -> ModelResult<Self> {
|
||||||
|
let user = users::Entity::find()
|
||||||
|
.filter(
|
||||||
|
model::query::condition()
|
||||||
|
.eq(users::Column::EmailVerificationToken, token)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.one(db)
|
||||||
|
.await?;
|
||||||
|
user.ok_or_else(|| ModelError::EntityNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// finds a user by the magic token and verify and token expiration
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// When could not find user by the given token or DB query error ot token expired
|
||||||
|
pub async fn find_by_magic_token(db: &DatabaseConnection, token: &str) -> ModelResult<Self> {
|
||||||
|
let user = users::Entity::find()
|
||||||
|
.filter(
|
||||||
|
query::condition()
|
||||||
|
.eq(users::Column::MagicLinkToken, token)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.one(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let user = user.ok_or_else(|| ModelError::EntityNotFound)?;
|
||||||
|
if let Some(expired_at) = user.magic_link_expiration {
|
||||||
|
if expired_at >= Local::now() {
|
||||||
|
Ok(user)
|
||||||
|
} else {
|
||||||
|
tracing::debug!(
|
||||||
|
user_pid = user.pid.to_string(),
|
||||||
|
token_expiration = expired_at.to_string(),
|
||||||
|
"magic token expired for the user."
|
||||||
|
);
|
||||||
|
Err(ModelError::msg("magic token expired"))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tracing::error!(
|
||||||
|
user_pid = user.pid.to_string(),
|
||||||
|
"magic link expiration time not exists"
|
||||||
|
);
|
||||||
|
Err(ModelError::msg("expiration token not exists"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// finds a user by the provided reset token
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// When could not find user by the given token or DB query error
|
||||||
|
pub async fn find_by_reset_token(db: &DatabaseConnection, token: &str) -> ModelResult<Self> {
|
||||||
|
let user = users::Entity::find()
|
||||||
|
.filter(
|
||||||
|
model::query::condition()
|
||||||
|
.eq(users::Column::ResetToken, token)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.one(db)
|
||||||
|
.await?;
|
||||||
|
user.ok_or_else(|| ModelError::EntityNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// finds a user by the provided pid
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// When could not find user or DB query error
|
||||||
|
pub async fn find_by_pid(db: &DatabaseConnection, pid: &str) -> ModelResult<Self> {
|
||||||
|
let parse_uuid = Uuid::parse_str(pid).map_err(|e| ModelError::Any(e.into()))?;
|
||||||
|
let user = users::Entity::find()
|
||||||
|
.filter(
|
||||||
|
model::query::condition()
|
||||||
|
.eq(users::Column::Pid, parse_uuid)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.one(db)
|
||||||
|
.await?;
|
||||||
|
user.ok_or_else(|| ModelError::EntityNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// finds a user by the provided api key
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// When could not find user by the given token or DB query error
|
||||||
|
pub async fn find_by_api_key(db: &DatabaseConnection, api_key: &str) -> ModelResult<Self> {
|
||||||
|
let user = users::Entity::find()
|
||||||
|
.filter(
|
||||||
|
model::query::condition()
|
||||||
|
.eq(users::Column::ApiKey, api_key)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.one(db)
|
||||||
|
.await?;
|
||||||
|
user.ok_or_else(|| ModelError::EntityNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verifies whether the provided plain password matches the hashed password
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// when could not verify password
|
||||||
|
#[must_use]
|
||||||
|
pub fn verify_password(&self, password: &str) -> bool {
|
||||||
|
hash::verify_password(password, &self.password)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Asynchronously creates a user with a password and saves it to the
|
||||||
|
/// database.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// When could not save the user into the DB
|
||||||
|
pub async fn create_with_password(
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
params: &RegisterParams,
|
||||||
|
) -> ModelResult<Self> {
|
||||||
|
let txn = db.begin().await?;
|
||||||
|
|
||||||
|
if users::Entity::find()
|
||||||
|
.filter(
|
||||||
|
model::query::condition()
|
||||||
|
.eq(users::Column::Email, ¶ms.email)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.one(&txn)
|
||||||
|
.await?
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
return Err(ModelError::EntityAlreadyExists {});
|
||||||
|
}
|
||||||
|
|
||||||
|
let password_hash =
|
||||||
|
hash::hash_password(¶ms.password).map_err(|e| ModelError::Any(e.into()))?;
|
||||||
|
let user = users::ActiveModel {
|
||||||
|
email: ActiveValue::set(params.email.to_string()),
|
||||||
|
password: ActiveValue::set(password_hash),
|
||||||
|
name: ActiveValue::set(params.name.to_string()),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.insert(&txn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
txn.commit().await?;
|
||||||
|
|
||||||
|
Ok(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a JWT
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// when could not convert user claims to jwt token
|
||||||
|
pub fn generate_jwt(&self, secret: &str, expiration: u64) -> ModelResult<String> {
|
||||||
|
Ok(jwt::JWT::new(secret).generate_token(expiration, self.pid.to_string(), Map::new())?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModel {
|
||||||
|
/// Sets the email verification information for the user and
|
||||||
|
/// updates it in the database.
|
||||||
|
///
|
||||||
|
/// This method is used to record the timestamp when the email verification
|
||||||
|
/// was sent and generate a unique verification token for the user.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// when has DB query error
|
||||||
|
pub async fn set_email_verification_sent(
|
||||||
|
mut self,
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
) -> ModelResult<Model> {
|
||||||
|
self.email_verification_sent_at = ActiveValue::set(Some(Local::now().into()));
|
||||||
|
self.email_verification_token = ActiveValue::Set(Some(Uuid::new_v4().to_string()));
|
||||||
|
Ok(self.update(db).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the information for a reset password request,
|
||||||
|
/// generates a unique reset password token, and updates it in the
|
||||||
|
/// database.
|
||||||
|
///
|
||||||
|
/// This method records the timestamp when the reset password token is sent
|
||||||
|
/// and generates a unique token for the user.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// when has DB query error
|
||||||
|
pub async fn set_forgot_password_sent(mut self, db: &DatabaseConnection) -> ModelResult<Model> {
|
||||||
|
self.reset_sent_at = ActiveValue::set(Some(Local::now().into()));
|
||||||
|
self.reset_token = ActiveValue::Set(Some(Uuid::new_v4().to_string()));
|
||||||
|
Ok(self.update(db).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records the verification time when a user verifies their
|
||||||
|
/// email and updates it in the database.
|
||||||
|
///
|
||||||
|
/// This method sets the timestamp when the user successfully verifies their
|
||||||
|
/// email.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// when has DB query error
|
||||||
|
pub async fn verified(mut self, db: &DatabaseConnection) -> ModelResult<Model> {
|
||||||
|
self.email_verified_at = ActiveValue::set(Some(Local::now().into()));
|
||||||
|
Ok(self.update(db).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resets the current user password with a new password and
|
||||||
|
/// updates it in the database.
|
||||||
|
///
|
||||||
|
/// This method hashes the provided password and sets it as the new password
|
||||||
|
/// for the user.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// when has DB query error or could not hashed the given password
|
||||||
|
pub async fn reset_password(
|
||||||
|
mut self,
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
password: &str,
|
||||||
|
) -> ModelResult<Model> {
|
||||||
|
self.password =
|
||||||
|
ActiveValue::set(hash::hash_password(password).map_err(|e| ModelError::Any(e.into()))?);
|
||||||
|
self.reset_token = ActiveValue::Set(None);
|
||||||
|
self.reset_sent_at = ActiveValue::Set(None);
|
||||||
|
Ok(self.update(db).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a magic link token for passwordless authentication.
|
||||||
|
///
|
||||||
|
/// Generates a random token with a specified length and sets an expiration time
|
||||||
|
/// for the magic link. This method is used to initiate the magic link authentication flow.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// - Returns an error if database update fails
|
||||||
|
pub async fn create_magic_link(mut self, db: &DatabaseConnection) -> ModelResult<Model> {
|
||||||
|
let random_str = hash::random_string(MAGIC_LINK_LENGTH as usize);
|
||||||
|
let expired = Local::now() + Duration::minutes(MAGIC_LINK_EXPIRATION_MIN.into());
|
||||||
|
|
||||||
|
self.magic_link_token = ActiveValue::set(Some(random_str));
|
||||||
|
self.magic_link_expiration = ActiveValue::set(Some(expired.into()));
|
||||||
|
Ok(self.update(db).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verifies and invalidates the magic link after successful authentication.
|
||||||
|
///
|
||||||
|
/// Clears the magic link token and expiration time after the user has
|
||||||
|
/// successfully authenticated using the magic link.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// - Returns an error if database update fails
|
||||||
|
pub async fn clear_magic_link(mut self, db: &DatabaseConnection) -> ModelResult<Model> {
|
||||||
|
self.magic_link_token = ActiveValue::set(None);
|
||||||
|
self.magic_link_expiration = ActiveValue::set(None);
|
||||||
|
Ok(self.update(db).await?)
|
||||||
|
}
|
||||||
|
}
|
2
src/services/mod.rs
Normal file
2
src/services/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
pub mod musicbrainz;
|
||||||
|
pub mod suggestion;
|
32
src/services/musicbrainz.rs
Normal file
32
src/services/musicbrainz.rs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
use loco_rs::prelude::*;
|
||||||
|
use musicbrainz_rs::entity::release::Release;
|
||||||
|
use musicbrainz_rs::entity::release::ReleaseSearchQuery;
|
||||||
|
use musicbrainz_rs::Fetch;
|
||||||
|
use musicbrainz_rs::Search;
|
||||||
|
|
||||||
|
pub async fn search_album(artist: &str, album: &str) -> Result<Vec<Release>> {
|
||||||
|
let search_query = ReleaseSearchQuery::query_builder()
|
||||||
|
.artist(artist)
|
||||||
|
.and()
|
||||||
|
.release(album)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let results = match Release::search(search_query).execute().await {
|
||||||
|
Ok(results) => results,
|
||||||
|
Err(_) => return Err(loco_rs::Error::NotFound),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(results.entities)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_release_with_tracks(release_id: &str) -> Result<Release> {
|
||||||
|
let release = Release::fetch()
|
||||||
|
.id(release_id)
|
||||||
|
.with_recordings()
|
||||||
|
.with_artist_credits()
|
||||||
|
.execute()
|
||||||
|
.await
|
||||||
|
.map_err(|_| loco_rs::Error::NotFound)?;
|
||||||
|
|
||||||
|
Ok(release)
|
||||||
|
}
|
99
src/services/suggestion.rs
Normal file
99
src/services/suggestion.rs
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use musicbrainz_rs::entity::release::Track;
|
||||||
|
use serde::Serialize;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
use crate::models::music_files;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct SuggestedTrackFix {
|
||||||
|
pub file_id: i32,
|
||||||
|
pub path: String,
|
||||||
|
pub current: MetadataFields,
|
||||||
|
pub suggested: MetadataFields,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, PartialEq)]
|
||||||
|
pub struct MetadataFields {
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub artist: Option<String>,
|
||||||
|
pub album_artist: Option<String>,
|
||||||
|
pub album: Option<String>,
|
||||||
|
pub track: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn match_album_metadata(
|
||||||
|
local_files: &[music_files::Model],
|
||||||
|
mb_tracks: &[Track],
|
||||||
|
album_title: &str,
|
||||||
|
) -> Vec<SuggestedTrackFix> {
|
||||||
|
let mut used_file_ids = HashSet::new();
|
||||||
|
let mut suggestions = Vec::new();
|
||||||
|
|
||||||
|
for mb_track in mb_tracks {
|
||||||
|
let mb_title_norm = mb_track.title.to_lowercase();
|
||||||
|
|
||||||
|
if let Some((local, _)) = local_files
|
||||||
|
.iter()
|
||||||
|
.filter(|f| !used_file_ids.contains(&f.id))
|
||||||
|
.map(|f| {
|
||||||
|
let title_match_score = f
|
||||||
|
.title
|
||||||
|
.as_ref()
|
||||||
|
.map(|t| (t.to_lowercase() == mb_title_norm) as u8)
|
||||||
|
.unwrap_or(0);
|
||||||
|
(f, title_match_score)
|
||||||
|
})
|
||||||
|
.max_by_key(|(_, score)| *score)
|
||||||
|
{
|
||||||
|
info!(
|
||||||
|
"Matched MB track '{}' with local '{}'",
|
||||||
|
mb_track.title,
|
||||||
|
local.title.as_deref().unwrap_or("Unknown")
|
||||||
|
);
|
||||||
|
|
||||||
|
used_file_ids.insert(local.id);
|
||||||
|
|
||||||
|
let album_artist = mb_track
|
||||||
|
.artist_credit
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|a| a.first().map(|a| a.name.clone()));
|
||||||
|
|
||||||
|
let suggested = MetadataFields {
|
||||||
|
title: Some(mb_track.title.clone()),
|
||||||
|
artist: album_artist.clone(),
|
||||||
|
album_artist,
|
||||||
|
album: Some(album_title.to_string()),
|
||||||
|
track: Some(mb_track.position),
|
||||||
|
};
|
||||||
|
|
||||||
|
let current = MetadataFields {
|
||||||
|
title: local.title.clone(),
|
||||||
|
artist: local.artist.clone(),
|
||||||
|
album: local.album.clone(),
|
||||||
|
track: local
|
||||||
|
.metadata
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|m| m.get("track")?.as_u64())
|
||||||
|
.map(|n| n as u32),
|
||||||
|
album_artist: local
|
||||||
|
.metadata
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|m| m.get("album_artist")?.as_str())
|
||||||
|
.map(|s| s.to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
if current != suggested {
|
||||||
|
suggestions.push(SuggestedTrackFix {
|
||||||
|
file_id: local.id,
|
||||||
|
path: local.path.clone(),
|
||||||
|
current,
|
||||||
|
suggested,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suggestions
|
||||||
|
}
|
37
src/tasks/create_user.rs
Normal file
37
src/tasks/create_user.rs
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
use loco_rs::prelude::*;
|
||||||
|
|
||||||
|
use crate::models::users::{self};
|
||||||
|
|
||||||
|
pub struct CreateUser;
|
||||||
|
#[async_trait]
|
||||||
|
impl Task for CreateUser {
|
||||||
|
fn task(&self) -> TaskInfo {
|
||||||
|
TaskInfo {
|
||||||
|
name: "create_user".to_string(),
|
||||||
|
detail: "Task for creating a new user".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async fn run(&self, app_context: &AppContext, vars: &task::Vars) -> Result<()> {
|
||||||
|
let username = vars.cli_arg("username")?;
|
||||||
|
let email = vars.cli_arg("email")?;
|
||||||
|
let password = vars.cli_arg("password")?;
|
||||||
|
|
||||||
|
let user = users::Model::create_with_password(
|
||||||
|
&app_context.db,
|
||||||
|
&users::RegisterParams {
|
||||||
|
name: username.to_string(),
|
||||||
|
email: email.to_string(),
|
||||||
|
password: password.to_string(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
user_id = user.id,
|
||||||
|
user_email = &user.email,
|
||||||
|
"User created successfully",
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
3
src/tasks/mod.rs
Normal file
3
src/tasks/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
|
||||||
|
|
||||||
|
pub mod create_user;
|
41
src/views/auth.rs
Normal file
41
src/views/auth.rs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::models::_entities::users;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
pub struct LoginResponse {
|
||||||
|
pub token: String,
|
||||||
|
pub pid: String,
|
||||||
|
pub name: String,
|
||||||
|
pub is_verified: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LoginResponse {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(user: &users::Model, token: &String) -> Self {
|
||||||
|
Self {
|
||||||
|
token: token.to_string(),
|
||||||
|
pid: user.pid.to_string(),
|
||||||
|
name: user.name.clone(),
|
||||||
|
is_verified: user.email_verified_at.is_some(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
pub struct CurrentResponse {
|
||||||
|
pub pid: String,
|
||||||
|
pub name: String,
|
||||||
|
pub email: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CurrentResponse {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(user: &users::Model) -> Self {
|
||||||
|
Self {
|
||||||
|
pid: user.pid.to_string(),
|
||||||
|
name: user.name.clone(),
|
||||||
|
email: user.email.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
1
src/views/mod.rs
Normal file
1
src/views/mod.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pub mod auth;
|
23
src/workers/downloader.rs
Normal file
23
src/workers/downloader.rs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
use loco_rs::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub struct DownloadWorker {
|
||||||
|
pub ctx: AppContext,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug, Serialize)]
|
||||||
|
pub struct DownloadWorkerArgs {
|
||||||
|
pub user_guid: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl BackgroundWorker<DownloadWorkerArgs> for DownloadWorker {
|
||||||
|
fn build(ctx: &AppContext) -> Self {
|
||||||
|
Self { ctx: ctx.clone() }
|
||||||
|
}
|
||||||
|
async fn perform(&self, _args: DownloadWorkerArgs) -> Result<()> {
|
||||||
|
// TODO: Some actual work goes here...
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
3
src/workers/mod.rs
Normal file
3
src/workers/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod downloader;
|
||||||
|
|
||||||
|
pub mod scan_library_worker;
|
117
src/workers/scan_library_worker.rs
Normal file
117
src/workers/scan_library_worker.rs
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
use loco_rs::prelude::*;
|
||||||
|
use lofty::{file::TaggedFileExt, read_from_path, tag::Accessor};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
|
use crate::models::{_entities::music_files, music_libraries};
|
||||||
|
|
||||||
|
pub struct Worker {
|
||||||
|
pub ctx: AppContext,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug, Serialize)]
|
||||||
|
pub struct WorkerArgs {
|
||||||
|
pub library_id: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl BackgroundWorker<WorkerArgs> for Worker {
|
||||||
|
fn build(ctx: &AppContext) -> Self {
|
||||||
|
Self { ctx: ctx.clone() }
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn perform(&self, args: WorkerArgs) -> Result<()> {
|
||||||
|
println!("=================ScanLibraryWorker=======================");
|
||||||
|
|
||||||
|
let library = music_libraries::Entity::find_by_id(args.library_id)
|
||||||
|
.one(&self.ctx.db)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| Error::Message("Library not found".to_string()))?;
|
||||||
|
|
||||||
|
let library_path = match &library.path {
|
||||||
|
Some(path) => path,
|
||||||
|
None => return Err(Error::NotFound),
|
||||||
|
};
|
||||||
|
|
||||||
|
let entries_count = WalkDir::new(&library_path).into_iter().flatten().count();
|
||||||
|
|
||||||
|
let mut unchanged_files: i32 = 0;
|
||||||
|
let mut new_files: i32 = 0;
|
||||||
|
let mut updated_files: i32 = 0;
|
||||||
|
|
||||||
|
for entry in WalkDir::new(&library_path).into_iter().flatten() {
|
||||||
|
if !entry.path().is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let path_str = entry.path().display().to_string();
|
||||||
|
|
||||||
|
if let Ok(tagged) = read_from_path(entry.path()) {
|
||||||
|
let tag = tagged.primary_tag().ok_or_else(|| Error::NotFound)?;
|
||||||
|
|
||||||
|
let existing = music_files::Entity::find()
|
||||||
|
.filter(music_files::Column::MusicLibraryId.eq(library.id))
|
||||||
|
.filter(music_files::Column::Path.eq(&path_str))
|
||||||
|
.one(&self.ctx.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let metadata = json!({
|
||||||
|
"title": tag.title(),
|
||||||
|
"artist": tag.artist(),
|
||||||
|
"album": tag.album(),
|
||||||
|
"track": tag.track(),
|
||||||
|
"disc": tag.disk(),
|
||||||
|
"genre": tag.genre(),
|
||||||
|
"year": tag.year(),
|
||||||
|
"composer": tag.get_string(&lofty::tag::ItemKey::Composer),
|
||||||
|
"lyrics": tag.get_string(&lofty::tag::ItemKey::Lyrics),
|
||||||
|
"album_artist": tag.get_string(&lofty::tag::ItemKey::AlbumArtist),
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(existing_file) = existing {
|
||||||
|
let existing_metadata: Option<Value> = existing_file.metadata.clone();
|
||||||
|
|
||||||
|
if existing_metadata.as_ref() == Some(&metadata) {
|
||||||
|
println!("Unchanged metadata for file {}", &path_str);
|
||||||
|
unchanged_files += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut model = existing_file.into_active_model();
|
||||||
|
model.title = Set(tag.title().map(|t| t.to_string()));
|
||||||
|
model.artist = Set(tag.artist().map(|a| a.to_string()));
|
||||||
|
model.album = Set(tag.album().map(|a| a.to_string()));
|
||||||
|
model.metadata = Set(Some(metadata.clone()));
|
||||||
|
model.update(&self.ctx.db).await?;
|
||||||
|
|
||||||
|
println!("Updated file {}", &path_str);
|
||||||
|
updated_files += 1;
|
||||||
|
} else {
|
||||||
|
let _ = music_files::ActiveModel {
|
||||||
|
music_library_id: Set(library.id),
|
||||||
|
path: Set(path_str.clone()),
|
||||||
|
title: Set(tag.title().map(|t| t.to_string())),
|
||||||
|
artist: Set(tag.artist().map(|a| a.to_string())),
|
||||||
|
album: Set(tag.album().map(|a| a.to_string())),
|
||||||
|
metadata: Set(Some(metadata)),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.insert(&self.ctx.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
println!("Added file {}", &path_str);
|
||||||
|
new_files += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"Scan results: {} files added, {} updated, {} unchanged, total files: {}",
|
||||||
|
new_files, updated_files, unchanged_files, entries_count
|
||||||
|
);
|
||||||
|
println!("Finished scanning library");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
4
tests/mod.rs
Normal file
4
tests/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
mod models;
|
||||||
|
mod requests;
|
||||||
|
mod tasks;
|
||||||
|
mod workers;
|
4
tests/models/mod.rs
Normal file
4
tests/models/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
mod users;
|
||||||
|
|
||||||
|
mod music_libraries;
|
||||||
|
mod music_files;
|
31
tests/models/music_files.rs
Normal file
31
tests/models/music_files.rs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
use music_metadata_manager::app::App;
|
||||||
|
use loco_rs::testing::prelude::*;
|
||||||
|
use serial_test::serial;
|
||||||
|
|
||||||
|
macro_rules! configure_insta {
|
||||||
|
($($expr:expr),*) => {
|
||||||
|
let mut settings = insta::Settings::clone_current();
|
||||||
|
settings.set_prepend_module_to_snapshot(false);
|
||||||
|
let _guard = settings.bind_to_scope();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn test_model() {
|
||||||
|
configure_insta!();
|
||||||
|
|
||||||
|
let boot = boot_test::<App>().await.unwrap();
|
||||||
|
seed::<App>(&boot.app_context).await.unwrap();
|
||||||
|
|
||||||
|
// query your model, e.g.:
|
||||||
|
//
|
||||||
|
// let item = models::posts::Model::find_by_pid(
|
||||||
|
// &boot.app_context.db,
|
||||||
|
// "11111111-1111-1111-1111-111111111111",
|
||||||
|
// )
|
||||||
|
// .await;
|
||||||
|
|
||||||
|
// snapshot the result:
|
||||||
|
// assert_debug_snapshot!(item);
|
||||||
|
}
|
31
tests/models/music_libraries.rs
Normal file
31
tests/models/music_libraries.rs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
use music_metadata_manager::app::App;
|
||||||
|
use loco_rs::testing::prelude::*;
|
||||||
|
use serial_test::serial;
|
||||||
|
|
||||||
|
macro_rules! configure_insta {
|
||||||
|
($($expr:expr),*) => {
|
||||||
|
let mut settings = insta::Settings::clone_current();
|
||||||
|
settings.set_prepend_module_to_snapshot(false);
|
||||||
|
let _guard = settings.bind_to_scope();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn test_model() {
|
||||||
|
configure_insta!();
|
||||||
|
|
||||||
|
let boot = boot_test::<App>().await.unwrap();
|
||||||
|
seed::<App>(&boot.app_context).await.unwrap();
|
||||||
|
|
||||||
|
// query your model, e.g.:
|
||||||
|
//
|
||||||
|
// let item = models::posts::Model::find_by_pid(
|
||||||
|
// &boot.app_context.db,
|
||||||
|
// "11111111-1111-1111-1111-111111111111",
|
||||||
|
// )
|
||||||
|
// .await;
|
||||||
|
|
||||||
|
// snapshot the result:
|
||||||
|
// assert_debug_snapshot!(item);
|
||||||
|
}
|
23
tests/models/snapshots/can_create_with_password@users.snap
Normal file
23
tests/models/snapshots/can_create_with_password@users.snap
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
source: tests/models/users.rs
|
||||||
|
expression: res
|
||||||
|
---
|
||||||
|
Ok(
|
||||||
|
Model {
|
||||||
|
created_at: DATE,
|
||||||
|
updated_at: DATE,
|
||||||
|
id: ID
|
||||||
|
pid: PID,
|
||||||
|
email: "test@framework.com",
|
||||||
|
password: "PASSWORD",
|
||||||
|
api_key: "lo-PID",
|
||||||
|
name: "framework",
|
||||||
|
reset_token: None,
|
||||||
|
reset_sent_at: None,
|
||||||
|
email_verification_token: None,
|
||||||
|
email_verification_sent_at: None,
|
||||||
|
email_verified_at: None,
|
||||||
|
magic_link_token: None,
|
||||||
|
magic_link_expiration: None,
|
||||||
|
},
|
||||||
|
)
|
7
tests/models/snapshots/can_find_by_email@users-2.snap
Normal file
7
tests/models/snapshots/can_find_by_email@users-2.snap
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
source: tests/models/users.rs
|
||||||
|
expression: non_existing_user_results
|
||||||
|
---
|
||||||
|
Err(
|
||||||
|
EntityNotFound,
|
||||||
|
)
|
23
tests/models/snapshots/can_find_by_email@users.snap
Normal file
23
tests/models/snapshots/can_find_by_email@users.snap
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
source: tests/models/users.rs
|
||||||
|
expression: existing_user
|
||||||
|
---
|
||||||
|
Ok(
|
||||||
|
Model {
|
||||||
|
created_at: 2023-11-12T12:34:56.789+00:00,
|
||||||
|
updated_at: 2023-11-12T12:34:56.789+00:00,
|
||||||
|
id: 1,
|
||||||
|
pid: 11111111-1111-1111-1111-111111111111,
|
||||||
|
email: "user1@example.com",
|
||||||
|
password: "$argon2id$v=19$m=19456,t=2,p=1$ETQBx4rTgNAZhSaeYZKOZg$eYTdH26CRT6nUJtacLDEboP0li6xUwUF/q5nSlQ8uuc",
|
||||||
|
api_key: "lo-95ec80d7-cb60-4b70-9b4b-9ef74cb88758",
|
||||||
|
name: "user1",
|
||||||
|
reset_token: None,
|
||||||
|
reset_sent_at: None,
|
||||||
|
email_verification_token: None,
|
||||||
|
email_verification_sent_at: None,
|
||||||
|
email_verified_at: None,
|
||||||
|
magic_link_token: None,
|
||||||
|
magic_link_expiration: None,
|
||||||
|
},
|
||||||
|
)
|
7
tests/models/snapshots/can_find_by_pid@users-2.snap
Normal file
7
tests/models/snapshots/can_find_by_pid@users-2.snap
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
source: tests/models/users.rs
|
||||||
|
expression: non_existing_user_results
|
||||||
|
---
|
||||||
|
Err(
|
||||||
|
EntityNotFound,
|
||||||
|
)
|
23
tests/models/snapshots/can_find_by_pid@users.snap
Normal file
23
tests/models/snapshots/can_find_by_pid@users.snap
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
source: tests/models/users.rs
|
||||||
|
expression: existing_user
|
||||||
|
---
|
||||||
|
Ok(
|
||||||
|
Model {
|
||||||
|
created_at: 2023-11-12T12:34:56.789+00:00,
|
||||||
|
updated_at: 2023-11-12T12:34:56.789+00:00,
|
||||||
|
id: 1,
|
||||||
|
pid: 11111111-1111-1111-1111-111111111111,
|
||||||
|
email: "user1@example.com",
|
||||||
|
password: "$argon2id$v=19$m=19456,t=2,p=1$ETQBx4rTgNAZhSaeYZKOZg$eYTdH26CRT6nUJtacLDEboP0li6xUwUF/q5nSlQ8uuc",
|
||||||
|
api_key: "lo-95ec80d7-cb60-4b70-9b4b-9ef74cb88758",
|
||||||
|
name: "user1",
|
||||||
|
reset_token: None,
|
||||||
|
reset_sent_at: None,
|
||||||
|
email_verification_token: None,
|
||||||
|
email_verification_sent_at: None,
|
||||||
|
email_verified_at: None,
|
||||||
|
magic_link_token: None,
|
||||||
|
magic_link_expiration: None,
|
||||||
|
},
|
||||||
|
)
|
9
tests/models/snapshots/can_validate_model@users.snap
Normal file
9
tests/models/snapshots/can_validate_model@users.snap
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
---
|
||||||
|
source: tests/models/users.rs
|
||||||
|
expression: res
|
||||||
|
---
|
||||||
|
Err(
|
||||||
|
Custom(
|
||||||
|
"{\"email\":[{\"code\":\"invalid email\",\"message\":null}],\"name\":[{\"code\":\"length\",\"message\":\"Name must be at least 2 characters long.\"}]}",
|
||||||
|
),
|
||||||
|
)
|
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
source: tests/models/users.rs
|
||||||
|
expression: new_user
|
||||||
|
---
|
||||||
|
Err(
|
||||||
|
EntityAlreadyExists,
|
||||||
|
)
|
360
tests/models/users.rs
Normal file
360
tests/models/users.rs
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
use chrono::{offset::Local, Duration};
|
||||||
|
use insta::assert_debug_snapshot;
|
||||||
|
use loco_rs::testing::prelude::*;
|
||||||
|
use music_metadata_manager::{
|
||||||
|
app::App,
|
||||||
|
models::users::{self, Model, RegisterParams},
|
||||||
|
};
|
||||||
|
use sea_orm::{ActiveModelTrait, ActiveValue, IntoActiveModel};
|
||||||
|
use serial_test::serial;
|
||||||
|
|
||||||
|
macro_rules! configure_insta {
|
||||||
|
($($expr:expr),*) => {
|
||||||
|
let mut settings = insta::Settings::clone_current();
|
||||||
|
settings.set_prepend_module_to_snapshot(false);
|
||||||
|
settings.set_snapshot_suffix("users");
|
||||||
|
let _guard = settings.bind_to_scope();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn test_can_validate_model() {
|
||||||
|
configure_insta!();
|
||||||
|
|
||||||
|
let boot = boot_test::<App>()
|
||||||
|
.await
|
||||||
|
.expect("Failed to boot test application");
|
||||||
|
|
||||||
|
let invalid_user = users::ActiveModel {
|
||||||
|
name: ActiveValue::set("1".to_string()),
|
||||||
|
email: ActiveValue::set("invalid-email".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let res = invalid_user.insert(&boot.app_context.db).await;
|
||||||
|
|
||||||
|
assert_debug_snapshot!(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn can_create_with_password() {
|
||||||
|
configure_insta!();
|
||||||
|
|
||||||
|
let boot = boot_test::<App>()
|
||||||
|
.await
|
||||||
|
.expect("Failed to boot test application");
|
||||||
|
|
||||||
|
let params = RegisterParams {
|
||||||
|
email: "test@framework.com".to_string(),
|
||||||
|
password: "1234".to_string(),
|
||||||
|
name: "framework".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let res = Model::create_with_password(&boot.app_context.db, ¶ms).await;
|
||||||
|
|
||||||
|
insta::with_settings!({
|
||||||
|
filters => cleanup_user_model()
|
||||||
|
}, {
|
||||||
|
assert_debug_snapshot!(res);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn handle_create_with_password_with_duplicate() {
|
||||||
|
configure_insta!();
|
||||||
|
|
||||||
|
let boot = boot_test::<App>()
|
||||||
|
.await
|
||||||
|
.expect("Failed to boot test application");
|
||||||
|
seed::<App>(&boot.app_context)
|
||||||
|
.await
|
||||||
|
.expect("Failed to seed database");
|
||||||
|
|
||||||
|
let new_user = Model::create_with_password(
|
||||||
|
&boot.app_context.db,
|
||||||
|
&RegisterParams {
|
||||||
|
email: "user1@example.com".to_string(),
|
||||||
|
password: "1234".to_string(),
|
||||||
|
name: "framework".to_string(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_debug_snapshot!(new_user);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn can_find_by_email() {
|
||||||
|
configure_insta!();
|
||||||
|
|
||||||
|
let boot = boot_test::<App>()
|
||||||
|
.await
|
||||||
|
.expect("Failed to boot test application");
|
||||||
|
seed::<App>(&boot.app_context)
|
||||||
|
.await
|
||||||
|
.expect("Failed to seed database");
|
||||||
|
|
||||||
|
let existing_user = Model::find_by_email(&boot.app_context.db, "user1@example.com").await;
|
||||||
|
let non_existing_user_results =
|
||||||
|
Model::find_by_email(&boot.app_context.db, "un@existing-email.com").await;
|
||||||
|
|
||||||
|
assert_debug_snapshot!(existing_user);
|
||||||
|
assert_debug_snapshot!(non_existing_user_results);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn can_find_by_pid() {
|
||||||
|
configure_insta!();
|
||||||
|
|
||||||
|
let boot = boot_test::<App>()
|
||||||
|
.await
|
||||||
|
.expect("Failed to boot test application");
|
||||||
|
seed::<App>(&boot.app_context)
|
||||||
|
.await
|
||||||
|
.expect("Failed to seed database");
|
||||||
|
|
||||||
|
let existing_user =
|
||||||
|
Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111").await;
|
||||||
|
let non_existing_user_results =
|
||||||
|
Model::find_by_pid(&boot.app_context.db, "23232323-2323-2323-2323-232323232323").await;
|
||||||
|
|
||||||
|
assert_debug_snapshot!(existing_user);
|
||||||
|
assert_debug_snapshot!(non_existing_user_results);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn can_verification_token() {
|
||||||
|
configure_insta!();
|
||||||
|
|
||||||
|
let boot = boot_test::<App>()
|
||||||
|
.await
|
||||||
|
.expect("Failed to boot test application");
|
||||||
|
seed::<App>(&boot.app_context)
|
||||||
|
.await
|
||||||
|
.expect("Failed to seed database");
|
||||||
|
|
||||||
|
let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111")
|
||||||
|
.await
|
||||||
|
.expect("Failed to find user by PID");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
user.email_verification_sent_at.is_none(),
|
||||||
|
"Expected no email verification sent timestamp"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
user.email_verification_token.is_none(),
|
||||||
|
"Expected no email verification token"
|
||||||
|
);
|
||||||
|
|
||||||
|
let result = user
|
||||||
|
.into_active_model()
|
||||||
|
.set_email_verification_sent(&boot.app_context.db)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(result.is_ok(), "Failed to set email verification sent");
|
||||||
|
|
||||||
|
let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111")
|
||||||
|
.await
|
||||||
|
.expect("Failed to find user by PID after setting verification sent");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
user.email_verification_sent_at.is_some(),
|
||||||
|
"Expected email verification sent timestamp to be present"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
user.email_verification_token.is_some(),
|
||||||
|
"Expected email verification token to be present"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn can_set_forgot_password_sent() {
|
||||||
|
configure_insta!();
|
||||||
|
|
||||||
|
let boot = boot_test::<App>()
|
||||||
|
.await
|
||||||
|
.expect("Failed to boot test application");
|
||||||
|
seed::<App>(&boot.app_context)
|
||||||
|
.await
|
||||||
|
.expect("Failed to seed database");
|
||||||
|
|
||||||
|
let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111")
|
||||||
|
.await
|
||||||
|
.expect("Failed to find user by PID");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
user.reset_sent_at.is_none(),
|
||||||
|
"Expected no reset sent timestamp"
|
||||||
|
);
|
||||||
|
assert!(user.reset_token.is_none(), "Expected no reset token");
|
||||||
|
|
||||||
|
let result = user
|
||||||
|
.into_active_model()
|
||||||
|
.set_forgot_password_sent(&boot.app_context.db)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(result.is_ok(), "Failed to set forgot password sent");
|
||||||
|
|
||||||
|
let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111")
|
||||||
|
.await
|
||||||
|
.expect("Failed to find user by PID after setting forgot password sent");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
user.reset_sent_at.is_some(),
|
||||||
|
"Expected reset sent timestamp to be present"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
user.reset_token.is_some(),
|
||||||
|
"Expected reset token to be present"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn can_verified() {
|
||||||
|
configure_insta!();
|
||||||
|
|
||||||
|
let boot = boot_test::<App>()
|
||||||
|
.await
|
||||||
|
.expect("Failed to boot test application");
|
||||||
|
seed::<App>(&boot.app_context)
|
||||||
|
.await
|
||||||
|
.expect("Failed to seed database");
|
||||||
|
|
||||||
|
let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111")
|
||||||
|
.await
|
||||||
|
.expect("Failed to find user by PID");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
user.email_verified_at.is_none(),
|
||||||
|
"Expected email to be unverified"
|
||||||
|
);
|
||||||
|
|
||||||
|
let result = user
|
||||||
|
.into_active_model()
|
||||||
|
.verified(&boot.app_context.db)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(result.is_ok(), "Failed to mark email as verified");
|
||||||
|
|
||||||
|
let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111")
|
||||||
|
.await
|
||||||
|
.expect("Failed to find user by PID after verification");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
user.email_verified_at.is_some(),
|
||||||
|
"Expected email to be verified"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn can_reset_password() {
|
||||||
|
configure_insta!();
|
||||||
|
|
||||||
|
let boot = boot_test::<App>()
|
||||||
|
.await
|
||||||
|
.expect("Failed to boot test application");
|
||||||
|
seed::<App>(&boot.app_context)
|
||||||
|
.await
|
||||||
|
.expect("Failed to seed database");
|
||||||
|
|
||||||
|
let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111")
|
||||||
|
.await
|
||||||
|
.expect("Failed to find user by PID");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
user.verify_password("12341234"),
|
||||||
|
"Password verification failed for original password"
|
||||||
|
);
|
||||||
|
|
||||||
|
let result = user
|
||||||
|
.clone()
|
||||||
|
.into_active_model()
|
||||||
|
.reset_password(&boot.app_context.db, "new-password")
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(result.is_ok(), "Failed to reset password");
|
||||||
|
|
||||||
|
let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111")
|
||||||
|
.await
|
||||||
|
.expect("Failed to find user by PID after password reset");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
user.verify_password("new-password"),
|
||||||
|
"Password verification failed for new password"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn magic_link() {
|
||||||
|
let boot = boot_test::<App>().await.unwrap();
|
||||||
|
seed::<App>(&boot.app_context).await.unwrap();
|
||||||
|
|
||||||
|
let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
user.magic_link_token.is_none(),
|
||||||
|
"Magic link token should be initially unset"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
user.magic_link_expiration.is_none(),
|
||||||
|
"Magic link expiration should be initially unset"
|
||||||
|
);
|
||||||
|
|
||||||
|
let create_result = user
|
||||||
|
.into_active_model()
|
||||||
|
.create_magic_link(&boot.app_context.db)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
create_result.is_ok(),
|
||||||
|
"Failed to create magic link: {:?}",
|
||||||
|
create_result.unwrap_err()
|
||||||
|
);
|
||||||
|
|
||||||
|
let updated_user =
|
||||||
|
Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111")
|
||||||
|
.await
|
||||||
|
.expect("Failed to refetch user after magic link creation");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
updated_user.magic_link_token.is_some(),
|
||||||
|
"Magic link token should be set after creation"
|
||||||
|
);
|
||||||
|
|
||||||
|
let magic_link_token = updated_user.magic_link_token.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
magic_link_token.len(),
|
||||||
|
users::MAGIC_LINK_LENGTH as usize,
|
||||||
|
"Magic link token length does not match expected length"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
updated_user.magic_link_expiration.is_some(),
|
||||||
|
"Magic link expiration should be set after creation"
|
||||||
|
);
|
||||||
|
|
||||||
|
let now = Local::now();
|
||||||
|
let should_expired_at = now + Duration::minutes(users::MAGIC_LINK_EXPIRATION_MIN.into());
|
||||||
|
let actual_expiration = updated_user.magic_link_expiration.unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
actual_expiration >= now,
|
||||||
|
"Magic link expiration should be in the future or now"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
actual_expiration <= should_expired_at,
|
||||||
|
"Magic link expiration exceeds expected maximum expiration time"
|
||||||
|
);
|
||||||
|
}
|
371
tests/requests/auth.rs
Normal file
371
tests/requests/auth.rs
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
use insta::{assert_debug_snapshot, with_settings};
|
||||||
|
use loco_rs::testing::prelude::*;
|
||||||
|
use music_metadata_manager::{app::App, models::users};
|
||||||
|
use rstest::rstest;
|
||||||
|
use serial_test::serial;
|
||||||
|
|
||||||
|
use super::prepare_data;
|
||||||
|
|
||||||
|
// TODO: see how to dedup / extract this to app-local test utils
|
||||||
|
// not to framework, because that would require a runtime dep on insta
|
||||||
|
macro_rules! configure_insta {
|
||||||
|
($($expr:expr),*) => {
|
||||||
|
let mut settings = insta::Settings::clone_current();
|
||||||
|
settings.set_prepend_module_to_snapshot(false);
|
||||||
|
settings.set_snapshot_suffix("auth_request");
|
||||||
|
let _guard = settings.bind_to_scope();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn can_register() {
|
||||||
|
configure_insta!();
|
||||||
|
|
||||||
|
request::<App, _, _>(|request, ctx| async move {
|
||||||
|
let email = "test@loco.com";
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"name": "loco",
|
||||||
|
"email": email,
|
||||||
|
"password": "12341234"
|
||||||
|
});
|
||||||
|
|
||||||
|
let response = request.post("/api/auth/register").json(&payload).await;
|
||||||
|
assert_eq!(
|
||||||
|
response.status_code(),
|
||||||
|
200,
|
||||||
|
"Register request should succeed"
|
||||||
|
);
|
||||||
|
let saved_user = users::Model::find_by_email(&ctx.db, email).await;
|
||||||
|
|
||||||
|
with_settings!({
|
||||||
|
filters => cleanup_user_model()
|
||||||
|
}, {
|
||||||
|
assert_debug_snapshot!(saved_user);
|
||||||
|
});
|
||||||
|
|
||||||
|
let deliveries = ctx.mailer.unwrap().deliveries();
|
||||||
|
assert_eq!(deliveries.count, 1, "Exactly one email should be sent");
|
||||||
|
|
||||||
|
// with_settings!({
|
||||||
|
// filters => cleanup_email()
|
||||||
|
// }, {
|
||||||
|
// assert_debug_snapshot!(ctx.mailer.unwrap().deliveries());
|
||||||
|
// });
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
#[case("login_with_valid_password", "12341234")]
|
||||||
|
#[case("login_with_invalid_password", "invalid-password")]
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn can_login_with_verify(#[case] test_name: &str, #[case] password: &str) {
|
||||||
|
configure_insta!();
|
||||||
|
|
||||||
|
request::<App, _, _>(|request, ctx| async move {
|
||||||
|
let email = "test@loco.com";
|
||||||
|
let register_payload = serde_json::json!({
|
||||||
|
"name": "loco",
|
||||||
|
"email": email,
|
||||||
|
"password": "12341234"
|
||||||
|
});
|
||||||
|
|
||||||
|
//Creating a new user
|
||||||
|
let register_response = request
|
||||||
|
.post("/api/auth/register")
|
||||||
|
.json(®ister_payload)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
register_response.status_code(),
|
||||||
|
200,
|
||||||
|
"Register request should succeed"
|
||||||
|
);
|
||||||
|
|
||||||
|
let user = users::Model::find_by_email(&ctx.db, email).await.unwrap();
|
||||||
|
let email_verification_token = user
|
||||||
|
.email_verification_token
|
||||||
|
.expect("Email verification token should be generated");
|
||||||
|
request
|
||||||
|
.get(&format!("/api/auth/verify/{email_verification_token}"))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
//verify user request
|
||||||
|
let response = request
|
||||||
|
.post("/api/auth/login")
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"email": email,
|
||||||
|
"password": password
|
||||||
|
}))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Make sure email_verified_at is set
|
||||||
|
let user = users::Model::find_by_email(&ctx.db, email)
|
||||||
|
.await
|
||||||
|
.expect("Failed to find user by email");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
user.email_verified_at.is_some(),
|
||||||
|
"Expected the email to be verified, but it was not. User: {:?}",
|
||||||
|
user
|
||||||
|
);
|
||||||
|
|
||||||
|
with_settings!({
|
||||||
|
filters => cleanup_user_model()
|
||||||
|
}, {
|
||||||
|
assert_debug_snapshot!(test_name, (response.status_code(), response.text()));
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn can_login_without_verify() {
|
||||||
|
configure_insta!();
|
||||||
|
|
||||||
|
request::<App, _, _>(|request, _ctx| async move {
|
||||||
|
let email = "test@loco.com";
|
||||||
|
let password = "12341234";
|
||||||
|
let register_payload = serde_json::json!({
|
||||||
|
"name": "loco",
|
||||||
|
"email": email,
|
||||||
|
"password": password
|
||||||
|
});
|
||||||
|
|
||||||
|
//Creating a new user
|
||||||
|
let register_response = request
|
||||||
|
.post("/api/auth/register")
|
||||||
|
.json(®ister_payload)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
register_response.status_code(),
|
||||||
|
200,
|
||||||
|
"Register request should succeed"
|
||||||
|
);
|
||||||
|
|
||||||
|
//verify user request
|
||||||
|
let login_response = request
|
||||||
|
.post("/api/auth/login")
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"email": email,
|
||||||
|
"password": password
|
||||||
|
}))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
login_response.status_code(),
|
||||||
|
200,
|
||||||
|
"Login request should succeed"
|
||||||
|
);
|
||||||
|
|
||||||
|
with_settings!({
|
||||||
|
filters => cleanup_user_model()
|
||||||
|
}, {
|
||||||
|
assert_debug_snapshot!(login_response.text());
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn can_reset_password() {
|
||||||
|
configure_insta!();
|
||||||
|
|
||||||
|
request::<App, _, _>(|request, ctx| async move {
|
||||||
|
let login_data = prepare_data::init_user_login(&request, &ctx).await;
|
||||||
|
|
||||||
|
let forgot_payload = serde_json::json!({
|
||||||
|
"email": login_data.user.email,
|
||||||
|
});
|
||||||
|
let forget_response = request.post("/api/auth/forgot").json(&forgot_payload).await;
|
||||||
|
assert_eq!(
|
||||||
|
forget_response.status_code(),
|
||||||
|
200,
|
||||||
|
"Forget request should succeed"
|
||||||
|
);
|
||||||
|
|
||||||
|
let user = users::Model::find_by_email(&ctx.db, &login_data.user.email)
|
||||||
|
.await
|
||||||
|
.expect("Failed to find user by email");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
user.reset_token.is_some(),
|
||||||
|
"Expected reset_token to be set, but it was None. User: {user:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
user.reset_sent_at.is_some(),
|
||||||
|
"Expected reset_sent_at to be set, but it was None. User: {user:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let new_password = "new-password";
|
||||||
|
let reset_payload = serde_json::json!({
|
||||||
|
"token": user.reset_token,
|
||||||
|
"password": new_password,
|
||||||
|
});
|
||||||
|
|
||||||
|
let reset_response = request.post("/api/auth/reset").json(&reset_payload).await;
|
||||||
|
assert_eq!(
|
||||||
|
reset_response.status_code(),
|
||||||
|
200,
|
||||||
|
"Reset password request should succeed"
|
||||||
|
);
|
||||||
|
|
||||||
|
let user = users::Model::find_by_email(&ctx.db, &user.email)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(user.reset_token.is_none());
|
||||||
|
assert!(user.reset_sent_at.is_none());
|
||||||
|
|
||||||
|
assert_debug_snapshot!(reset_response.text());
|
||||||
|
|
||||||
|
let login_response = request
|
||||||
|
.post("/api/auth/login")
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"email": user.email,
|
||||||
|
"password": new_password
|
||||||
|
}))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
login_response.status_code(),
|
||||||
|
200,
|
||||||
|
"Login request should succeed"
|
||||||
|
);
|
||||||
|
|
||||||
|
let deliveries = ctx.mailer.unwrap().deliveries();
|
||||||
|
assert_eq!(deliveries.count, 2, "Exactly one email should be sent");
|
||||||
|
// with_settings!({
|
||||||
|
// filters => cleanup_email()
|
||||||
|
// }, {
|
||||||
|
// assert_debug_snapshot!(deliveries.messages);
|
||||||
|
// });
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn can_get_current_user() {
|
||||||
|
configure_insta!();
|
||||||
|
|
||||||
|
request::<App, _, _>(|request, ctx| async move {
|
||||||
|
let user = prepare_data::init_user_login(&request, &ctx).await;
|
||||||
|
|
||||||
|
let (auth_key, auth_value) = prepare_data::auth_header(&user.token);
|
||||||
|
let response = request
|
||||||
|
.get("/api/auth/current")
|
||||||
|
.add_header(auth_key, auth_value)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
response.status_code(),
|
||||||
|
200,
|
||||||
|
"Current request should succeed"
|
||||||
|
);
|
||||||
|
|
||||||
|
with_settings!({
|
||||||
|
filters => cleanup_user_model()
|
||||||
|
}, {
|
||||||
|
assert_debug_snapshot!((response.status_code(), response.text()));
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn can_auth_with_magic_link() {
|
||||||
|
configure_insta!();
|
||||||
|
request::<App, _, _>(|request, ctx| async move {
|
||||||
|
seed::<App>(&ctx).await.unwrap();
|
||||||
|
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"email": "user1@example.com",
|
||||||
|
});
|
||||||
|
let response = request.post("/api/auth/magic-link").json(&payload).await;
|
||||||
|
assert_eq!(
|
||||||
|
response.status_code(),
|
||||||
|
200,
|
||||||
|
"Magic link request should succeed"
|
||||||
|
);
|
||||||
|
|
||||||
|
let deliveries = ctx.mailer.unwrap().deliveries();
|
||||||
|
assert_eq!(deliveries.count, 1, "Exactly one email should be sent");
|
||||||
|
|
||||||
|
// let redact_token = format!("[a-zA-Z0-9]{{{}}}", users::MAGIC_LINK_LENGTH);
|
||||||
|
// with_settings!({
|
||||||
|
// filters => {
|
||||||
|
// let mut combined_filters = cleanup_email().clone();
|
||||||
|
// combined_filters.extend(vec![(r"(\\r\\n|=\\r\\n)", ""), (redact_token.as_str(), "[REDACT_TOKEN]") ]);
|
||||||
|
// combined_filters
|
||||||
|
// }
|
||||||
|
// }, {
|
||||||
|
// assert_debug_snapshot!(deliveries.messages);
|
||||||
|
// });
|
||||||
|
|
||||||
|
let user = users::Model::find_by_email(&ctx.db, "user1@example.com")
|
||||||
|
.await
|
||||||
|
.expect("User should be found");
|
||||||
|
|
||||||
|
let magic_link_token = user
|
||||||
|
.magic_link_token
|
||||||
|
.expect("Magic link token should be generated");
|
||||||
|
let magic_link_response = request
|
||||||
|
.get(&format!("/api/auth/magic-link/{magic_link_token}"))
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
magic_link_response.status_code(),
|
||||||
|
200,
|
||||||
|
"Magic link authentication should succeed"
|
||||||
|
);
|
||||||
|
|
||||||
|
with_settings!({
|
||||||
|
filters => cleanup_user_model()
|
||||||
|
}, {
|
||||||
|
assert_debug_snapshot!(magic_link_response.text());
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn can_reject_invalid_email() {
|
||||||
|
configure_insta!();
|
||||||
|
request::<App, _, _>(|request, _ctx| async move {
|
||||||
|
let invalid_email = "user1@temp-mail.com";
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"email": invalid_email,
|
||||||
|
});
|
||||||
|
let response = request.post("/api/auth/magic-link").json(&payload).await;
|
||||||
|
assert_eq!(
|
||||||
|
response.status_code(),
|
||||||
|
400,
|
||||||
|
"Expected request with invalid email '{invalid_email}' to be blocked, but it was allowed."
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn can_reject_invalid_magic_link_token() {
|
||||||
|
configure_insta!();
|
||||||
|
request::<App, _, _>(|request, ctx| async move {
|
||||||
|
seed::<App>(&ctx).await.unwrap();
|
||||||
|
|
||||||
|
let magic_link_response = request.get("/api/auth/magic-link/invalid-token").await;
|
||||||
|
assert_eq!(
|
||||||
|
magic_link_response.status_code(),
|
||||||
|
401,
|
||||||
|
"Magic link authentication should be rejected"
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
6
tests/requests/mod.rs
Normal file
6
tests/requests/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
mod auth;
|
||||||
|
mod prepare_data;
|
||||||
|
|
||||||
|
pub mod music_library;
|
||||||
|
pub mod music_file;
|
||||||
|
pub mod musicbrainz;
|
16
tests/requests/music_file.rs
Normal file
16
tests/requests/music_file.rs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
use music_metadata_manager::app::App;
|
||||||
|
use loco_rs::testing::prelude::*;
|
||||||
|
use serial_test::serial;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn can_get_music_files() {
|
||||||
|
request::<App, _, _>(|request, _ctx| async move {
|
||||||
|
let res = request.get("/api/music_files/").await;
|
||||||
|
assert_eq!(res.status_code(), 200);
|
||||||
|
|
||||||
|
// you can assert content like this:
|
||||||
|
// assert_eq!(res.text(), "content");
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
16
tests/requests/music_library.rs
Normal file
16
tests/requests/music_library.rs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
use music_metadata_manager::app::App;
|
||||||
|
use loco_rs::testing::prelude::*;
|
||||||
|
use serial_test::serial;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn can_get_music_libraries() {
|
||||||
|
request::<App, _, _>(|request, _ctx| async move {
|
||||||
|
let res = request.get("/api/music_libraries/").await;
|
||||||
|
assert_eq!(res.status_code(), 200);
|
||||||
|
|
||||||
|
// you can assert content like this:
|
||||||
|
// assert_eq!(res.text(), "content");
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
17
tests/requests/musicbrainz.rs
Normal file
17
tests/requests/musicbrainz.rs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
use music_metadata_manager::app::App;
|
||||||
|
use loco_rs::testing::prelude::*;
|
||||||
|
use serial_test::serial;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn can_get_musicbrainzs() {
|
||||||
|
request::<App, _, _>(|request, _ctx| async move {
|
||||||
|
let res = request.get("/api/musicbrainzs/").await;
|
||||||
|
assert_eq!(res.status_code(), 200);
|
||||||
|
|
||||||
|
// you can assert content like this:
|
||||||
|
// assert_eq!(res.text(), "content");
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
57
tests/requests/prepare_data.rs
Normal file
57
tests/requests/prepare_data.rs
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
use axum::http::{HeaderName, HeaderValue};
|
||||||
|
use loco_rs::{app::AppContext, TestServer};
|
||||||
|
use music_metadata_manager::{models::users, views::auth::LoginResponse};
|
||||||
|
|
||||||
|
const USER_EMAIL: &str = "test@loco.com";
|
||||||
|
const USER_PASSWORD: &str = "1234";
|
||||||
|
|
||||||
|
pub struct LoggedInUser {
|
||||||
|
pub user: users::Model,
|
||||||
|
pub token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn init_user_login(request: &TestServer, ctx: &AppContext) -> LoggedInUser {
|
||||||
|
let register_payload = serde_json::json!({
|
||||||
|
"name": "loco",
|
||||||
|
"email": USER_EMAIL,
|
||||||
|
"password": USER_PASSWORD
|
||||||
|
});
|
||||||
|
|
||||||
|
//Creating a new user
|
||||||
|
request
|
||||||
|
.post("/api/auth/register")
|
||||||
|
.json(®ister_payload)
|
||||||
|
.await;
|
||||||
|
let user = users::Model::find_by_email(&ctx.db, USER_EMAIL)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let verify_payload = serde_json::json!({
|
||||||
|
"token": user.email_verification_token,
|
||||||
|
});
|
||||||
|
|
||||||
|
request.post("/api/auth/verify").json(&verify_payload).await;
|
||||||
|
|
||||||
|
let response = request
|
||||||
|
.post("/api/auth/login")
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"email": USER_EMAIL,
|
||||||
|
"password": USER_PASSWORD
|
||||||
|
}))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let login_response: LoginResponse = serde_json::from_str(&response.text()).unwrap();
|
||||||
|
|
||||||
|
LoggedInUser {
|
||||||
|
user: users::Model::find_by_email(&ctx.db, USER_EMAIL)
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
token: login_response.token,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn auth_header(token: &str) -> (HeaderName, HeaderValue) {
|
||||||
|
let auth_header_value = HeaderValue::from_str(&format!("Bearer {}", &token)).unwrap();
|
||||||
|
|
||||||
|
(HeaderName::from_static("authorization"), auth_header_value)
|
||||||
|
}
|
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
source: tests/requests/auth.rs
|
||||||
|
expression: magic_link_response.text()
|
||||||
|
---
|
||||||
|
"{\"token\":\"TOKEN\",\"pid\":\"PID\",\"name\":\"user1\",\"is_verified\":false}"
|
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
source: tests/requests/auth.rs
|
||||||
|
expression: "(response.status_code(), response.text())"
|
||||||
|
---
|
||||||
|
(
|
||||||
|
200,
|
||||||
|
"{\"pid\":\"PID\",\"name\":\"loco\",\"email\":\"test@loco.com\"}",
|
||||||
|
)
|
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
source: tests/requests/auth.rs
|
||||||
|
expression: login_response.text()
|
||||||
|
---
|
||||||
|
"{\"token\":\"TOKEN\",\"pid\":\"PID\",\"name\":\"loco\",\"is_verified\":false}"
|
27
tests/requests/snapshots/can_register@auth_request.snap
Normal file
27
tests/requests/snapshots/can_register@auth_request.snap
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
source: tests/requests/auth.rs
|
||||||
|
expression: saved_user
|
||||||
|
---
|
||||||
|
Ok(
|
||||||
|
Model {
|
||||||
|
created_at: DATE,
|
||||||
|
updated_at: DATE,
|
||||||
|
id: ID
|
||||||
|
pid: PID,
|
||||||
|
email: "test@loco.com",
|
||||||
|
password: "PASSWORD",
|
||||||
|
api_key: "lo-PID",
|
||||||
|
name: "loco",
|
||||||
|
reset_token: None,
|
||||||
|
reset_sent_at: None,
|
||||||
|
email_verification_token: Some(
|
||||||
|
"PID",
|
||||||
|
),
|
||||||
|
email_verification_sent_at: Some(
|
||||||
|
DATE,
|
||||||
|
),
|
||||||
|
email_verified_at: None,
|
||||||
|
magic_link_token: None,
|
||||||
|
magic_link_expiration: None,
|
||||||
|
},
|
||||||
|
)
|
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
source: tests/requests/auth.rs
|
||||||
|
expression: "(reset_response.status_code(), reset_response.text())"
|
||||||
|
---
|
||||||
|
"null"
|
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
source: tests/requests/auth.rs
|
||||||
|
expression: "(response.status_code(), response.text())"
|
||||||
|
---
|
||||||
|
(
|
||||||
|
401,
|
||||||
|
"{\"error\":\"unauthorized\",\"description\":\"You do not have permission to access this resource\"}",
|
||||||
|
)
|
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
source: tests/requests/auth.rs
|
||||||
|
expression: "(response.status_code(), response.text())"
|
||||||
|
---
|
||||||
|
(
|
||||||
|
200,
|
||||||
|
"{\"token\":\"TOKEN\",\"pid\":\"PID\",\"name\":\"loco\",\"is_verified\":true}",
|
||||||
|
)
|
17
tests/tasks/create_user.rs
Normal file
17
tests/tasks/create_user.rs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
use music_metadata_manager::app::App;
|
||||||
|
use loco_rs::{task, testing::prelude::*};
|
||||||
|
|
||||||
|
use loco_rs::boot::run_task;
|
||||||
|
use serial_test::serial;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn test_can_run_create_user() {
|
||||||
|
let boot = boot_test::<App>().await.unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
run_task::<App>(&boot.app_context, Some(&"create_user".to_string()), &task::Vars::default())
|
||||||
|
.await
|
||||||
|
.is_ok()
|
||||||
|
);
|
||||||
|
}
|
3
tests/tasks/mod.rs
Normal file
3
tests/tasks/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
|
||||||
|
|
||||||
|
pub mod create_user;
|
3
tests/workers/mod.rs
Normal file
3
tests/workers/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
|
||||||
|
|
||||||
|
pub mod scan_library_worker;
|
20
tests/workers/scan_library_worker.rs
Normal file
20
tests/workers/scan_library_worker.rs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
use loco_rs::{bgworker::BackgroundWorker, testing::prelude::*};
|
||||||
|
use music_metadata_manager::{
|
||||||
|
app::App,
|
||||||
|
workers::scan_library_worker::{Worker, WorkerArgs},
|
||||||
|
};
|
||||||
|
use serial_test::serial;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn test_run_scan_library_worker_worker() {
|
||||||
|
let boot = boot_test::<App>().await.unwrap();
|
||||||
|
|
||||||
|
// Execute the worker ensuring that it operates in 'ForegroundBlocking' mode, which prevents the addition of your worker to the background
|
||||||
|
assert!(
|
||||||
|
Worker::perform_later(&boot.app_context, WorkerArgs { library_id: 1 })
|
||||||
|
.await
|
||||||
|
.is_ok()
|
||||||
|
);
|
||||||
|
// Include additional assert validations after the execution of the worker
|
||||||
|
}
|
Reference in New Issue
Block a user