From 7bd27d9b9c48036b500598dfa91557ed2ef9712d Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sat, 11 Jul 2026 21:28:52 +0200 Subject: [PATCH] feat: JWT auth, /api prefix, SPA serving, OpenAPI, lean main.rs - auth: register/login/refresh/logout w/ JWT+Argon2, protected mutations - domain: User, RefreshSession, auth ports, Unauthorized/Forbidden errors - presentation: context/state/factory/errors/extractors/openapi modules - routes behind /api, SPA served from root w/ fallback - OpenAPI Scalar at /docs - frontend ssr:false, single-binary Dockerfile --- .dockerignore | 3 +- Cargo.lock | 290 ++++++++++++++++++ Cargo.toml | 2 + Dockerfile | 20 +- app/react-router.config.ts | 2 +- crates/adapters/auth/Cargo.toml | 14 + crates/adapters/auth/src/lib.rs | 100 ++++++ crates/adapters/sqlite/Cargo.toml | 1 + .../adapters/sqlite/migrations/002_users.sql | 7 + .../migrations/003_refresh_sessions.sql | 11 + crates/adapters/sqlite/src/lib.rs | 2 + .../adapters/sqlite/src/refresh_sessions.rs | 96 ++++++ crates/adapters/sqlite/src/users.rs | 81 +++++ crates/api-types/Cargo.toml | 1 + crates/api-types/src/lib.rs | 49 ++- crates/application/Cargo.toml | 1 + crates/application/src/auth/commands.rs | 18 ++ crates/application/src/auth/deps.rs | 27 ++ crates/application/src/auth/login.rs | 53 ++++ crates/application/src/auth/logout.rs | 8 + crates/application/src/auth/mod.rs | 6 + crates/application/src/auth/refresh.rs | 50 +++ crates/application/src/auth/register.rs | 34 ++ crates/application/src/lib.rs | 1 + crates/domain/Cargo.toml | 2 + crates/domain/src/errors/mod.rs | 6 + crates/domain/src/lib.rs | 12 +- crates/domain/src/models/mod.rs | 4 + crates/domain/src/models/refresh_session.rs | 17 + crates/domain/src/models/user.rs | 50 +++ crates/domain/src/ports/auth.rs | 34 ++ crates/domain/src/ports/mod.rs | 2 + crates/domain/src/value_objects/ids.rs | 18 ++ crates/domain/src/value_objects/mod.rs | 4 + crates/domain/src/value_objects/user.rs | 107 +++++++ crates/infra-wiring/src/config.rs | 32 +- crates/presentation/Cargo.toml | 5 + crates/presentation/src/context.rs | 30 ++ crates/presentation/src/errors.rs | 20 ++ crates/presentation/src/extractors.rs | 40 +++ crates/presentation/src/factory.rs | 36 +++ crates/presentation/src/lib.rs | 7 + crates/presentation/src/main.rs | 67 +--- crates/presentation/src/openapi.rs | 62 ++++ crates/presentation/src/routes/auth.rs | 106 +++++++ crates/presentation/src/routes/mod.rs | 62 ++++ crates/presentation/src/routes/songs.rs | 150 ++++----- crates/presentation/src/routes/tabs.rs | 27 +- crates/presentation/src/state.rs | 6 + docker-compose.yml | 34 +- 50 files changed, 1604 insertions(+), 213 deletions(-) create mode 100644 crates/adapters/auth/Cargo.toml create mode 100644 crates/adapters/auth/src/lib.rs create mode 100644 crates/adapters/sqlite/migrations/002_users.sql create mode 100644 crates/adapters/sqlite/migrations/003_refresh_sessions.sql create mode 100644 crates/adapters/sqlite/src/refresh_sessions.rs create mode 100644 crates/adapters/sqlite/src/users.rs create mode 100644 crates/application/src/auth/commands.rs create mode 100644 crates/application/src/auth/deps.rs create mode 100644 crates/application/src/auth/login.rs create mode 100644 crates/application/src/auth/logout.rs create mode 100644 crates/application/src/auth/mod.rs create mode 100644 crates/application/src/auth/refresh.rs create mode 100644 crates/application/src/auth/register.rs create mode 100644 crates/domain/src/models/refresh_session.rs create mode 100644 crates/domain/src/models/user.rs create mode 100644 crates/domain/src/ports/auth.rs create mode 100644 crates/domain/src/value_objects/ids.rs create mode 100644 crates/domain/src/value_objects/user.rs create mode 100644 crates/presentation/src/context.rs create mode 100644 crates/presentation/src/errors.rs create mode 100644 crates/presentation/src/extractors.rs create mode 100644 crates/presentation/src/factory.rs create mode 100644 crates/presentation/src/lib.rs create mode 100644 crates/presentation/src/openapi.rs create mode 100644 crates/presentation/src/routes/auth.rs create mode 100644 crates/presentation/src/state.rs diff --git a/.dockerignore b/.dockerignore index e90b671..282cb7f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,6 @@ /target -/app +/app/node_modules +/app/build .superpowers/ .git/ .claude/ diff --git a/Cargo.lock b/Cargo.lock index f7fdbe3..5401e7b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,15 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -28,16 +37,30 @@ name = "api-types" version = "0.1.0" dependencies = [ "serde", + "utoipa", ] [[package]] name = "application" version = "0.1.0" dependencies = [ + "chrono", "domain", "uuid", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -64,6 +87,20 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "auth" +version = "0.1.0" +dependencies = [ + "argon2", + "async-trait", + "chrono", + "domain", + "jsonwebtoken", + "rand_core 0.6.4", + "serde", + "uuid", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -177,6 +214,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -234,6 +280,20 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "cmake" version = "0.1.58" @@ -377,6 +437,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + [[package]] name = "derive_more" version = "0.99.20" @@ -416,6 +482,8 @@ name = "domain" version = "0.1.0" dependencies = [ "async-trait", + "chrono", + "email_address", "serde", "thiserror 2.0.18", "uuid", @@ -463,6 +531,15 @@ dependencies = [ "serde", ] +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + [[package]] name = "encoding_rs" version = "0.8.35" @@ -913,6 +990,30 @@ dependencies = [ "windows-registry", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.2.0" @@ -1126,6 +1227,21 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1307,6 +1423,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-bigint-dig" version = "0.8.6" @@ -1323,6 +1449,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-integer" version = "0.1.46" @@ -1394,6 +1526,27 @@ dependencies = [ "windows-link", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -1509,6 +1662,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1530,15 +1689,20 @@ version = "0.1.0" dependencies = [ "api-types", "application", + "async-trait", + "auth", "axum", "domain", "infra-wiring", + "serde", "sqlite", "tokio", "tower-http", "tracing", "tracing-subscriber", "ug-parser", + "utoipa", + "utoipa-scalar", "uuid", ] @@ -1715,6 +1879,18 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + [[package]] name = "regex-automata" version = "0.4.14" @@ -2117,6 +2293,18 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] + [[package]] name = "siphasher" version = "1.0.2" @@ -2172,6 +2360,7 @@ name = "sqlite" version = "0.1.0" dependencies = [ "async-trait", + "chrono", "domain", "serde_json", "sqlx", @@ -2532,6 +2721,36 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -2835,6 +3054,42 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utoipa" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bde15df68e80b16c7d16b9616e80770ad158988daa56a27dccd1e55558b0160" +dependencies = [ + "indexmap", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba0b99ee52df3028635d93840c797102da61f8a7bb3cf751032455895b52ef8" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn", +] + +[[package]] +name = "utoipa-scalar" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59559e1509172f6b26c1cdbc7247c4ddd1ac6560fe94b584f81ee489b141f719" +dependencies = [ + "axum", + "serde", + "serde_json", + "utoipa", +] + [[package]] name = "uuid" version = "1.23.0" @@ -3069,6 +3324,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 4bcf2a4..86ee8e2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,6 @@ [workspace] members = [ + "crates/adapters/auth", "crates/adapters/sqlite", "crates/adapters/ug-parser", "crates/api-types", @@ -30,6 +31,7 @@ domain = { path = "crates/domain" } application = { path = "crates/application" } api-types = { path = "crates/api-types" } infra-wiring = { path = "crates/infra-wiring" } +auth = { path = "crates/adapters/auth" } sqlite = { path = "crates/adapters/sqlite" } ug-parser = { path = "crates/adapters/ug-parser" } diff --git a/Dockerfile b/Dockerfile index f10b015..9c89a42 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,29 +1,35 @@ -FROM rust:1.97 AS builder +FROM node:22-slim AS frontend + +WORKDIR /app/frontend +COPY app/package.json app/package-lock.json ./ +RUN npm ci +COPY app/ . +ENV VITE_API_URL=/api +RUN npm run build + +FROM rust:1.97 AS backend WORKDIR /app COPY . . - -# Build the release binary RUN cargo build --release -p presentation FROM debian:trixie-slim WORKDIR /app -# Install OpenSSL, CA certs RUN apt-get update && apt-get install -y --no-install-recommends \ libssl3 \ ca-certificates \ libsqlite3-0 \ && rm -rf /var/lib/apt/lists/* -COPY --from=builder /app/target/release/presentation . +COPY --from=backend /app/target/release/presentation . +COPY --from=frontend /app/frontend/build/client ./spa - -# Create data directory for SQLite RUN mkdir -p /app/data ENV DATABASE_URL=sqlite:///app/data/pocket-chords.db +ENV SPA_DIR=/app/spa EXPOSE 8000 diff --git a/app/react-router.config.ts b/app/react-router.config.ts index 6ff16f9..b8b143a 100644 --- a/app/react-router.config.ts +++ b/app/react-router.config.ts @@ -3,5 +3,5 @@ import type { Config } from "@react-router/dev/config"; export default { // Config options... // Server-side render by default, to enable SPA mode set this to `false` - ssr: true, + ssr: false, } satisfies Config; diff --git a/crates/adapters/auth/Cargo.toml b/crates/adapters/auth/Cargo.toml new file mode 100644 index 0000000..264a6c0 --- /dev/null +++ b/crates/adapters/auth/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "auth" +version = "0.1.0" +edition = "2024" + +[dependencies] +domain = { workspace = true } +uuid = { workspace = true } +async-trait = { workspace = true } +chrono = { version = "0.4", features = ["serde"] } +jsonwebtoken = "9" +argon2 = { version = "0.5", features = ["std"] } +rand_core = { version = "0.6", features = ["getrandom"] } +serde = { workspace = true } diff --git a/crates/adapters/auth/src/lib.rs b/crates/adapters/auth/src/lib.rs new file mode 100644 index 0000000..502f390 --- /dev/null +++ b/crates/adapters/auth/src/lib.rs @@ -0,0 +1,100 @@ +use std::sync::Arc; + +use argon2::password_hash::SaltString; +use argon2::{Argon2, PasswordHash, PasswordHasher as ArgonHasher, PasswordVerifier}; +use async_trait::async_trait; +use chrono::{Duration, Utc}; +use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation}; +use rand_core::OsRng; +use serde::{Deserialize, Serialize}; + +use domain::errors::DomainError; +use domain::models::GeneratedToken; +use domain::value_objects::UserId; + +pub struct JwtAuthService { + encoding_key: EncodingKey, + decoding_key: DecodingKey, + ttl_seconds: i64, +} + +#[derive(Serialize, Deserialize)] +struct Claims { + sub: String, + exp: usize, +} + +impl JwtAuthService { + pub fn new(secret: &str, ttl_seconds: u64) -> Self { + Self { + encoding_key: EncodingKey::from_secret(secret.as_bytes()), + decoding_key: DecodingKey::from_secret(secret.as_bytes()), + ttl_seconds: ttl_seconds as i64, + } + } +} + +#[async_trait] +impl domain::ports::AuthService for JwtAuthService { + async fn generate_token(&self, user_id: &UserId) -> Result { + let expires_at = Utc::now() + Duration::seconds(self.ttl_seconds); + let claims = Claims { + sub: user_id.value().to_string(), + exp: expires_at.timestamp() as usize, + }; + let token = jsonwebtoken::encode(&Header::default(), &claims, &self.encoding_key) + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + Ok(GeneratedToken { token, expires_at }) + } + + async fn validate_token(&self, token: &str) -> Result { + let data = + jsonwebtoken::decode::(token, &self.decoding_key, &Validation::default()) + .map_err(|_| DomainError::Unauthorized("invalid or expired token".into()))?; + let uuid = uuid::Uuid::parse_str(&data.claims.sub) + .map_err(|_| DomainError::Unauthorized("invalid token subject".into()))?; + Ok(UserId::from_uuid(uuid)) + } +} + +pub struct Argon2PasswordHasher; + +#[async_trait] +impl domain::ports::PasswordHasher for Argon2PasswordHasher { + async fn hash( + &self, + plain_password: &str, + ) -> Result { + let salt = SaltString::generate(&mut OsRng); + let hash = Argon2::default() + .hash_password(plain_password.as_bytes(), &salt) + .map_err(|e| DomainError::InfrastructureError(e.to_string()))? + .to_string(); + domain::value_objects::PasswordHash::new(hash) + } + + async fn verify( + &self, + plain_password: &str, + hash: &domain::value_objects::PasswordHash, + ) -> Result { + let parsed = PasswordHash::new(hash.value()) + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + Ok(Argon2::default() + .verify_password(plain_password.as_bytes(), &parsed) + .is_ok()) + } +} + +pub fn create( + secret: &str, + ttl_seconds: u64, +) -> ( + Arc, + Arc, +) { + ( + Arc::new(JwtAuthService::new(secret, ttl_seconds)), + Arc::new(Argon2PasswordHasher), + ) +} diff --git a/crates/adapters/sqlite/Cargo.toml b/crates/adapters/sqlite/Cargo.toml index 11f3c02..125e034 100644 --- a/crates/adapters/sqlite/Cargo.toml +++ b/crates/adapters/sqlite/Cargo.toml @@ -8,4 +8,5 @@ sqlx = { workspace = true } uuid = { workspace = true } async-trait = { workspace = true } serde_json = { workspace = true } +chrono = { version = "0.4", features = ["serde"] } domain = { workspace = true } diff --git a/crates/adapters/sqlite/migrations/002_users.sql b/crates/adapters/sqlite/migrations/002_users.sql new file mode 100644 index 0000000..bc51f4d --- /dev/null +++ b/crates/adapters/sqlite/migrations/002_users.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY NOT NULL, + email TEXT UNIQUE NOT NULL, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + created_at TEXT NOT NULL +); diff --git a/crates/adapters/sqlite/migrations/003_refresh_sessions.sql b/crates/adapters/sqlite/migrations/003_refresh_sessions.sql new file mode 100644 index 0000000..2109489 --- /dev/null +++ b/crates/adapters/sqlite/migrations/003_refresh_sessions.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS refresh_sessions ( + id TEXT PRIMARY KEY NOT NULL, + user_id TEXT NOT NULL, + token TEXT UNIQUE NOT NULL, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_refresh_sessions_token ON refresh_sessions(token); +CREATE INDEX IF NOT EXISTS idx_refresh_sessions_user_id ON refresh_sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_refresh_sessions_expires_at ON refresh_sessions(expires_at); diff --git a/crates/adapters/sqlite/src/lib.rs b/crates/adapters/sqlite/src/lib.rs index 46e900c..1640ab3 100644 --- a/crates/adapters/sqlite/src/lib.rs +++ b/crates/adapters/sqlite/src/lib.rs @@ -1,5 +1,7 @@ +mod refresh_sessions; pub mod repository; mod row; mod search; +mod users; pub use repository::{SqliteRepositoryFactory, SqliteSongRepository}; diff --git a/crates/adapters/sqlite/src/refresh_sessions.rs b/crates/adapters/sqlite/src/refresh_sessions.rs new file mode 100644 index 0000000..4db2b76 --- /dev/null +++ b/crates/adapters/sqlite/src/refresh_sessions.rs @@ -0,0 +1,96 @@ +use async_trait::async_trait; +use chrono::DateTime; +use domain::errors::DomainError; +use domain::models::RefreshSession; +use domain::value_objects::UserId; + +use crate::repository::SqliteSongRepository; + +#[derive(sqlx::FromRow)] +struct RefreshSessionRow { + id: String, + user_id: String, + token: String, + expires_at: String, + created_at: String, +} + +fn row_to_session(row: RefreshSessionRow) -> Result { + let id = uuid::Uuid::parse_str(&row.id) + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + let user_id = uuid::Uuid::parse_str(&row.user_id) + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + let expires_at = DateTime::parse_from_rfc3339(&row.expires_at) + .map_err(|e| DomainError::InfrastructureError(e.to_string()))? + .to_utc(); + let created_at = DateTime::parse_from_rfc3339(&row.created_at) + .map_err(|e| DomainError::InfrastructureError(e.to_string()))? + .to_utc(); + + Ok(RefreshSession { + id, + user_id: UserId::from_uuid(user_id), + token: row.token, + expires_at, + created_at, + }) +} + +#[async_trait] +impl domain::ports::RefreshSessionRepository for SqliteSongRepository { + async fn create(&self, session: &RefreshSession) -> Result<(), DomainError> { + sqlx::query( + "INSERT INTO refresh_sessions (id, user_id, token, expires_at, created_at) VALUES (?, ?, ?, ?, ?)", + ) + .bind(session.id.to_string()) + .bind(session.user_id.value().to_string()) + .bind(&session.token) + .bind(session.expires_at.to_rfc3339()) + .bind(session.created_at.to_rfc3339()) + .execute(&self.pool) + .await + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + + Ok(()) + } + + async fn get_by_token(&self, token: &str) -> Result, DomainError> { + let row = sqlx::query_as::<_, RefreshSessionRow>( + "SELECT id, user_id, token, expires_at, created_at FROM refresh_sessions WHERE token = ?", + ) + .bind(token) + .fetch_optional(&self.pool) + .await + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + + row.map(row_to_session).transpose() + } + + async fn revoke(&self, token: &str) -> Result<(), DomainError> { + sqlx::query("DELETE FROM refresh_sessions WHERE token = ?") + .bind(token) + .execute(&self.pool) + .await + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + Ok(()) + } + + async fn revoke_all_for_user(&self, user_id: &UserId) -> Result<(), DomainError> { + sqlx::query("DELETE FROM refresh_sessions WHERE user_id = ?") + .bind(user_id.value().to_string()) + .execute(&self.pool) + .await + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + Ok(()) + } + + async fn delete_expired(&self) -> Result { + let now = chrono::Utc::now().to_rfc3339(); + let result = sqlx::query("DELETE FROM refresh_sessions WHERE expires_at < ?") + .bind(&now) + .execute(&self.pool) + .await + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + Ok(result.rows_affected()) + } +} diff --git a/crates/adapters/sqlite/src/users.rs b/crates/adapters/sqlite/src/users.rs new file mode 100644 index 0000000..5c07012 --- /dev/null +++ b/crates/adapters/sqlite/src/users.rs @@ -0,0 +1,81 @@ +use async_trait::async_trait; +use domain::errors::DomainError; +use domain::models::User; +use domain::value_objects::{Email, PasswordHash, UserId, Username}; + +use crate::repository::SqliteSongRepository; + +#[derive(sqlx::FromRow)] +struct UserRow { + id: String, + email: String, + username: String, + password_hash: String, +} + +fn row_to_user(row: UserRow) -> Result { + let id = uuid::Uuid::parse_str(&row.id) + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + Ok(User::from_persistence( + UserId::from_uuid(id), + Email::new(&row.email)?, + Username::new(&row.username)?, + PasswordHash::new(row.password_hash)?, + )) +} + +#[async_trait] +impl domain::ports::UserRepository for SqliteSongRepository { + async fn find_by_email(&self, email: &Email) -> Result, DomainError> { + let row = sqlx::query_as::<_, UserRow>( + "SELECT id, email, username, password_hash FROM users WHERE email = ?", + ) + .bind(email.value()) + .fetch_optional(&self.pool) + .await + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + + row.map(row_to_user).transpose() + } + + async fn find_by_username(&self, username: &Username) -> Result, DomainError> { + let row = sqlx::query_as::<_, UserRow>( + "SELECT id, email, username, password_hash FROM users WHERE username = ?", + ) + .bind(username.value()) + .fetch_optional(&self.pool) + .await + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + + row.map(row_to_user).transpose() + } + + async fn find_by_id(&self, id: &UserId) -> Result, DomainError> { + let row = sqlx::query_as::<_, UserRow>( + "SELECT id, email, username, password_hash FROM users WHERE id = ?", + ) + .bind(id.value().to_string()) + .fetch_optional(&self.pool) + .await + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + + row.map(row_to_user).transpose() + } + + async fn save(&self, user: &User) -> Result<(), DomainError> { + let now = chrono::Utc::now().to_rfc3339(); + sqlx::query( + "INSERT INTO users (id, email, username, password_hash, created_at) VALUES (?, ?, ?, ?, ?)", + ) + .bind(user.id().value().to_string()) + .bind(user.email().value()) + .bind(user.username().value()) + .bind(user.password_hash().value()) + .bind(&now) + .execute(&self.pool) + .await + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + + Ok(()) + } +} diff --git a/crates/api-types/Cargo.toml b/crates/api-types/Cargo.toml index 44283e8..26eb24a 100644 --- a/crates/api-types/Cargo.toml +++ b/crates/api-types/Cargo.toml @@ -5,3 +5,4 @@ edition = "2024" [dependencies] serde = { workspace = true } +utoipa = { version = "5", features = ["axum_extras"] } diff --git a/crates/api-types/src/lib.rs b/crates/api-types/src/lib.rs index dd96d39..c915395 100644 --- a/crates/api-types/src/lib.rs +++ b/crates/api-types/src/lib.rs @@ -1,31 +1,70 @@ use serde::{Deserialize, Serialize}; +use utoipa::{IntoParams, ToSchema}; -#[derive(Deserialize)] +#[derive(Deserialize, ToSchema)] pub struct ParseRequest { pub source: Option, pub html: Option, } -#[derive(Serialize)] +#[derive(Serialize, ToSchema)] pub struct ErrorResponse { pub error: String, } -#[derive(Deserialize)] +#[derive(Deserialize, ToSchema, IntoParams)] pub struct ListQuery { pub q: Option, pub sort: Option, pub order: Option, } -#[derive(Deserialize)] +#[derive(Deserialize, ToSchema)] pub struct UpdateSongRequest { pub title: Option, pub artist: Option, pub original_key: Option, } -#[derive(Deserialize)] +#[derive(Deserialize, ToSchema, IntoParams)] pub struct GetSongQuery { pub apply_capo: Option, } + +#[derive(Deserialize, ToSchema)] +pub struct LoginRequest { + pub email: String, + pub password: String, +} + +#[derive(Serialize, ToSchema)] +pub struct LoginResponse { + pub token: String, + pub refresh_token: String, + pub user_id: String, + pub expires_at: String, +} + +#[derive(Deserialize, ToSchema)] +pub struct RegisterRequest { + pub email: String, + pub username: String, + pub password: String, +} + +#[derive(Deserialize, ToSchema)] +pub struct RefreshRequest { + pub refresh_token: String, +} + +#[derive(Serialize, ToSchema)] +pub struct RefreshResponse { + pub token: String, + pub refresh_token: String, + pub expires_at: String, +} + +#[derive(Deserialize, ToSchema)] +pub struct LogoutRequest { + pub refresh_token: String, +} diff --git a/crates/application/Cargo.toml b/crates/application/Cargo.toml index cba3f0c..a552b16 100644 --- a/crates/application/Cargo.toml +++ b/crates/application/Cargo.toml @@ -5,4 +5,5 @@ edition = "2024" [dependencies] uuid = { workspace = true } +chrono = { version = "0.4", features = ["serde"] } domain = { workspace = true } diff --git a/crates/application/src/auth/commands.rs b/crates/application/src/auth/commands.rs new file mode 100644 index 0000000..19ec30c --- /dev/null +++ b/crates/application/src/auth/commands.rs @@ -0,0 +1,18 @@ +pub struct RegisterCommand { + pub email: String, + pub username: String, + pub password: String, +} + +pub struct LoginCommand { + pub email: String, + pub password: String, +} + +pub struct RefreshCommand { + pub refresh_token: String, +} + +pub struct LogoutCommand { + pub refresh_token: String, +} diff --git a/crates/application/src/auth/deps.rs b/crates/application/src/auth/deps.rs new file mode 100644 index 0000000..243619b --- /dev/null +++ b/crates/application/src/auth/deps.rs @@ -0,0 +1,27 @@ +use std::sync::Arc; + +use domain::ports::{AuthService, PasswordHasher, RefreshSessionRepository, UserRepository}; + +pub struct RegisterDeps { + pub user_repo: Arc, + pub password_hasher: Arc, + pub allow_registration: bool, +} + +pub struct LoginDeps { + pub user_repo: Arc, + pub password_hasher: Arc, + pub auth_service: Arc, + pub refresh_repo: Arc, + pub refresh_ttl_seconds: u64, +} + +pub struct RefreshDeps { + pub auth_service: Arc, + pub refresh_repo: Arc, + pub refresh_ttl_seconds: u64, +} + +pub struct LogoutDeps { + pub refresh_repo: Arc, +} diff --git a/crates/application/src/auth/login.rs b/crates/application/src/auth/login.rs new file mode 100644 index 0000000..7dbb673 --- /dev/null +++ b/crates/application/src/auth/login.rs @@ -0,0 +1,53 @@ +use chrono::{Duration, Utc}; +use domain::errors::DomainError; +use domain::models::RefreshSession; +use domain::value_objects::{Email, UserId}; +use uuid::Uuid; + +use super::commands::LoginCommand; +use super::deps::LoginDeps; + +pub struct LoginResult { + pub access_token: String, + pub refresh_token: String, + pub user_id: UserId, + pub expires_at: String, +} + +pub async fn execute(deps: &LoginDeps, cmd: LoginCommand) -> Result { + let email = Email::new(&cmd.email)?; + + let user = deps + .user_repo + .find_by_email(&email) + .await? + .ok_or_else(|| DomainError::Unauthorized("invalid credentials".into()))?; + + let valid = deps + .password_hasher + .verify(&cmd.password, user.password_hash()) + .await?; + if !valid { + return Err(DomainError::Unauthorized("invalid credentials".into())); + } + + let generated = deps.auth_service.generate_token(user.id()).await?; + + let refresh_token = Uuid::new_v4().to_string(); + let refresh_expires = Utc::now() + Duration::seconds(deps.refresh_ttl_seconds as i64); + let session = RefreshSession { + id: Uuid::new_v4(), + user_id: *user.id(), + token: refresh_token.clone(), + expires_at: refresh_expires, + created_at: Utc::now(), + }; + deps.refresh_repo.create(&session).await?; + + Ok(LoginResult { + access_token: generated.token, + refresh_token, + user_id: *user.id(), + expires_at: generated.expires_at.to_rfc3339(), + }) +} diff --git a/crates/application/src/auth/logout.rs b/crates/application/src/auth/logout.rs new file mode 100644 index 0000000..25938a6 --- /dev/null +++ b/crates/application/src/auth/logout.rs @@ -0,0 +1,8 @@ +use domain::errors::DomainError; + +use super::commands::LogoutCommand; +use super::deps::LogoutDeps; + +pub async fn execute(deps: &LogoutDeps, cmd: LogoutCommand) -> Result<(), DomainError> { + deps.refresh_repo.revoke(&cmd.refresh_token).await +} diff --git a/crates/application/src/auth/mod.rs b/crates/application/src/auth/mod.rs new file mode 100644 index 0000000..afe2e6e --- /dev/null +++ b/crates/application/src/auth/mod.rs @@ -0,0 +1,6 @@ +pub mod commands; +pub mod deps; +pub mod login; +pub mod logout; +pub mod refresh; +pub mod register; diff --git a/crates/application/src/auth/refresh.rs b/crates/application/src/auth/refresh.rs new file mode 100644 index 0000000..e1b7a18 --- /dev/null +++ b/crates/application/src/auth/refresh.rs @@ -0,0 +1,50 @@ +use chrono::{Duration, Utc}; +use domain::errors::DomainError; +use domain::models::RefreshSession; +use uuid::Uuid; + +use super::commands::RefreshCommand; +use super::deps::RefreshDeps; + +pub struct RefreshResult { + pub access_token: String, + pub refresh_token: String, + pub expires_at: String, +} + +pub async fn execute( + deps: &RefreshDeps, + cmd: RefreshCommand, +) -> Result { + let session = deps + .refresh_repo + .get_by_token(&cmd.refresh_token) + .await? + .ok_or_else(|| DomainError::Unauthorized("invalid refresh token".into()))?; + + if session.expires_at < Utc::now() { + deps.refresh_repo.revoke(&cmd.refresh_token).await?; + return Err(DomainError::Unauthorized("refresh token expired".into())); + } + + deps.refresh_repo.revoke(&cmd.refresh_token).await?; + + let generated = deps.auth_service.generate_token(&session.user_id).await?; + + let new_refresh_token = Uuid::new_v4().to_string(); + let refresh_expires = Utc::now() + Duration::seconds(deps.refresh_ttl_seconds as i64); + let new_session = RefreshSession { + id: Uuid::new_v4(), + user_id: session.user_id, + token: new_refresh_token.clone(), + expires_at: refresh_expires, + created_at: Utc::now(), + }; + deps.refresh_repo.create(&new_session).await?; + + Ok(RefreshResult { + access_token: generated.token, + refresh_token: new_refresh_token, + expires_at: generated.expires_at.to_rfc3339(), + }) +} diff --git a/crates/application/src/auth/register.rs b/crates/application/src/auth/register.rs new file mode 100644 index 0000000..a1ea67e --- /dev/null +++ b/crates/application/src/auth/register.rs @@ -0,0 +1,34 @@ +use domain::errors::DomainError; +use domain::models::User; +use domain::value_objects::{Email, Password, Username}; + +use super::commands::RegisterCommand; +use super::deps::RegisterDeps; + +pub async fn execute(deps: &RegisterDeps, cmd: RegisterCommand) -> Result<(), DomainError> { + if !deps.allow_registration { + return Err(DomainError::Unauthorized("registration is disabled".into())); + } + + let password = Password::new(&cmd.password)?; + let email = Email::new(&cmd.email)?; + let username = Username::new(&cmd.username)?; + + if deps.user_repo.find_by_email(&email).await?.is_some() { + return Err(DomainError::ValidationError( + "email already registered".into(), + )); + } + + if deps.user_repo.find_by_username(&username).await?.is_some() { + return Err(DomainError::ValidationError( + "username already taken".into(), + )); + } + + let hash = deps.password_hasher.hash(password.value()).await?; + let user = User::new(email, username, hash); + deps.user_repo.save(&user).await?; + + Ok(()) +} diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 6876366..2d52651 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -1,2 +1,3 @@ +pub mod auth; pub mod songs; pub mod tabs; diff --git a/crates/domain/Cargo.toml b/crates/domain/Cargo.toml index ce3f275..51ab612 100644 --- a/crates/domain/Cargo.toml +++ b/crates/domain/Cargo.toml @@ -8,3 +8,5 @@ thiserror = { workspace = true } uuid = { workspace = true } serde = { workspace = true } async-trait = { workspace = true } +chrono = { version = "0.4", features = ["serde"] } +email_address = "0.2" diff --git a/crates/domain/src/errors/mod.rs b/crates/domain/src/errors/mod.rs index d2025ca..03d981f 100644 --- a/crates/domain/src/errors/mod.rs +++ b/crates/domain/src/errors/mod.rs @@ -10,4 +10,10 @@ pub enum DomainError { #[error("Infrastructure failure: {0}")] InfrastructureError(String), + + #[error("Unauthorized: {0}")] + Unauthorized(String), + + #[error("Forbidden: {0}")] + Forbidden(String), } diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index bfb540b..81a4aa0 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -6,12 +6,14 @@ pub mod value_objects; pub use errors::DomainError; pub use models::{ - ChordPosition, LyricLine, Section, SectionKind, Song, SongMeta, SongSummary, StoredSong, - song_preview_chords, + ChordPosition, GeneratedToken, LyricLine, RefreshSession, Section, SectionKind, Song, SongMeta, + SongSummary, StoredSong, User, song_preview_chords, }; pub use ports::{ - FetchError, ParseError, SongRepositoryPort, SongSearchPort, TabFetcherPort, TabParserPort, - TabSource, + AuthService, FetchError, ParseError, PasswordHasher, RefreshSessionRepository, + SongRepositoryPort, SongSearchPort, TabFetcherPort, TabParserPort, TabSource, UserRepository, }; pub use services::{ChordTransposer, TransposeError}; -pub use value_objects::{Chord, Note, SortField, SortOrder}; +pub use value_objects::{ + Chord, Email, Note, Password, PasswordHash, SortField, SortOrder, UserId, Username, +}; diff --git a/crates/domain/src/models/mod.rs b/crates/domain/src/models/mod.rs index e96c71c..aa3196c 100644 --- a/crates/domain/src/models/mod.rs +++ b/crates/domain/src/models/mod.rs @@ -1,3 +1,7 @@ +pub mod refresh_session; pub mod song; +pub mod user; +pub use refresh_session::*; pub use song::*; +pub use user::*; diff --git a/crates/domain/src/models/refresh_session.rs b/crates/domain/src/models/refresh_session.rs new file mode 100644 index 0000000..2ba3a02 --- /dev/null +++ b/crates/domain/src/models/refresh_session.rs @@ -0,0 +1,17 @@ +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +use crate::value_objects::UserId; + +pub struct GeneratedToken { + pub token: String, + pub expires_at: DateTime, +} + +pub struct RefreshSession { + pub id: Uuid, + pub user_id: UserId, + pub token: String, + pub expires_at: DateTime, + pub created_at: DateTime, +} diff --git a/crates/domain/src/models/user.rs b/crates/domain/src/models/user.rs new file mode 100644 index 0000000..8ea88d1 --- /dev/null +++ b/crates/domain/src/models/user.rs @@ -0,0 +1,50 @@ +use crate::value_objects::{Email, PasswordHash, UserId, Username}; + +#[derive(Debug, Clone)] +pub struct User { + id: UserId, + email: Email, + username: Username, + password_hash: PasswordHash, +} + +impl User { + pub fn new(email: Email, username: Username, password_hash: PasswordHash) -> Self { + Self { + id: UserId::generate(), + email, + username, + password_hash, + } + } + + pub fn from_persistence( + id: UserId, + email: Email, + username: Username, + password_hash: PasswordHash, + ) -> Self { + Self { + id, + email, + username, + password_hash, + } + } + + pub fn id(&self) -> &UserId { + &self.id + } + + pub fn email(&self) -> &Email { + &self.email + } + + pub fn username(&self) -> &Username { + &self.username + } + + pub fn password_hash(&self) -> &PasswordHash { + &self.password_hash + } +} diff --git a/crates/domain/src/ports/auth.rs b/crates/domain/src/ports/auth.rs new file mode 100644 index 0000000..d2fcb6b --- /dev/null +++ b/crates/domain/src/ports/auth.rs @@ -0,0 +1,34 @@ +use async_trait::async_trait; + +use crate::errors::DomainError; +use crate::models::{GeneratedToken, RefreshSession, User}; +use crate::value_objects::{Email, PasswordHash, UserId, Username}; + +#[async_trait] +pub trait AuthService: Send + Sync { + async fn generate_token(&self, user_id: &UserId) -> Result; + async fn validate_token(&self, token: &str) -> Result; +} + +#[async_trait] +pub trait PasswordHasher: Send + Sync { + async fn hash(&self, plain_password: &str) -> Result; + async fn verify(&self, plain_password: &str, hash: &PasswordHash) -> Result; +} + +#[async_trait] +pub trait UserRepository: Send + Sync { + async fn find_by_email(&self, email: &Email) -> Result, DomainError>; + async fn find_by_username(&self, username: &Username) -> Result, DomainError>; + async fn find_by_id(&self, id: &UserId) -> Result, DomainError>; + async fn save(&self, user: &User) -> Result<(), DomainError>; +} + +#[async_trait] +pub trait RefreshSessionRepository: Send + Sync { + async fn create(&self, session: &RefreshSession) -> Result<(), DomainError>; + async fn get_by_token(&self, token: &str) -> Result, DomainError>; + async fn revoke(&self, token: &str) -> Result<(), DomainError>; + async fn revoke_all_for_user(&self, user_id: &UserId) -> Result<(), DomainError>; + async fn delete_expired(&self) -> Result; +} diff --git a/crates/domain/src/ports/mod.rs b/crates/domain/src/ports/mod.rs index 80e2efc..2c8a837 100644 --- a/crates/domain/src/ports/mod.rs +++ b/crates/domain/src/ports/mod.rs @@ -1,5 +1,7 @@ +pub mod auth; pub mod repository; pub mod tab_source; +pub use auth::*; pub use repository::*; pub use tab_source::*; diff --git a/crates/domain/src/value_objects/ids.rs b/crates/domain/src/value_objects/ids.rs new file mode 100644 index 0000000..e4a42c0 --- /dev/null +++ b/crates/domain/src/value_objects/ids.rs @@ -0,0 +1,18 @@ +use uuid::Uuid; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct UserId(Uuid); + +impl UserId { + pub fn generate() -> Self { + Self(Uuid::new_v4()) + } + + pub fn from_uuid(uuid: Uuid) -> Self { + Self(uuid) + } + + pub fn value(&self) -> Uuid { + self.0 + } +} diff --git a/crates/domain/src/value_objects/mod.rs b/crates/domain/src/value_objects/mod.rs index 00b7144..272837f 100644 --- a/crates/domain/src/value_objects/mod.rs +++ b/crates/domain/src/value_objects/mod.rs @@ -1,7 +1,11 @@ mod chord; +mod ids; mod note; mod sorting; +mod user; pub use chord::*; +pub use ids::*; pub use note::*; pub use sorting::*; +pub use user::*; diff --git a/crates/domain/src/value_objects/user.rs b/crates/domain/src/value_objects/user.rs new file mode 100644 index 0000000..6f2c1a7 --- /dev/null +++ b/crates/domain/src/value_objects/user.rs @@ -0,0 +1,107 @@ +use crate::errors::DomainError; + +#[derive(Clone, PartialEq, Eq)] +pub struct Email(String); + +impl Email { + pub fn new(email: &str) -> Result { + let trimmed = email.trim().to_lowercase(); + if !email_address::EmailAddress::is_valid(&trimmed) { + return Err(DomainError::ValidationError(format!( + "invalid email: {trimmed}" + ))); + } + Ok(Self(trimmed)) + } + + pub fn value(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Debug for Email { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Email({})", self.0) + } +} + +#[derive(Clone, PartialEq, Eq)] +pub struct Username(String); + +impl Username { + pub fn new(username: &str) -> Result { + let normalized = username.trim().to_lowercase(); + if normalized.len() < 2 || normalized.len() > 30 { + return Err(DomainError::ValidationError( + "username must be 2-30 characters".into(), + )); + } + if !normalized + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + { + return Err(DomainError::ValidationError( + "username may only contain alphanumeric characters, underscores, and dashes".into(), + )); + } + Ok(Self(normalized)) + } + + pub fn value(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Debug for Username { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Username({})", self.0) + } +} + +#[derive(Clone, PartialEq, Eq)] +pub struct PasswordHash(String); + +impl PasswordHash { + pub fn new(hash: String) -> Result { + if hash.is_empty() { + return Err(DomainError::ValidationError( + "password hash cannot be empty".into(), + )); + } + Ok(Self(hash)) + } + + pub fn value(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Debug for PasswordHash { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "PasswordHash([REDACTED])") + } +} + +#[derive(Clone, PartialEq, Eq)] +pub struct Password(String); + +impl Password { + pub fn new(password: &str) -> Result { + if password.len() < 8 { + return Err(DomainError::ValidationError( + "password must be at least 8 characters".into(), + )); + } + Ok(Self(password.to_string())) + } + + pub fn value(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Debug for Password { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Password([REDACTED])") + } +} diff --git a/crates/infra-wiring/src/config.rs b/crates/infra-wiring/src/config.rs index 3838c0d..a445af1 100644 --- a/crates/infra-wiring/src/config.rs +++ b/crates/infra-wiring/src/config.rs @@ -1,14 +1,19 @@ use std::env; -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct AppConfig { pub database_url: String, pub host: String, pub port: u16, pub cors_origins: CorsOrigins, + pub jwt_secret: String, + pub jwt_ttl_seconds: u64, + pub refresh_ttl_seconds: u64, + pub allow_registration: bool, + pub spa_dir: String, } -#[derive(Debug)] +#[derive(Debug, Clone)] pub enum CorsOrigins { Any, List(Vec), @@ -40,11 +45,34 @@ impl AppConfig { ), }; + let jwt_secret = env::var("JWT_SECRET").expect("JWT_SECRET env var is required"); + + let jwt_ttl_seconds = env::var("JWT_TTL_SECONDS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(900); + + let refresh_ttl_seconds = env::var("REFRESH_TTL_SECONDS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(2_592_000); + + let allow_registration = env::var("ALLOW_REGISTRATION") + .map(|v| v == "true" || v == "1") + .unwrap_or(false); + + let spa_dir = env::var("SPA_DIR").unwrap_or_else(|_| "./app/build/client".into()); + Self { database_url, host, port, cors_origins, + jwt_secret, + jwt_ttl_seconds, + refresh_ttl_seconds, + allow_registration, + spa_dir, } } diff --git a/crates/presentation/Cargo.toml b/crates/presentation/Cargo.toml index 9b5142c..071573d 100644 --- a/crates/presentation/Cargo.toml +++ b/crates/presentation/Cargo.toml @@ -9,10 +9,15 @@ tokio = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } uuid = { workspace = true } +async-trait = { workspace = true } +serde = { workspace = true } tower-http = { version = "0.6.8", features = ["cors", "fs", "trace", "tracing"] } +utoipa = { version = "5", features = ["axum_extras"] } +utoipa-scalar = { version = "0.3", features = ["axum"], default-features = false } api-types = { workspace = true } application = { workspace = true } +auth = { workspace = true } domain = { workspace = true } infra-wiring = { workspace = true } sqlite = { workspace = true } diff --git a/crates/presentation/src/context.rs b/crates/presentation/src/context.rs new file mode 100644 index 0000000..8dbd572 --- /dev/null +++ b/crates/presentation/src/context.rs @@ -0,0 +1,30 @@ +use std::sync::Arc; + +use domain::ports::{ + AuthService, PasswordHasher, RefreshSessionRepository, SongRepositoryPort, SongSearchPort, + TabFetcherPort, TabParserPort, UserRepository, +}; +use infra_wiring::AppConfig; + +#[derive(Clone)] +pub struct Repositories { + pub song_repo: Arc, + pub song_search: Arc, + pub user_repo: Arc, + pub refresh_repo: Arc, +} + +#[derive(Clone)] +pub struct Services { + pub auth: Arc, + pub password_hasher: Arc, + pub tab_fetcher: Arc, + pub tab_parser: Arc, +} + +#[derive(Clone)] +pub struct AppContext { + pub repos: Repositories, + pub services: Services, + pub config: AppConfig, +} diff --git a/crates/presentation/src/errors.rs b/crates/presentation/src/errors.rs new file mode 100644 index 0000000..f40398a --- /dev/null +++ b/crates/presentation/src/errors.rs @@ -0,0 +1,20 @@ +use api_types::ErrorResponse; +use axum::{Json, http::StatusCode}; +use domain::DomainError; + +pub fn map_error(e: DomainError) -> (StatusCode, Json) { + let (status, message) = match &e { + DomainError::NotFound => (StatusCode::NOT_FOUND, "not found".to_string()), + DomainError::ValidationError(msg) => (StatusCode::BAD_REQUEST, msg.clone()), + DomainError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg.clone()), + DomainError::Forbidden(msg) => (StatusCode::FORBIDDEN, msg.clone()), + DomainError::InfrastructureError(_) => { + tracing::error!("{e}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "internal error".to_string(), + ) + } + }; + (status, Json(ErrorResponse { error: message })) +} diff --git a/crates/presentation/src/extractors.rs b/crates/presentation/src/extractors.rs new file mode 100644 index 0000000..6aaa164 --- /dev/null +++ b/crates/presentation/src/extractors.rs @@ -0,0 +1,40 @@ +use axum::{ + extract::FromRequestParts, + http::{StatusCode, request::Parts}, +}; + +use domain::value_objects::UserId; + +use crate::state::AppState; + +#[allow(dead_code)] +pub struct AuthenticatedUser(pub UserId); + +impl FromRequestParts for AuthenticatedUser { + type Rejection = StatusCode; + + async fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> Result { + let header = parts + .headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .ok_or(StatusCode::UNAUTHORIZED)?; + + let token = header + .strip_prefix("Bearer ") + .ok_or(StatusCode::UNAUTHORIZED)?; + + let user_id = state + .ctx + .services + .auth + .validate_token(token) + .await + .map_err(|_| StatusCode::UNAUTHORIZED)?; + + Ok(AuthenticatedUser(user_id)) + } +} diff --git a/crates/presentation/src/factory.rs b/crates/presentation/src/factory.rs new file mode 100644 index 0000000..86ef651 --- /dev/null +++ b/crates/presentation/src/factory.rs @@ -0,0 +1,36 @@ +use std::sync::Arc; + +use infra_wiring::AppConfig; +use sqlite::SqliteRepositoryFactory; +use ug_parser::{UgHtmlParser, UgTabFetcher}; + +use crate::context::{AppContext, Repositories, Services}; +use crate::state::AppState; + +pub async fn wire(config: AppConfig) -> AppState { + let repo = SqliteRepositoryFactory::create(&config.database_url) + .await + .expect("failed to connect to database"); + let repo = Arc::new(repo); + + let (auth_service, password_hasher) = + ::auth::create(&config.jwt_secret, config.jwt_ttl_seconds); + + let ctx = AppContext { + repos: Repositories { + song_repo: repo.clone(), + song_search: repo.clone(), + user_repo: repo.clone(), + refresh_repo: repo.clone(), + }, + services: Services { + auth: auth_service, + password_hasher, + tab_fetcher: Arc::new(UgTabFetcher::new()), + tab_parser: Arc::new(UgHtmlParser), + }, + config, + }; + + AppState { ctx } +} diff --git a/crates/presentation/src/lib.rs b/crates/presentation/src/lib.rs new file mode 100644 index 0000000..e86a8d3 --- /dev/null +++ b/crates/presentation/src/lib.rs @@ -0,0 +1,7 @@ +pub mod context; +pub mod errors; +pub mod extractors; +pub mod factory; +pub mod openapi; +pub mod routes; +pub mod state; diff --git a/crates/presentation/src/main.rs b/crates/presentation/src/main.rs index 2fc533b..ca9396d 100644 --- a/crates/presentation/src/main.rs +++ b/crates/presentation/src/main.rs @@ -1,20 +1,4 @@ -mod routes; - -use std::sync::Arc; - -use application::songs::deps::{SongCommandDeps, SongQueryDeps}; -use application::tabs::deps::ParseTabDeps; -use axum::{ - Router, - http::HeaderValue, - routing::{get, post}, -}; -use infra_wiring::{AppConfig, CorsOrigins}; -use routes::songs::{create_song, delete_song, get_song, list_songs, update_song}; -use routes::tabs::{AppState, parse_tab}; -use sqlite::SqliteRepositoryFactory; -use tower_http::cors::{Any, CorsLayer}; -use ug_parser::{UgHtmlParser, UgTabFetcher}; +use infra_wiring::AppConfig; #[tokio::main] async fn main() { @@ -23,53 +7,8 @@ async fn main() { let config = AppConfig::from_env(); tracing::info!(?config, "starting with config"); - let repo = SqliteRepositoryFactory::create(&config.database_url) - .await - .expect("failed to connect to database"); - - let repo = Arc::new(repo); - - let state = Arc::new(AppState { - song_commands: SongCommandDeps { repo: repo.clone() }, - song_queries: SongQueryDeps { - repo: repo.clone(), - search: repo.clone(), - }, - tab_parser: ParseTabDeps { - fetcher: Arc::new(UgTabFetcher::new()), - parser: Arc::new(UgHtmlParser), - }, - }); - - let cors = match config.cors_origins { - CorsOrigins::Any => CorsLayer::new() - .allow_origin(Any) - .allow_methods(Any) - .allow_headers(Any), - CorsOrigins::List(ref origins) => { - let parsed: Vec = origins - .iter() - .map(|o| { - o.parse() - .unwrap_or_else(|_| panic!("invalid CORS origin: {o}")) - }) - .collect(); - CorsLayer::new() - .allow_origin(parsed) - .allow_methods(Any) - .allow_headers(Any) - } - }; - - let app = Router::new() - .route("/tabs/parse", post(parse_tab)) - .route("/songs", post(create_song).get(list_songs)) - .route( - "/songs/{id}", - get(get_song).delete(delete_song).patch(update_song), - ) - .layer(cors) - .with_state(state); + let state = presentation::factory::wire(config.clone()).await; + let app = presentation::openapi::serve(presentation::routes::build_router(state)); let addr = config.bind_addr(); let listener = tokio::net::TcpListener::bind(&addr) diff --git a/crates/presentation/src/openapi.rs b/crates/presentation/src/openapi.rs new file mode 100644 index 0000000..5416e91 --- /dev/null +++ b/crates/presentation/src/openapi.rs @@ -0,0 +1,62 @@ +use axum::Router; +use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme}; +use utoipa::{Modify, OpenApi}; +use utoipa_scalar::{Scalar, Servable}; + +#[derive(OpenApi)] +#[openapi( + info( + title = "PocketChords API", + version = "0.1.0", + description = "Chord sheet management API" + ), + modifiers(&SecurityAddon), + paths( + crate::routes::songs::list_songs, + crate::routes::songs::create_song, + crate::routes::songs::get_song, + crate::routes::songs::update_song, + crate::routes::songs::delete_song, + crate::routes::tabs::parse_tab, + crate::routes::auth::register, + crate::routes::auth::login, + crate::routes::auth::refresh, + crate::routes::auth::logout, + ), + components(schemas( + api_types::ParseRequest, + api_types::ErrorResponse, + api_types::ListQuery, + api_types::UpdateSongRequest, + api_types::GetSongQuery, + api_types::LoginRequest, + api_types::LoginResponse, + api_types::RegisterRequest, + api_types::RefreshRequest, + api_types::RefreshResponse, + api_types::LogoutRequest, + )) +)] +struct ApiDoc; + +struct SecurityAddon; + +impl Modify for SecurityAddon { + fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { + if let Some(components) = openapi.components.as_mut() { + components.add_security_scheme( + "bearer", + SecurityScheme::Http( + HttpBuilder::new() + .scheme(HttpAuthScheme::Bearer) + .bearer_format("JWT") + .build(), + ), + ); + } + } +} + +pub fn serve(router: Router) -> Router { + router.merge(Scalar::with_url("/docs", ApiDoc::openapi())) +} diff --git a/crates/presentation/src/routes/auth.rs b/crates/presentation/src/routes/auth.rs new file mode 100644 index 0000000..1a068b0 --- /dev/null +++ b/crates/presentation/src/routes/auth.rs @@ -0,0 +1,106 @@ +use api_types::{ + ErrorResponse, LoginRequest, LoginResponse, LogoutRequest, RefreshRequest, RefreshResponse, + RegisterRequest, +}; +use application::auth::commands; +use application::auth::deps::{LoginDeps, LogoutDeps, RefreshDeps, RegisterDeps}; +use axum::{Json, extract::State, http::StatusCode}; + +use crate::errors::map_error; +use crate::state::AppState; + +#[utoipa::path(post, path = "/api/auth/register", request_body = RegisterRequest, responses((status = 201, description = "Registered")))] +pub async fn register( + State(state): State, + Json(body): Json, +) -> Result)> { + let deps = RegisterDeps { + user_repo: state.ctx.repos.user_repo.clone(), + password_hasher: state.ctx.services.password_hasher.clone(), + allow_registration: state.ctx.config.allow_registration, + }; + let cmd = commands::RegisterCommand { + email: body.email, + username: body.username, + password: body.password, + }; + + application::auth::register::execute(&deps, cmd) + .await + .map(|()| StatusCode::CREATED) + .map_err(map_error) +} + +#[utoipa::path(post, path = "/api/auth/login", request_body = LoginRequest, responses((status = 200, description = "Login successful", body = LoginResponse)))] +pub async fn login( + State(state): State, + Json(body): Json, +) -> Result, (StatusCode, Json)> { + let deps = LoginDeps { + user_repo: state.ctx.repos.user_repo.clone(), + password_hasher: state.ctx.services.password_hasher.clone(), + auth_service: state.ctx.services.auth.clone(), + refresh_repo: state.ctx.repos.refresh_repo.clone(), + refresh_ttl_seconds: state.ctx.config.refresh_ttl_seconds, + }; + let cmd = commands::LoginCommand { + email: body.email, + password: body.password, + }; + + application::auth::login::execute(&deps, cmd) + .await + .map(|result| { + Json(LoginResponse { + token: result.access_token, + refresh_token: result.refresh_token, + user_id: result.user_id.value().to_string(), + expires_at: result.expires_at, + }) + }) + .map_err(map_error) +} + +#[utoipa::path(post, path = "/api/auth/refresh", request_body = RefreshRequest, responses((status = 200, description = "Token refreshed", body = RefreshResponse)))] +pub async fn refresh( + State(state): State, + Json(body): Json, +) -> Result, (StatusCode, Json)> { + let deps = RefreshDeps { + auth_service: state.ctx.services.auth.clone(), + refresh_repo: state.ctx.repos.refresh_repo.clone(), + refresh_ttl_seconds: state.ctx.config.refresh_ttl_seconds, + }; + let cmd = commands::RefreshCommand { + refresh_token: body.refresh_token, + }; + + application::auth::refresh::execute(&deps, cmd) + .await + .map(|result| { + Json(RefreshResponse { + token: result.access_token, + refresh_token: result.refresh_token, + expires_at: result.expires_at, + }) + }) + .map_err(map_error) +} + +#[utoipa::path(post, path = "/api/auth/logout", request_body = LogoutRequest, responses((status = 204, description = "Logged out")))] +pub async fn logout( + State(state): State, + Json(body): Json, +) -> Result)> { + let deps = LogoutDeps { + refresh_repo: state.ctx.repos.refresh_repo.clone(), + }; + let cmd = commands::LogoutCommand { + refresh_token: body.refresh_token, + }; + + application::auth::logout::execute(&deps, cmd) + .await + .map(|()| StatusCode::NO_CONTENT) + .map_err(map_error) +} diff --git a/crates/presentation/src/routes/mod.rs b/crates/presentation/src/routes/mod.rs index 6876366..5bc6ff5 100644 --- a/crates/presentation/src/routes/mod.rs +++ b/crates/presentation/src/routes/mod.rs @@ -1,2 +1,64 @@ +pub mod auth; pub mod songs; pub mod tabs; + +use axum::{ + Router, + http::HeaderValue, + routing::{get, post}, +}; +use infra_wiring::CorsOrigins; +use tower_http::cors::{Any, CorsLayer}; +use tower_http::services::{ServeDir, ServeFile}; + +use crate::state::AppState; + +pub fn build_router(state: AppState) -> Router<()> { + let api = Router::new() + .route("/tabs/parse", post(tabs::parse_tab)) + .route("/songs", post(songs::create_song).get(songs::list_songs)) + .route( + "/songs/{id}", + get(songs::get_song) + .delete(songs::delete_song) + .patch(songs::update_song), + ) + .route("/auth/register", post(auth::register)) + .route("/auth/login", post(auth::login)) + .route("/auth/refresh", post(auth::refresh)) + .route("/auth/logout", post(auth::logout)); + + let cors = build_cors(&state.ctx.config.cors_origins); + + let spa_dir = &state.ctx.config.spa_dir; + let spa_index = format!("{}/index.html", spa_dir); + let spa_service = ServeDir::new(spa_dir).fallback(ServeFile::new(spa_index)); + + Router::new() + .nest("/api", api) + .fallback_service(spa_service) + .layer(cors) + .with_state(state) +} + +fn build_cors(origins: &CorsOrigins) -> CorsLayer { + match origins { + CorsOrigins::Any => CorsLayer::new() + .allow_origin(Any) + .allow_methods(Any) + .allow_headers(Any), + CorsOrigins::List(origins) => { + let parsed: Vec = origins + .iter() + .map(|o| { + o.parse() + .unwrap_or_else(|_| panic!("invalid CORS origin: {o}")) + }) + .collect(); + CorsLayer::new() + .allow_origin(parsed) + .allow_methods(Any) + .allow_headers(Any) + } + } +} diff --git a/crates/presentation/src/routes/songs.rs b/crates/presentation/src/routes/songs.rs index 9b231ee..e5c7017 100644 --- a/crates/presentation/src/routes/songs.rs +++ b/crates/presentation/src/routes/songs.rs @@ -1,54 +1,51 @@ use api_types::{ErrorResponse, GetSongQuery, ListQuery, ParseRequest, UpdateSongRequest}; use application::songs::commands::{DeleteSongCommand, SaveSongCommand, UpdateSongMetaCommand}; +use application::songs::deps::{SongCommandDeps, SongQueryDeps}; use application::songs::queries::{ListSongsQuery, SearchSongsQuery}; use application::tabs::commands::ParseTabCommand; +use application::tabs::deps::ParseTabDeps; use axum::{ Json, extract::{Path, Query, State}, http::StatusCode, }; -use domain::{ChordTransposer, DomainError, SortField, SortOrder}; -use std::sync::Arc; +use domain::{ChordTransposer, SortField, SortOrder}; use uuid::Uuid; -use crate::routes::tabs::AppState; +use crate::errors::map_error; +use crate::extractors::AuthenticatedUser; +use crate::state::AppState; +#[utoipa::path(post, path = "/api/songs", request_body = ParseRequest, responses((status = 200, description = "Song created"), (status = 401, description = "Unauthorized")), security(("bearer" = [])))] pub async fn create_song( - State(state): State>, + State(state): State, + _user: AuthenticatedUser, Json(body): Json, ) -> Result, (StatusCode, Json)> { + let tab_deps = ParseTabDeps { + fetcher: state.ctx.services.tab_fetcher.clone(), + parser: state.ctx.services.tab_parser.clone(), + }; let cmd = ParseTabCommand { source: body.source, html: body.html, }; - - let song = application::tabs::parse_tab::execute(&state.tab_parser, cmd) + let song = application::tabs::parse_tab::execute(&tab_deps, cmd) .await - .map_err(|e| { - ( - StatusCode::BAD_REQUEST, - Json(ErrorResponse { - error: e.to_string(), - }), - ) - })?; + .map_err(map_error)?; - let cmd = SaveSongCommand { song }; - application::songs::save_song::execute(&state.song_commands, cmd) + let deps = SongCommandDeps { + repo: state.ctx.repos.song_repo.clone(), + }; + application::songs::save_song::execute(&deps, SaveSongCommand { song }) .await .map(Json) - .map_err(|e| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ErrorResponse { - error: e.to_string(), - }), - ) - }) + .map_err(map_error) } +#[utoipa::path(get, path = "/api/songs", params(ListQuery), responses((status = 200, description = "List songs")))] pub async fn list_songs( - State(state): State>, + State(state): State, Query(params): Query, ) -> Result>, (StatusCode, Json)> { let sort = match params.sort.as_deref() { @@ -61,30 +58,32 @@ pub async fn list_songs( _ => SortOrder::Desc, }; - let result = if let Some(q) = params.q.filter(|s| !s.is_empty()) { - let query = SearchSongsQuery { - query: q, - sort, - order, - }; - application::songs::search_songs::execute(&state.song_queries, query).await - } else { - let query = ListSongsQuery { sort, order }; - application::songs::list_songs::execute(&state.song_queries, query).await + let deps = SongQueryDeps { + repo: state.ctx.repos.song_repo.clone(), + search: state.ctx.repos.song_search.clone(), }; - result.map(Json).map_err(|e| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ErrorResponse { - error: e.to_string(), - }), + let result = if let Some(q) = params.q.filter(|s| !s.is_empty()) { + application::songs::search_songs::execute( + &deps, + SearchSongsQuery { + query: q, + sort, + order, + }, ) - }) + .await + } else { + application::songs::list_songs::execute(&deps, ListSongsQuery { sort, order }).await + }; + + result.map(Json).map_err(map_error) } +#[utoipa::path(patch, path = "/api/songs/{id}", request_body = UpdateSongRequest, responses((status = 200, description = "Song updated"), (status = 401, description = "Unauthorized")), security(("bearer" = [])))] pub async fn update_song( - State(state): State>, + State(state): State, + _user: AuthenticatedUser, Path(id): Path, Json(body): Json, ) -> Result, (StatusCode, Json)> { @@ -97,6 +96,9 @@ pub async fn update_song( ) })?; + let deps = SongCommandDeps { + repo: state.ctx.repos.song_repo.clone(), + }; let cmd = UpdateSongMetaCommand { id: uuid, title: body.title, @@ -104,27 +106,15 @@ pub async fn update_song( original_key: body.original_key, }; - application::songs::update_meta::execute(&state.song_commands, cmd) + application::songs::update_meta::execute(&deps, cmd) .await .map(Json) - .map_err(|e| match e { - DomainError::NotFound => ( - StatusCode::NOT_FOUND, - Json(ErrorResponse { - error: "Not found".into(), - }), - ), - e => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ErrorResponse { - error: e.to_string(), - }), - ), - }) + .map_err(map_error) } +#[utoipa::path(get, path = "/api/songs/{id}", params(GetSongQuery), responses((status = 200, description = "Song details"), (status = 404, description = "Not found")))] pub async fn get_song( - State(state): State>, + State(state): State, Path(id): Path, Query(params): Query, ) -> Result, (StatusCode, Json)> { @@ -137,8 +127,12 @@ pub async fn get_song( ) })?; + let deps = SongQueryDeps { + repo: state.ctx.repos.song_repo.clone(), + search: state.ctx.repos.song_search.clone(), + }; let query = application::songs::queries::GetSongQuery { id: uuid }; - let song = match application::songs::get_song::execute(&state.song_queries, query).await { + let song = match application::songs::get_song::execute(&deps, query).await { Ok(Some(s)) => s, Ok(None) => { return Err(( @@ -148,14 +142,7 @@ pub async fn get_song( }), )); } - Err(e) => { - return Err(( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ErrorResponse { - error: e.to_string(), - }), - )); - } + Err(e) => return Err(map_error(e)), }; let song = if params.apply_capo.unwrap_or(false) { @@ -171,8 +158,10 @@ pub async fn get_song( Ok(Json(song)) } +#[utoipa::path(delete, path = "/api/songs/{id}", responses((status = 204, description = "Song deleted"), (status = 401, description = "Unauthorized")), security(("bearer" = [])))] pub async fn delete_song( - State(state): State>, + State(state): State, + _user: AuthenticatedUser, Path(id): Path, ) -> Result)> { let uuid = Uuid::parse_str(&id).map_err(|_| { @@ -184,20 +173,11 @@ pub async fn delete_song( ) })?; - let cmd = DeleteSongCommand { id: uuid }; - match application::songs::delete_song::execute(&state.song_commands, cmd).await { - Ok(()) => Ok(StatusCode::NO_CONTENT), - Err(DomainError::NotFound) => Err(( - StatusCode::NOT_FOUND, - Json(ErrorResponse { - error: "Not found".into(), - }), - )), - Err(e) => Err(( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ErrorResponse { - error: e.to_string(), - }), - )), - } + let deps = SongCommandDeps { + repo: state.ctx.repos.song_repo.clone(), + }; + application::songs::delete_song::execute(&deps, DeleteSongCommand { id: uuid }) + .await + .map(|()| StatusCode::NO_CONTENT) + .map_err(map_error) } diff --git a/crates/presentation/src/routes/tabs.rs b/crates/presentation/src/routes/tabs.rs index f2a2305..46cb16a 100644 --- a/crates/presentation/src/routes/tabs.rs +++ b/crates/presentation/src/routes/tabs.rs @@ -1,34 +1,27 @@ use api_types::{ErrorResponse, ParseRequest}; -use application::songs::deps::{SongCommandDeps, SongQueryDeps}; use application::tabs::commands::ParseTabCommand; use application::tabs::deps::ParseTabDeps; use axum::{Json, extract::State, http::StatusCode}; -use std::sync::Arc; -pub struct AppState { - pub song_commands: SongCommandDeps, - pub song_queries: SongQueryDeps, - pub tab_parser: ParseTabDeps, -} +use crate::errors::map_error; +use crate::state::AppState; +#[utoipa::path(post, path = "/api/tabs/parse", request_body = ParseRequest, responses((status = 200, description = "Parsed song")))] pub async fn parse_tab( - State(state): State>, + State(state): State, Json(body): Json, ) -> Result, (StatusCode, Json)> { + let deps = ParseTabDeps { + fetcher: state.ctx.services.tab_fetcher.clone(), + parser: state.ctx.services.tab_parser.clone(), + }; let cmd = ParseTabCommand { source: body.source, html: body.html, }; - application::tabs::parse_tab::execute(&state.tab_parser, cmd) + application::tabs::parse_tab::execute(&deps, cmd) .await .map(Json) - .map_err(|e| { - ( - StatusCode::BAD_REQUEST, - Json(ErrorResponse { - error: e.to_string(), - }), - ) - }) + .map_err(map_error) } diff --git a/crates/presentation/src/state.rs b/crates/presentation/src/state.rs new file mode 100644 index 0000000..3003eb3 --- /dev/null +++ b/crates/presentation/src/state.rs @@ -0,0 +1,6 @@ +use crate::context::AppContext; + +#[derive(Clone)] +pub struct AppState { + pub ctx: AppContext, +} diff --git a/docker-compose.yml b/docker-compose.yml index ce994d5..5ed11d6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,46 +1,28 @@ -# PocketChords – homeserver deployment template +# PocketChords – single-binary deployment # # Usage: # cp .env.compose .env.compose.local # fill in your values # docker compose --env-file .env.compose.local up -d --build -# -# VITE_API_URL is baked into the JS bundle at build time. -# Set it to the URL your BROWSER (and SSR server) will use to reach the API. -# On a LAN homeserver: http://192.168.x.x:8000 -# Behind a reverse proxy: https://pocketchords.example.com/api services: - api: + app: build: context: . dockerfile: Dockerfile restart: unless-stopped ports: - - "${API_PORT:-8000}:8000" + - "${PORT:-8000}:8000" environment: DATABASE_URL: sqlite:///app/data/pocket-chords.db HOST: 0.0.0.0 PORT: 8000 - # Comma-separated allowed origins, or * for any. - # Lock this down in production: https://pocketchords.yourdomain.com + JWT_SECRET: ${JWT_SECRET} + ALLOW_REGISTRATION: ${ALLOW_REGISTRATION:-false} CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-*} + SPA_DIR: /app/spa volumes: - - api-data:/app/data - - app: - build: - context: ./app - dockerfile: Dockerfile - args: - VITE_API_URL: ${VITE_API_URL:-http://localhost:8000} - restart: unless-stopped - ports: - - "${APP_PORT:-3000}:3000" - environment: - PORT: 3000 - depends_on: - - api + - app-data:/app/data volumes: - api-data: + app-data: driver: local