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
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
/target
|
/target
|
||||||
/app
|
/app/node_modules
|
||||||
|
/app/build
|
||||||
.superpowers/
|
.superpowers/
|
||||||
.git/
|
.git/
|
||||||
.claude/
|
.claude/
|
||||||
|
|||||||
290
Cargo.lock
generated
290
Cargo.lock
generated
@@ -17,6 +17,15 @@ version = "0.2.21"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
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]]
|
[[package]]
|
||||||
name = "anyhow"
|
name = "anyhow"
|
||||||
version = "1.0.102"
|
version = "1.0.102"
|
||||||
@@ -28,16 +37,30 @@ name = "api-types"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
|
"utoipa",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "application"
|
name = "application"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"chrono",
|
||||||
"domain",
|
"domain",
|
||||||
"uuid",
|
"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]]
|
[[package]]
|
||||||
name = "async-trait"
|
name = "async-trait"
|
||||||
version = "0.1.89"
|
version = "0.1.89"
|
||||||
@@ -64,6 +87,20 @@ version = "1.1.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "auth"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"argon2",
|
||||||
|
"async-trait",
|
||||||
|
"chrono",
|
||||||
|
"domain",
|
||||||
|
"jsonwebtoken",
|
||||||
|
"rand_core 0.6.4",
|
||||||
|
"serde",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "autocfg"
|
name = "autocfg"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -177,6 +214,15 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "blake2"
|
||||||
|
version = "0.10.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||||
|
dependencies = [
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
version = "0.10.4"
|
version = "0.10.4"
|
||||||
@@ -234,6 +280,20 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
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]]
|
[[package]]
|
||||||
name = "cmake"
|
name = "cmake"
|
||||||
version = "0.1.58"
|
version = "0.1.58"
|
||||||
@@ -377,6 +437,12 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "deranged"
|
||||||
|
version = "0.5.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "derive_more"
|
name = "derive_more"
|
||||||
version = "0.99.20"
|
version = "0.99.20"
|
||||||
@@ -416,6 +482,8 @@ name = "domain"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
"chrono",
|
||||||
|
"email_address",
|
||||||
"serde",
|
"serde",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"uuid",
|
"uuid",
|
||||||
@@ -463,6 +531,15 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "email_address"
|
||||||
|
version = "0.2.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "encoding_rs"
|
name = "encoding_rs"
|
||||||
version = "0.8.35"
|
version = "0.8.35"
|
||||||
@@ -913,6 +990,30 @@ dependencies = [
|
|||||||
"windows-registry",
|
"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]]
|
[[package]]
|
||||||
name = "icu_collections"
|
name = "icu_collections"
|
||||||
version = "2.2.0"
|
version = "2.2.0"
|
||||||
@@ -1126,6 +1227,21 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"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]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -1307,6 +1423,16 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "num-bigint-dig"
|
name = "num-bigint-dig"
|
||||||
version = "0.8.6"
|
version = "0.8.6"
|
||||||
@@ -1323,6 +1449,12 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-conv"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-integer"
|
name = "num-integer"
|
||||||
version = "0.1.46"
|
version = "0.1.46"
|
||||||
@@ -1394,6 +1526,27 @@ dependencies = [
|
|||||||
"windows-link",
|
"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]]
|
[[package]]
|
||||||
name = "pem-rfc7468"
|
name = "pem-rfc7468"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
@@ -1509,6 +1662,12 @@ dependencies = [
|
|||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "powerfmt"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ppv-lite86"
|
name = "ppv-lite86"
|
||||||
version = "0.2.21"
|
version = "0.2.21"
|
||||||
@@ -1530,15 +1689,20 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"api-types",
|
"api-types",
|
||||||
"application",
|
"application",
|
||||||
|
"async-trait",
|
||||||
|
"auth",
|
||||||
"axum",
|
"axum",
|
||||||
"domain",
|
"domain",
|
||||||
"infra-wiring",
|
"infra-wiring",
|
||||||
|
"serde",
|
||||||
"sqlite",
|
"sqlite",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"ug-parser",
|
"ug-parser",
|
||||||
|
"utoipa",
|
||||||
|
"utoipa-scalar",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1715,6 +1879,18 @@ dependencies = [
|
|||||||
"bitflags",
|
"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]]
|
[[package]]
|
||||||
name = "regex-automata"
|
name = "regex-automata"
|
||||||
version = "0.4.14"
|
version = "0.4.14"
|
||||||
@@ -2117,6 +2293,18 @@ dependencies = [
|
|||||||
"rand_core 0.6.4",
|
"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]]
|
[[package]]
|
||||||
name = "siphasher"
|
name = "siphasher"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
@@ -2172,6 +2360,7 @@ name = "sqlite"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
"chrono",
|
||||||
"domain",
|
"domain",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
@@ -2532,6 +2721,36 @@ dependencies = [
|
|||||||
"cfg-if",
|
"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]]
|
[[package]]
|
||||||
name = "tinystr"
|
name = "tinystr"
|
||||||
version = "0.8.3"
|
version = "0.8.3"
|
||||||
@@ -2835,6 +3054,42 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
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]]
|
[[package]]
|
||||||
name = "uuid"
|
name = "uuid"
|
||||||
version = "1.23.0"
|
version = "1.23.0"
|
||||||
@@ -3069,6 +3324,41 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "windows-link"
|
name = "windows-link"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
members = [
|
members = [
|
||||||
|
"crates/adapters/auth",
|
||||||
"crates/adapters/sqlite",
|
"crates/adapters/sqlite",
|
||||||
"crates/adapters/ug-parser",
|
"crates/adapters/ug-parser",
|
||||||
"crates/api-types",
|
"crates/api-types",
|
||||||
@@ -30,6 +31,7 @@ domain = { path = "crates/domain" }
|
|||||||
application = { path = "crates/application" }
|
application = { path = "crates/application" }
|
||||||
api-types = { path = "crates/api-types" }
|
api-types = { path = "crates/api-types" }
|
||||||
infra-wiring = { path = "crates/infra-wiring" }
|
infra-wiring = { path = "crates/infra-wiring" }
|
||||||
|
auth = { path = "crates/adapters/auth" }
|
||||||
sqlite = { path = "crates/adapters/sqlite" }
|
sqlite = { path = "crates/adapters/sqlite" }
|
||||||
ug-parser = { path = "crates/adapters/ug-parser" }
|
ug-parser = { path = "crates/adapters/ug-parser" }
|
||||||
|
|
||||||
|
|||||||
20
Dockerfile
20
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
|
WORKDIR /app
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Build the release binary
|
|
||||||
RUN cargo build --release -p presentation
|
RUN cargo build --release -p presentation
|
||||||
|
|
||||||
FROM debian:trixie-slim
|
FROM debian:trixie-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install OpenSSL, CA certs
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
libssl3 \
|
libssl3 \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
libsqlite3-0 \
|
libsqlite3-0 \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& 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
|
RUN mkdir -p /app/data
|
||||||
|
|
||||||
ENV DATABASE_URL=sqlite:///app/data/pocket-chords.db
|
ENV DATABASE_URL=sqlite:///app/data/pocket-chords.db
|
||||||
|
ENV SPA_DIR=/app/spa
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
|
|||||||
@@ -3,5 +3,5 @@ import type { Config } from "@react-router/dev/config";
|
|||||||
export default {
|
export default {
|
||||||
// Config options...
|
// Config options...
|
||||||
// Server-side render by default, to enable SPA mode set this to `false`
|
// Server-side render by default, to enable SPA mode set this to `false`
|
||||||
ssr: true,
|
ssr: false,
|
||||||
} satisfies Config;
|
} satisfies Config;
|
||||||
|
|||||||
14
crates/adapters/auth/Cargo.toml
Normal file
14
crates/adapters/auth/Cargo.toml
Normal file
@@ -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 }
|
||||||
100
crates/adapters/auth/src/lib.rs
Normal file
100
crates/adapters/auth/src/lib.rs
Normal file
@@ -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<GeneratedToken, DomainError> {
|
||||||
|
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<UserId, DomainError> {
|
||||||
|
let data =
|
||||||
|
jsonwebtoken::decode::<Claims>(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<domain::value_objects::PasswordHash, DomainError> {
|
||||||
|
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<bool, DomainError> {
|
||||||
|
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<dyn domain::ports::AuthService>,
|
||||||
|
Arc<dyn domain::ports::PasswordHasher>,
|
||||||
|
) {
|
||||||
|
(
|
||||||
|
Arc::new(JwtAuthService::new(secret, ttl_seconds)),
|
||||||
|
Arc::new(Argon2PasswordHasher),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -8,4 +8,5 @@ sqlx = { workspace = true }
|
|||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
domain = { workspace = true }
|
domain = { workspace = true }
|
||||||
|
|||||||
7
crates/adapters/sqlite/migrations/002_users.sql
Normal file
7
crates/adapters/sqlite/migrations/002_users.sql
Normal file
@@ -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
|
||||||
|
);
|
||||||
11
crates/adapters/sqlite/migrations/003_refresh_sessions.sql
Normal file
11
crates/adapters/sqlite/migrations/003_refresh_sessions.sql
Normal file
@@ -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);
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
|
mod refresh_sessions;
|
||||||
pub mod repository;
|
pub mod repository;
|
||||||
mod row;
|
mod row;
|
||||||
mod search;
|
mod search;
|
||||||
|
mod users;
|
||||||
|
|
||||||
pub use repository::{SqliteRepositoryFactory, SqliteSongRepository};
|
pub use repository::{SqliteRepositoryFactory, SqliteSongRepository};
|
||||||
|
|||||||
96
crates/adapters/sqlite/src/refresh_sessions.rs
Normal file
96
crates/adapters/sqlite/src/refresh_sessions.rs
Normal file
@@ -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<RefreshSession, DomainError> {
|
||||||
|
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<Option<RefreshSession>, 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<u64, DomainError> {
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
81
crates/adapters/sqlite/src/users.rs
Normal file
81
crates/adapters/sqlite/src/users.rs
Normal file
@@ -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<User, DomainError> {
|
||||||
|
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<Option<User>, 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<Option<User>, 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<Option<User>, 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,3 +5,4 @@ edition = "2024"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
|
utoipa = { version = "5", features = ["axum_extras"] }
|
||||||
|
|||||||
@@ -1,31 +1,70 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use utoipa::{IntoParams, ToSchema};
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize, ToSchema)]
|
||||||
pub struct ParseRequest {
|
pub struct ParseRequest {
|
||||||
pub source: Option<String>,
|
pub source: Option<String>,
|
||||||
pub html: Option<String>,
|
pub html: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize, ToSchema)]
|
||||||
pub struct ErrorResponse {
|
pub struct ErrorResponse {
|
||||||
pub error: String,
|
pub error: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize, ToSchema, IntoParams)]
|
||||||
pub struct ListQuery {
|
pub struct ListQuery {
|
||||||
pub q: Option<String>,
|
pub q: Option<String>,
|
||||||
pub sort: Option<String>,
|
pub sort: Option<String>,
|
||||||
pub order: Option<String>,
|
pub order: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize, ToSchema)]
|
||||||
pub struct UpdateSongRequest {
|
pub struct UpdateSongRequest {
|
||||||
pub title: Option<String>,
|
pub title: Option<String>,
|
||||||
pub artist: Option<String>,
|
pub artist: Option<String>,
|
||||||
pub original_key: Option<String>,
|
pub original_key: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize, ToSchema, IntoParams)]
|
||||||
pub struct GetSongQuery {
|
pub struct GetSongQuery {
|
||||||
pub apply_capo: Option<bool>,
|
pub apply_capo: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,4 +5,5 @@ edition = "2024"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
domain = { workspace = true }
|
domain = { workspace = true }
|
||||||
|
|||||||
18
crates/application/src/auth/commands.rs
Normal file
18
crates/application/src/auth/commands.rs
Normal file
@@ -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,
|
||||||
|
}
|
||||||
27
crates/application/src/auth/deps.rs
Normal file
27
crates/application/src/auth/deps.rs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use domain::ports::{AuthService, PasswordHasher, RefreshSessionRepository, UserRepository};
|
||||||
|
|
||||||
|
pub struct RegisterDeps {
|
||||||
|
pub user_repo: Arc<dyn UserRepository>,
|
||||||
|
pub password_hasher: Arc<dyn PasswordHasher>,
|
||||||
|
pub allow_registration: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct LoginDeps {
|
||||||
|
pub user_repo: Arc<dyn UserRepository>,
|
||||||
|
pub password_hasher: Arc<dyn PasswordHasher>,
|
||||||
|
pub auth_service: Arc<dyn AuthService>,
|
||||||
|
pub refresh_repo: Arc<dyn RefreshSessionRepository>,
|
||||||
|
pub refresh_ttl_seconds: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RefreshDeps {
|
||||||
|
pub auth_service: Arc<dyn AuthService>,
|
||||||
|
pub refresh_repo: Arc<dyn RefreshSessionRepository>,
|
||||||
|
pub refresh_ttl_seconds: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct LogoutDeps {
|
||||||
|
pub refresh_repo: Arc<dyn RefreshSessionRepository>,
|
||||||
|
}
|
||||||
53
crates/application/src/auth/login.rs
Normal file
53
crates/application/src/auth/login.rs
Normal file
@@ -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<LoginResult, DomainError> {
|
||||||
|
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(),
|
||||||
|
})
|
||||||
|
}
|
||||||
8
crates/application/src/auth/logout.rs
Normal file
8
crates/application/src/auth/logout.rs
Normal file
@@ -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
|
||||||
|
}
|
||||||
6
crates/application/src/auth/mod.rs
Normal file
6
crates/application/src/auth/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
pub mod commands;
|
||||||
|
pub mod deps;
|
||||||
|
pub mod login;
|
||||||
|
pub mod logout;
|
||||||
|
pub mod refresh;
|
||||||
|
pub mod register;
|
||||||
50
crates/application/src/auth/refresh.rs
Normal file
50
crates/application/src/auth/refresh.rs
Normal file
@@ -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<RefreshResult, DomainError> {
|
||||||
|
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(),
|
||||||
|
})
|
||||||
|
}
|
||||||
34
crates/application/src/auth/register.rs
Normal file
34
crates/application/src/auth/register.rs
Normal file
@@ -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(())
|
||||||
|
}
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
|
pub mod auth;
|
||||||
pub mod songs;
|
pub mod songs;
|
||||||
pub mod tabs;
|
pub mod tabs;
|
||||||
|
|||||||
@@ -8,3 +8,5 @@ thiserror = { workspace = true }
|
|||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
email_address = "0.2"
|
||||||
|
|||||||
@@ -10,4 +10,10 @@ pub enum DomainError {
|
|||||||
|
|
||||||
#[error("Infrastructure failure: {0}")]
|
#[error("Infrastructure failure: {0}")]
|
||||||
InfrastructureError(String),
|
InfrastructureError(String),
|
||||||
|
|
||||||
|
#[error("Unauthorized: {0}")]
|
||||||
|
Unauthorized(String),
|
||||||
|
|
||||||
|
#[error("Forbidden: {0}")]
|
||||||
|
Forbidden(String),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ pub mod value_objects;
|
|||||||
|
|
||||||
pub use errors::DomainError;
|
pub use errors::DomainError;
|
||||||
pub use models::{
|
pub use models::{
|
||||||
ChordPosition, LyricLine, Section, SectionKind, Song, SongMeta, SongSummary, StoredSong,
|
ChordPosition, GeneratedToken, LyricLine, RefreshSession, Section, SectionKind, Song, SongMeta,
|
||||||
song_preview_chords,
|
SongSummary, StoredSong, User, song_preview_chords,
|
||||||
};
|
};
|
||||||
pub use ports::{
|
pub use ports::{
|
||||||
FetchError, ParseError, SongRepositoryPort, SongSearchPort, TabFetcherPort, TabParserPort,
|
AuthService, FetchError, ParseError, PasswordHasher, RefreshSessionRepository,
|
||||||
TabSource,
|
SongRepositoryPort, SongSearchPort, TabFetcherPort, TabParserPort, TabSource, UserRepository,
|
||||||
};
|
};
|
||||||
pub use services::{ChordTransposer, TransposeError};
|
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,
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
pub mod refresh_session;
|
||||||
pub mod song;
|
pub mod song;
|
||||||
|
pub mod user;
|
||||||
|
|
||||||
|
pub use refresh_session::*;
|
||||||
pub use song::*;
|
pub use song::*;
|
||||||
|
pub use user::*;
|
||||||
|
|||||||
17
crates/domain/src/models/refresh_session.rs
Normal file
17
crates/domain/src/models/refresh_session.rs
Normal file
@@ -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<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RefreshSession {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub user_id: UserId,
|
||||||
|
pub token: String,
|
||||||
|
pub expires_at: DateTime<Utc>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
50
crates/domain/src/models/user.rs
Normal file
50
crates/domain/src/models/user.rs
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
34
crates/domain/src/ports/auth.rs
Normal file
34
crates/domain/src/ports/auth.rs
Normal file
@@ -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<GeneratedToken, DomainError>;
|
||||||
|
async fn validate_token(&self, token: &str) -> Result<UserId, DomainError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait PasswordHasher: Send + Sync {
|
||||||
|
async fn hash(&self, plain_password: &str) -> Result<PasswordHash, DomainError>;
|
||||||
|
async fn verify(&self, plain_password: &str, hash: &PasswordHash) -> Result<bool, DomainError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait UserRepository: Send + Sync {
|
||||||
|
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError>;
|
||||||
|
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError>;
|
||||||
|
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, 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<Option<RefreshSession>, 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<u64, DomainError>;
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
|
pub mod auth;
|
||||||
pub mod repository;
|
pub mod repository;
|
||||||
pub mod tab_source;
|
pub mod tab_source;
|
||||||
|
|
||||||
|
pub use auth::*;
|
||||||
pub use repository::*;
|
pub use repository::*;
|
||||||
pub use tab_source::*;
|
pub use tab_source::*;
|
||||||
|
|||||||
18
crates/domain/src/value_objects/ids.rs
Normal file
18
crates/domain/src/value_objects/ids.rs
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
mod chord;
|
mod chord;
|
||||||
|
mod ids;
|
||||||
mod note;
|
mod note;
|
||||||
mod sorting;
|
mod sorting;
|
||||||
|
mod user;
|
||||||
|
|
||||||
pub use chord::*;
|
pub use chord::*;
|
||||||
|
pub use ids::*;
|
||||||
pub use note::*;
|
pub use note::*;
|
||||||
pub use sorting::*;
|
pub use sorting::*;
|
||||||
|
pub use user::*;
|
||||||
|
|||||||
107
crates/domain/src/value_objects/user.rs
Normal file
107
crates/domain/src/value_objects/user.rs
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
use crate::errors::DomainError;
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq, Eq)]
|
||||||
|
pub struct Email(String);
|
||||||
|
|
||||||
|
impl Email {
|
||||||
|
pub fn new(email: &str) -> Result<Self, DomainError> {
|
||||||
|
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<Self, DomainError> {
|
||||||
|
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<Self, DomainError> {
|
||||||
|
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<Self, DomainError> {
|
||||||
|
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])")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,19 @@
|
|||||||
use std::env;
|
use std::env;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct AppConfig {
|
pub struct AppConfig {
|
||||||
pub database_url: String,
|
pub database_url: String,
|
||||||
pub host: String,
|
pub host: String,
|
||||||
pub port: u16,
|
pub port: u16,
|
||||||
pub cors_origins: CorsOrigins,
|
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 {
|
pub enum CorsOrigins {
|
||||||
Any,
|
Any,
|
||||||
List(Vec<String>),
|
List(Vec<String>),
|
||||||
@@ -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 {
|
Self {
|
||||||
database_url,
|
database_url,
|
||||||
host,
|
host,
|
||||||
port,
|
port,
|
||||||
cors_origins,
|
cors_origins,
|
||||||
|
jwt_secret,
|
||||||
|
jwt_ttl_seconds,
|
||||||
|
refresh_ttl_seconds,
|
||||||
|
allow_registration,
|
||||||
|
spa_dir,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,10 +9,15 @@ tokio = { workspace = true }
|
|||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
tracing-subscriber = { workspace = true }
|
tracing-subscriber = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
|
async-trait = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
tower-http = { version = "0.6.8", features = ["cors", "fs", "trace", "tracing"] }
|
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 }
|
api-types = { workspace = true }
|
||||||
application = { workspace = true }
|
application = { workspace = true }
|
||||||
|
auth = { workspace = true }
|
||||||
domain = { workspace = true }
|
domain = { workspace = true }
|
||||||
infra-wiring = { workspace = true }
|
infra-wiring = { workspace = true }
|
||||||
sqlite = { workspace = true }
|
sqlite = { workspace = true }
|
||||||
|
|||||||
30
crates/presentation/src/context.rs
Normal file
30
crates/presentation/src/context.rs
Normal file
@@ -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<dyn SongRepositoryPort>,
|
||||||
|
pub song_search: Arc<dyn SongSearchPort>,
|
||||||
|
pub user_repo: Arc<dyn UserRepository>,
|
||||||
|
pub refresh_repo: Arc<dyn RefreshSessionRepository>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Services {
|
||||||
|
pub auth: Arc<dyn AuthService>,
|
||||||
|
pub password_hasher: Arc<dyn PasswordHasher>,
|
||||||
|
pub tab_fetcher: Arc<dyn TabFetcherPort>,
|
||||||
|
pub tab_parser: Arc<dyn TabParserPort>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppContext {
|
||||||
|
pub repos: Repositories,
|
||||||
|
pub services: Services,
|
||||||
|
pub config: AppConfig,
|
||||||
|
}
|
||||||
20
crates/presentation/src/errors.rs
Normal file
20
crates/presentation/src/errors.rs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
use api_types::ErrorResponse;
|
||||||
|
use axum::{Json, http::StatusCode};
|
||||||
|
use domain::DomainError;
|
||||||
|
|
||||||
|
pub fn map_error(e: DomainError) -> (StatusCode, Json<ErrorResponse>) {
|
||||||
|
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 }))
|
||||||
|
}
|
||||||
40
crates/presentation/src/extractors.rs
Normal file
40
crates/presentation/src/extractors.rs
Normal file
@@ -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<AppState> for AuthenticatedUser {
|
||||||
|
type Rejection = StatusCode;
|
||||||
|
|
||||||
|
async fn from_request_parts(
|
||||||
|
parts: &mut Parts,
|
||||||
|
state: &AppState,
|
||||||
|
) -> Result<Self, Self::Rejection> {
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
36
crates/presentation/src/factory.rs
Normal file
36
crates/presentation/src/factory.rs
Normal file
@@ -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 }
|
||||||
|
}
|
||||||
7
crates/presentation/src/lib.rs
Normal file
7
crates/presentation/src/lib.rs
Normal file
@@ -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;
|
||||||
@@ -1,20 +1,4 @@
|
|||||||
mod routes;
|
use infra_wiring::AppConfig;
|
||||||
|
|
||||||
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};
|
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
@@ -23,53 +7,8 @@ async fn main() {
|
|||||||
let config = AppConfig::from_env();
|
let config = AppConfig::from_env();
|
||||||
tracing::info!(?config, "starting with config");
|
tracing::info!(?config, "starting with config");
|
||||||
|
|
||||||
let repo = SqliteRepositoryFactory::create(&config.database_url)
|
let state = presentation::factory::wire(config.clone()).await;
|
||||||
.await
|
let app = presentation::openapi::serve(presentation::routes::build_router(state));
|
||||||
.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<HeaderValue> = 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 addr = config.bind_addr();
|
let addr = config.bind_addr();
|
||||||
let listener = tokio::net::TcpListener::bind(&addr)
|
let listener = tokio::net::TcpListener::bind(&addr)
|
||||||
|
|||||||
62
crates/presentation/src/openapi.rs
Normal file
62
crates/presentation/src/openapi.rs
Normal file
@@ -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()))
|
||||||
|
}
|
||||||
106
crates/presentation/src/routes/auth.rs
Normal file
106
crates/presentation/src/routes/auth.rs
Normal file
@@ -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<AppState>,
|
||||||
|
Json(body): Json<RegisterRequest>,
|
||||||
|
) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
|
||||||
|
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<AppState>,
|
||||||
|
Json(body): Json<LoginRequest>,
|
||||||
|
) -> Result<Json<LoginResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
|
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<AppState>,
|
||||||
|
Json(body): Json<RefreshRequest>,
|
||||||
|
) -> Result<Json<RefreshResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
|
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<AppState>,
|
||||||
|
Json(body): Json<LogoutRequest>,
|
||||||
|
) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -1,2 +1,64 @@
|
|||||||
|
pub mod auth;
|
||||||
pub mod songs;
|
pub mod songs;
|
||||||
pub mod tabs;
|
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<HeaderValue> = 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,54 +1,51 @@
|
|||||||
use api_types::{ErrorResponse, GetSongQuery, ListQuery, ParseRequest, UpdateSongRequest};
|
use api_types::{ErrorResponse, GetSongQuery, ListQuery, ParseRequest, UpdateSongRequest};
|
||||||
use application::songs::commands::{DeleteSongCommand, SaveSongCommand, UpdateSongMetaCommand};
|
use application::songs::commands::{DeleteSongCommand, SaveSongCommand, UpdateSongMetaCommand};
|
||||||
|
use application::songs::deps::{SongCommandDeps, SongQueryDeps};
|
||||||
use application::songs::queries::{ListSongsQuery, SearchSongsQuery};
|
use application::songs::queries::{ListSongsQuery, SearchSongsQuery};
|
||||||
use application::tabs::commands::ParseTabCommand;
|
use application::tabs::commands::ParseTabCommand;
|
||||||
|
use application::tabs::deps::ParseTabDeps;
|
||||||
use axum::{
|
use axum::{
|
||||||
Json,
|
Json,
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
};
|
};
|
||||||
use domain::{ChordTransposer, DomainError, SortField, SortOrder};
|
use domain::{ChordTransposer, SortField, SortOrder};
|
||||||
use std::sync::Arc;
|
|
||||||
use uuid::Uuid;
|
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(
|
pub async fn create_song(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<AppState>,
|
||||||
|
_user: AuthenticatedUser,
|
||||||
Json(body): Json<ParseRequest>,
|
Json(body): Json<ParseRequest>,
|
||||||
) -> Result<Json<domain::StoredSong>, (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<Json<domain::StoredSong>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
|
let tab_deps = ParseTabDeps {
|
||||||
|
fetcher: state.ctx.services.tab_fetcher.clone(),
|
||||||
|
parser: state.ctx.services.tab_parser.clone(),
|
||||||
|
};
|
||||||
let cmd = ParseTabCommand {
|
let cmd = ParseTabCommand {
|
||||||
source: body.source,
|
source: body.source,
|
||||||
html: body.html,
|
html: body.html,
|
||||||
};
|
};
|
||||||
|
let song = application::tabs::parse_tab::execute(&tab_deps, cmd)
|
||||||
let song = application::tabs::parse_tab::execute(&state.tab_parser, cmd)
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(map_error)?;
|
||||||
(
|
|
||||||
StatusCode::BAD_REQUEST,
|
|
||||||
Json(ErrorResponse {
|
|
||||||
error: e.to_string(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let cmd = SaveSongCommand { song };
|
let deps = SongCommandDeps {
|
||||||
application::songs::save_song::execute(&state.song_commands, cmd)
|
repo: state.ctx.repos.song_repo.clone(),
|
||||||
|
};
|
||||||
|
application::songs::save_song::execute(&deps, SaveSongCommand { song })
|
||||||
.await
|
.await
|
||||||
.map(Json)
|
.map(Json)
|
||||||
.map_err(|e| {
|
.map_err(map_error)
|
||||||
(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
Json(ErrorResponse {
|
|
||||||
error: e.to_string(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(get, path = "/api/songs", params(ListQuery), responses((status = 200, description = "List songs")))]
|
||||||
pub async fn list_songs(
|
pub async fn list_songs(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<AppState>,
|
||||||
Query(params): Query<ListQuery>,
|
Query(params): Query<ListQuery>,
|
||||||
) -> Result<Json<Vec<domain::SongSummary>>, (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<Json<Vec<domain::SongSummary>>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
let sort = match params.sort.as_deref() {
|
let sort = match params.sort.as_deref() {
|
||||||
@@ -61,30 +58,32 @@ pub async fn list_songs(
|
|||||||
_ => SortOrder::Desc,
|
_ => SortOrder::Desc,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let deps = SongQueryDeps {
|
||||||
|
repo: state.ctx.repos.song_repo.clone(),
|
||||||
|
search: state.ctx.repos.song_search.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
let result = if let Some(q) = params.q.filter(|s| !s.is_empty()) {
|
let result = if let Some(q) = params.q.filter(|s| !s.is_empty()) {
|
||||||
let query = SearchSongsQuery {
|
application::songs::search_songs::execute(
|
||||||
|
&deps,
|
||||||
|
SearchSongsQuery {
|
||||||
query: q,
|
query: q,
|
||||||
sort,
|
sort,
|
||||||
order,
|
order,
|
||||||
};
|
},
|
||||||
application::songs::search_songs::execute(&state.song_queries, query).await
|
)
|
||||||
|
.await
|
||||||
} else {
|
} else {
|
||||||
let query = ListSongsQuery { sort, order };
|
application::songs::list_songs::execute(&deps, ListSongsQuery { sort, order }).await
|
||||||
application::songs::list_songs::execute(&state.song_queries, query).await
|
|
||||||
};
|
};
|
||||||
|
|
||||||
result.map(Json).map_err(|e| {
|
result.map(Json).map_err(map_error)
|
||||||
(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
Json(ErrorResponse {
|
|
||||||
error: e.to_string(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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(
|
pub async fn update_song(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<AppState>,
|
||||||
|
_user: AuthenticatedUser,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
Json(body): Json<UpdateSongRequest>,
|
Json(body): Json<UpdateSongRequest>,
|
||||||
) -> Result<Json<domain::SongSummary>, (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<Json<domain::SongSummary>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
@@ -97,6 +96,9 @@ pub async fn update_song(
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
let deps = SongCommandDeps {
|
||||||
|
repo: state.ctx.repos.song_repo.clone(),
|
||||||
|
};
|
||||||
let cmd = UpdateSongMetaCommand {
|
let cmd = UpdateSongMetaCommand {
|
||||||
id: uuid,
|
id: uuid,
|
||||||
title: body.title,
|
title: body.title,
|
||||||
@@ -104,27 +106,15 @@ pub async fn update_song(
|
|||||||
original_key: body.original_key,
|
original_key: body.original_key,
|
||||||
};
|
};
|
||||||
|
|
||||||
application::songs::update_meta::execute(&state.song_commands, cmd)
|
application::songs::update_meta::execute(&deps, cmd)
|
||||||
.await
|
.await
|
||||||
.map(Json)
|
.map(Json)
|
||||||
.map_err(|e| match e {
|
.map_err(map_error)
|
||||||
DomainError::NotFound => (
|
|
||||||
StatusCode::NOT_FOUND,
|
|
||||||
Json(ErrorResponse {
|
|
||||||
error: "Not found".into(),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
e => (
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
Json(ErrorResponse {
|
|
||||||
error: e.to_string(),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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(
|
pub async fn get_song(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
Query(params): Query<GetSongQuery>,
|
Query(params): Query<GetSongQuery>,
|
||||||
) -> Result<Json<domain::Song>, (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<Json<domain::Song>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
@@ -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 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(Some(s)) => s,
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
return Err((
|
return Err((
|
||||||
@@ -148,14 +142,7 @@ pub async fn get_song(
|
|||||||
}),
|
}),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => return Err(map_error(e)),
|
||||||
return Err((
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
Json(ErrorResponse {
|
|
||||||
error: e.to_string(),
|
|
||||||
}),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let song = if params.apply_capo.unwrap_or(false) {
|
let song = if params.apply_capo.unwrap_or(false) {
|
||||||
@@ -171,8 +158,10 @@ pub async fn get_song(
|
|||||||
Ok(Json(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(
|
pub async fn delete_song(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<AppState>,
|
||||||
|
_user: AuthenticatedUser,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
|
||||||
let uuid = Uuid::parse_str(&id).map_err(|_| {
|
let uuid = Uuid::parse_str(&id).map_err(|_| {
|
||||||
@@ -184,20 +173,11 @@ pub async fn delete_song(
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let cmd = DeleteSongCommand { id: uuid };
|
let deps = SongCommandDeps {
|
||||||
match application::songs::delete_song::execute(&state.song_commands, cmd).await {
|
repo: state.ctx.repos.song_repo.clone(),
|
||||||
Ok(()) => Ok(StatusCode::NO_CONTENT),
|
};
|
||||||
Err(DomainError::NotFound) => Err((
|
application::songs::delete_song::execute(&deps, DeleteSongCommand { id: uuid })
|
||||||
StatusCode::NOT_FOUND,
|
.await
|
||||||
Json(ErrorResponse {
|
.map(|()| StatusCode::NO_CONTENT)
|
||||||
error: "Not found".into(),
|
.map_err(map_error)
|
||||||
}),
|
|
||||||
)),
|
|
||||||
Err(e) => Err((
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
Json(ErrorResponse {
|
|
||||||
error: e.to_string(),
|
|
||||||
}),
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,34 +1,27 @@
|
|||||||
use api_types::{ErrorResponse, ParseRequest};
|
use api_types::{ErrorResponse, ParseRequest};
|
||||||
use application::songs::deps::{SongCommandDeps, SongQueryDeps};
|
|
||||||
use application::tabs::commands::ParseTabCommand;
|
use application::tabs::commands::ParseTabCommand;
|
||||||
use application::tabs::deps::ParseTabDeps;
|
use application::tabs::deps::ParseTabDeps;
|
||||||
use axum::{Json, extract::State, http::StatusCode};
|
use axum::{Json, extract::State, http::StatusCode};
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
pub struct AppState {
|
use crate::errors::map_error;
|
||||||
pub song_commands: SongCommandDeps,
|
use crate::state::AppState;
|
||||||
pub song_queries: SongQueryDeps,
|
|
||||||
pub tab_parser: ParseTabDeps,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
#[utoipa::path(post, path = "/api/tabs/parse", request_body = ParseRequest, responses((status = 200, description = "Parsed song")))]
|
||||||
pub async fn parse_tab(
|
pub async fn parse_tab(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<AppState>,
|
||||||
Json(body): Json<ParseRequest>,
|
Json(body): Json<ParseRequest>,
|
||||||
) -> Result<Json<domain::models::Song>, (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<Json<domain::models::Song>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
|
let deps = ParseTabDeps {
|
||||||
|
fetcher: state.ctx.services.tab_fetcher.clone(),
|
||||||
|
parser: state.ctx.services.tab_parser.clone(),
|
||||||
|
};
|
||||||
let cmd = ParseTabCommand {
|
let cmd = ParseTabCommand {
|
||||||
source: body.source,
|
source: body.source,
|
||||||
html: body.html,
|
html: body.html,
|
||||||
};
|
};
|
||||||
|
|
||||||
application::tabs::parse_tab::execute(&state.tab_parser, cmd)
|
application::tabs::parse_tab::execute(&deps, cmd)
|
||||||
.await
|
.await
|
||||||
.map(Json)
|
.map(Json)
|
||||||
.map_err(|e| {
|
.map_err(map_error)
|
||||||
(
|
|
||||||
StatusCode::BAD_REQUEST,
|
|
||||||
Json(ErrorResponse {
|
|
||||||
error: e.to_string(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
6
crates/presentation/src/state.rs
Normal file
6
crates/presentation/src/state.rs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
use crate::context::AppContext;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub ctx: AppContext,
|
||||||
|
}
|
||||||
@@ -1,46 +1,28 @@
|
|||||||
# PocketChords – homeserver deployment template
|
# PocketChords – single-binary deployment
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# cp .env.compose .env.compose.local # fill in your values
|
# cp .env.compose .env.compose.local # fill in your values
|
||||||
# docker compose --env-file .env.compose.local up -d --build
|
# 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:
|
services:
|
||||||
api:
|
app:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "${API_PORT:-8000}:8000"
|
- "${PORT:-8000}:8000"
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: sqlite:///app/data/pocket-chords.db
|
DATABASE_URL: sqlite:///app/data/pocket-chords.db
|
||||||
HOST: 0.0.0.0
|
HOST: 0.0.0.0
|
||||||
PORT: 8000
|
PORT: 8000
|
||||||
# Comma-separated allowed origins, or * for any.
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
# Lock this down in production: https://pocketchords.yourdomain.com
|
ALLOW_REGISTRATION: ${ALLOW_REGISTRATION:-false}
|
||||||
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-*}
|
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-*}
|
||||||
|
SPA_DIR: /app/spa
|
||||||
volumes:
|
volumes:
|
||||||
- api-data:/app/data
|
- app-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
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
api-data:
|
app-data:
|
||||||
driver: local
|
driver: local
|
||||||
|
|||||||
Reference in New Issue
Block a user