Compare commits
120
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86be6256a4 | ||
|
|
c65a3d2c40 | ||
|
|
9fe97f2b8a | ||
|
|
e5d44c13b4 | ||
|
|
f89c6bb259 | ||
|
|
b9966f82a0 | ||
|
|
84f4cd05f4 | ||
|
|
4d14d5a3d4 | ||
|
|
d72792fe0a | ||
|
|
48da6b9c67 | ||
|
|
e3f8590ba2 | ||
|
|
384483b14c | ||
|
|
eb3eae3fac | ||
|
|
dfa2c1c0e2 | ||
|
|
22f4d42c19 | ||
|
|
4a116e013c | ||
|
|
5fe0da1a65 | ||
|
|
7a6ac5f0be | ||
|
|
15f79d3d8b | ||
|
|
05914b147d | ||
|
|
abc0116945 | ||
|
|
b6bcb72165 | ||
|
|
ca30b20873 | ||
|
|
ec6e7f04f2 | ||
|
|
1cc0aafc1a | ||
|
|
abe3fc777c | ||
|
|
6354f7e189 | ||
|
|
3731b90f6c | ||
|
|
433485bcf0 | ||
|
|
f1f2dee701 | ||
|
|
2a17f2d333 | ||
|
|
788858f0c1 | ||
|
|
ee8955574f | ||
|
|
ac27a9ab36 | ||
|
|
2de3dc30bf | ||
|
|
4d30c4c58f | ||
|
|
0052dff119 | ||
|
|
3bdca88144 | ||
|
|
4b18429945 | ||
|
|
d9b2297eca | ||
|
|
b4282c2b4b | ||
|
|
8554fbd6e5 | ||
|
|
550bb7f0eb | ||
|
|
cc37fdde9a | ||
|
|
e351638e52 | ||
|
|
90a642c1c5 | ||
|
|
e150b81190 | ||
|
|
94578475ea | ||
|
|
92c491b742 | ||
|
|
eb93a2131f | ||
|
|
1e177580c9 | ||
|
|
7df9a6415a | ||
|
|
1179f4fcd4 | ||
|
|
709d32050e | ||
|
|
b9d84fa72b | ||
|
|
7abc10acde | ||
|
|
9be0e3b5c4 | ||
|
|
ed0f0fb759 | ||
|
|
2d80515ad5 | ||
|
|
50125f4659 | ||
|
|
a6da34740f | ||
|
|
7ed06ccdcf | ||
|
|
db07e0debb | ||
|
|
a0be094cbd | ||
|
|
7366d2e16b | ||
|
|
82aa8948cf | ||
|
|
84a3a9a5b5 | ||
|
|
b260065245 | ||
|
|
264316eb29 | ||
|
|
c625c80958 | ||
|
|
6a04d29eed | ||
|
|
10e28cc5e2 | ||
|
|
970678986a | ||
|
|
40e45f3a01 | ||
|
|
518b79bec6 | ||
|
|
01fb4e308a | ||
|
|
b6300ffa30 | ||
|
|
b163a07e84 | ||
|
|
157f03b655 | ||
|
|
42a3a53314 | ||
|
|
9b7ca3d692 | ||
|
|
e51a59a829 | ||
|
|
4a06fa82eb | ||
|
|
80238bb7a1 | ||
|
|
dcbd46531e | ||
|
|
0864b39336 | ||
|
|
2a0c96bb4c | ||
|
|
c53213a357 | ||
|
|
62cadbcfa0 | ||
|
|
d7aefc4110 | ||
|
|
e972a8dddb | ||
|
|
dc633783e0 | ||
|
|
e33ab71fac | ||
|
|
88b9375a4e | ||
|
|
f7a3e3f0f8 | ||
|
|
72df3dec3b | ||
|
|
b80ec5c6ac | ||
|
|
fbd3aff6d8 | ||
|
|
0ad79c9bc1 | ||
|
|
e175d28c2c | ||
|
|
8e3f09774a | ||
|
|
dbc54032f7 | ||
|
|
047b99e2aa | ||
|
|
1859078cf4 | ||
|
|
c44ba91ec7 | ||
|
|
96a6d0ee5d | ||
|
|
5619bb27a1 | ||
|
|
dfc99c7d15 | ||
|
|
fb82f505b6 | ||
|
|
5970340a17 | ||
|
|
9a77e76ff4 | ||
|
|
3591415d46 | ||
|
|
ab06205744 | ||
|
|
0689e680fa | ||
|
|
297d5aca1f | ||
|
|
0a023c1556 | ||
|
|
aa600af7b2 | ||
|
|
fc4aae8c0c | ||
|
|
24c0b5fa52 | ||
|
|
2af2af3460 |
@@ -1,5 +1,15 @@
|
||||
# Papercrate
|
||||
|
||||
## Local Development
|
||||
|
||||
Use the provided `papercrate.tmux` to spin up the full stack in one tmux session:
|
||||
|
||||
```bash
|
||||
tmux -f papercrate.tmux attach
|
||||
```
|
||||
|
||||
This creates windows for the compose stack, frontend dev server, backend API, and background worker using the repository-relative paths defined in the tmux file. Detach with `Ctrl+b d` and reattach later with the same command.
|
||||
|
||||
## Backend Integration Tests
|
||||
|
||||
Integration tests require a running Postgres instance (and, optionally, Quickwit for OCR indexing). The repository includes a lightweight compose file for local runs:
|
||||
|
||||
Generated
+402
-25
@@ -59,6 +59,45 @@ dependencies = [
|
||||
"password-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asn1-rs"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048"
|
||||
dependencies = [
|
||||
"asn1-rs-derive",
|
||||
"asn1-rs-impl",
|
||||
"displaydoc",
|
||||
"nom",
|
||||
"num-traits",
|
||||
"rusticata-macros",
|
||||
"thiserror 1.0.69",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asn1-rs-derive"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asn1-rs-impl"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.89"
|
||||
@@ -67,7 +106,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -611,6 +650,7 @@ dependencies = [
|
||||
"diesel",
|
||||
"diesel_migrations",
|
||||
"dotenv",
|
||||
"envy",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"http-body-util",
|
||||
@@ -625,6 +665,9 @@ dependencies = [
|
||||
"rand 0.8.5",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde-aux",
|
||||
"serde_bytes",
|
||||
"serde_cbor_2",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tempfile",
|
||||
@@ -635,7 +678,10 @@ dependencies = [
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
"utoipa",
|
||||
"uuid",
|
||||
"webauthn-rs",
|
||||
"webauthn-rs-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -687,6 +733,17 @@ version = "1.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba"
|
||||
|
||||
[[package]]
|
||||
name = "base64urlsafedata"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "215ee31f8a88f588c349ce2d20108b2ed96089b96b9c2b03775dc35dd72938e8"
|
||||
dependencies = [
|
||||
"base64 0.21.7",
|
||||
"pastey",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bindgen"
|
||||
version = "0.72.1"
|
||||
@@ -704,7 +761,7 @@ dependencies = [
|
||||
"regex",
|
||||
"rustc-hash",
|
||||
"shlex",
|
||||
"syn",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -936,6 +993,12 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crunchy"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-bigint"
|
||||
version = "0.4.9"
|
||||
@@ -989,7 +1052,7 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"syn",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1000,9 +1063,15 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476"
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.6.1"
|
||||
@@ -1013,6 +1082,20 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "der-parser"
|
||||
version = "9.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553"
|
||||
dependencies = [
|
||||
"asn1-rs",
|
||||
"displaydoc",
|
||||
"nom",
|
||||
"num-bigint",
|
||||
"num-traits",
|
||||
"rusticata-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.4"
|
||||
@@ -1050,7 +1133,7 @@ dependencies = [
|
||||
"dsl_auto_type",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1070,7 +1153,7 @@ version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe2444076b48641147115697648dc743c2c00b61adade0f01ce67133c7babe8c"
|
||||
dependencies = [
|
||||
"syn",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1092,7 +1175,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1118,7 +1201,7 @@ dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1174,6 +1257,15 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "envy"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f47e0157f2cb54f5ae1bd371b30a2ae4311e1c028f575cd4e81de7353215965"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
@@ -1243,6 +1335,21 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
|
||||
dependencies = [
|
||||
"foreign-types-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types-shared"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
@@ -1287,7 +1394,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1417,6 +1524,17 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "half"
|
||||
version = "2.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"crunchy",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
@@ -1812,6 +1930,8 @@ checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.16.0",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2145,18 +2265,74 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oid-registry"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9"
|
||||
dependencies = [
|
||||
"asn1-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.74"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24ad14dd45412269e1a30f52ad8f0664f0f4f4a89ee8fe28c3b3527021ebb654"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cfg-if",
|
||||
"foreign-types",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"openssl-macros",
|
||||
"openssl-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-macros"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.110"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0a9f0075ba3c21b09f8e8b2026584b1d18d49388648f2fbbf3c97ea8deced8e2"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ordered-float"
|
||||
version = "2.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "outref"
|
||||
version = "0.5.2"
|
||||
@@ -2208,6 +2384,12 @@ dependencies = [
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pastey"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
||||
|
||||
[[package]]
|
||||
name = "pdfium-render"
|
||||
version = "0.8.35"
|
||||
@@ -2267,7 +2449,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2359,7 +2541,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-error"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
|
||||
dependencies = [
|
||||
"proc-macro-error-attr",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-error-attr"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2660,6 +2866,15 @@ dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusticata-macros"
|
||||
version = "4.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
|
||||
dependencies = [
|
||||
"nom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.2"
|
||||
@@ -2877,6 +3092,48 @@ dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde-aux"
|
||||
version = "4.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "207f67b28fe90fb596503a9bf0bf1ea5e831e21307658e177c5dfcdfc3ab8a0a"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"serde",
|
||||
"serde-value",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde-value"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c"
|
||||
dependencies = [
|
||||
"ordered-float",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_bytes"
|
||||
version = "0.11.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_cbor_2"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34aec2709de9078e077090abd848e967abab63c9fb3fdb5d4799ad359d8d482c"
|
||||
dependencies = [
|
||||
"half",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
@@ -2894,7 +3151,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3082,6 +3339,16 @@ version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "1.0.109"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.106"
|
||||
@@ -3110,7 +3377,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3152,7 +3419,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3163,7 +3430,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3259,7 +3526,7 @@ checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3409,7 +3676,7 @@ checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3514,6 +3781,31 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "utoipa"
|
||||
version = "4.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c5afb1a60e207dca502682537fefcfd9921e71d0b83e9576060f09abc6efab23"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"utoipa-gen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utoipa-gen"
|
||||
version = "4.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20c24e8ab68ff9ee746aad22d39b5535601e6416d1b0feeabf78be986a5c4392"
|
||||
dependencies = [
|
||||
"proc-macro-error",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.18.1"
|
||||
@@ -3615,7 +3907,7 @@ dependencies = [
|
||||
"log",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.106",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
@@ -3650,7 +3942,7 @@ checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.106",
|
||||
"wasm-bindgen-backend",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
@@ -3697,6 +3989,74 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webauthn-attestation-ca"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f77a2892ec44032e6c48dad9aad1b05fada09c346ada11d8d32db119b4b4f205"
|
||||
dependencies = [
|
||||
"base64urlsafedata",
|
||||
"openssl",
|
||||
"openssl-sys",
|
||||
"serde",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webauthn-rs"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eb7c3a2f9c8bddd524e47bbd427bcf3a28aa074de55d74470b42a91a41937b8e"
|
||||
dependencies = [
|
||||
"base64urlsafedata",
|
||||
"serde",
|
||||
"tracing",
|
||||
"url",
|
||||
"uuid",
|
||||
"webauthn-rs-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webauthn-rs-core"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19f1d80f3146382529fe70a3ab5d0feb2413a015204ed7843f9377cd39357fc4"
|
||||
dependencies = [
|
||||
"base64 0.21.7",
|
||||
"base64urlsafedata",
|
||||
"der-parser",
|
||||
"hex",
|
||||
"nom",
|
||||
"openssl",
|
||||
"openssl-sys",
|
||||
"rand 0.8.5",
|
||||
"rand_chacha 0.3.1",
|
||||
"serde",
|
||||
"serde_cbor_2",
|
||||
"serde_json",
|
||||
"thiserror 1.0.69",
|
||||
"tracing",
|
||||
"url",
|
||||
"uuid",
|
||||
"webauthn-attestation-ca",
|
||||
"webauthn-rs-proto",
|
||||
"x509-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webauthn-rs-proto"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e786894f89facb9aaf1c5f6559670236723c98382e045521c76f3d5ca5047bd"
|
||||
dependencies = [
|
||||
"base64 0.21.7",
|
||||
"base64urlsafedata",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.3"
|
||||
@@ -3727,7 +4087,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3738,7 +4098,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3939,6 +4299,23 @@ version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb"
|
||||
|
||||
[[package]]
|
||||
name = "x509-parser"
|
||||
version = "0.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69"
|
||||
dependencies = [
|
||||
"asn1-rs",
|
||||
"data-encoding",
|
||||
"der-parser",
|
||||
"lazy_static",
|
||||
"nom",
|
||||
"oid-registry",
|
||||
"rusticata-macros",
|
||||
"thiserror 1.0.69",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xmlparser"
|
||||
version = "0.13.6"
|
||||
@@ -3965,7 +4342,7 @@ checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.106",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
@@ -3986,7 +4363,7 @@ checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4006,7 +4383,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.106",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
@@ -4046,7 +4423,7 @@ checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -25,6 +25,8 @@ aws-credential-types = "1.2"
|
||||
# Serialization
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
envy = "0.4"
|
||||
serde-aux = "4.4"
|
||||
|
||||
# Utilities
|
||||
tracing = "0.1"
|
||||
@@ -44,6 +46,8 @@ base64 = "0.21"
|
||||
quick-xml = "0.32"
|
||||
futures-util = "0.3"
|
||||
url = "2.5"
|
||||
once_cell = "1.19"
|
||||
utoipa = { version = "4.2", default-features = false, features = ["chrono", "uuid", "preserve_order"] }
|
||||
|
||||
# Error handling
|
||||
thiserror = "1.0"
|
||||
@@ -52,6 +56,9 @@ anyhow = "1.0"
|
||||
# Authentication & security
|
||||
argon2 = "0.5"
|
||||
jsonwebtoken = "9"
|
||||
webauthn-rs = { version = "0.5", features = ["danger-allow-state-serialisation", "danger-credential-internals"] }
|
||||
serde_bytes = "0.11"
|
||||
serde_cbor_2 = "0.13"
|
||||
|
||||
# Misc
|
||||
rand = "0.8"
|
||||
@@ -60,3 +67,4 @@ rand = "0.8"
|
||||
once_cell = "1.19"
|
||||
hyper = "1.2"
|
||||
http-body-util = "0.1"
|
||||
webauthn-rs-core = "0.5"
|
||||
|
||||
+2
-1
@@ -20,7 +20,7 @@ COPY migrations ./migrations
|
||||
COPY tests ./tests
|
||||
COPY diesel.toml ./
|
||||
|
||||
RUN cargo build --release --bin backend --bin worker --bin webdav
|
||||
RUN cargo build --release --bin backend --bin worker --bin webdav --bin admin
|
||||
RUN cargo install diesel_cli --no-default-features --features postgres
|
||||
|
||||
FROM debian:trixie-slim AS runtime
|
||||
@@ -53,6 +53,7 @@ RUN apt-get update \
|
||||
COPY --from=builder /app/target/release/backend /usr/local/bin/papercrate-backend
|
||||
COPY --from=builder /app/target/release/worker /usr/local/bin/papercrate-worker
|
||||
COPY --from=builder /app/target/release/webdav /usr/local/bin/papercrate-webdav
|
||||
COPY --from=builder /app/target/release/admin /usr/local/bin/papercrate-admin
|
||||
COPY --from=builder /usr/local/cargo/bin/diesel /usr/local/bin/diesel
|
||||
COPY migrations ./migrations
|
||||
COPY diesel.toml ./
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
use argon2::{
|
||||
password_hash::{PasswordHasher, SaltString},
|
||||
Argon2,
|
||||
};
|
||||
use rand::thread_rng;
|
||||
use std::env;
|
||||
|
||||
fn main() {
|
||||
let password = env::args()
|
||||
.nth(1)
|
||||
.expect("Usage: cargo run --example hash_password <password>");
|
||||
let salt = SaltString::generate(&mut thread_rng());
|
||||
let argon2 = Argon2::default();
|
||||
let hash = argon2
|
||||
.hash_password(password.as_bytes(), &salt)
|
||||
.expect("hashing failed")
|
||||
.to_string();
|
||||
println!("{}", hash);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
DROP INDEX IF EXISTS idx_document_tags_tag;
|
||||
DROP TABLE IF EXISTS document_tags;
|
||||
DROP INDEX IF EXISTS idx_document_versions_document;
|
||||
DROP TABLE IF EXISTS document_versions;
|
||||
DROP INDEX IF EXISTS idx_documents_deleted_at;
|
||||
DROP INDEX IF EXISTS idx_documents_folder;
|
||||
DROP TABLE IF EXISTS documents;
|
||||
DROP INDEX IF EXISTS idx_folders_parent;
|
||||
DROP TABLE IF EXISTS folders;
|
||||
DROP TABLE IF EXISTS tags;
|
||||
DROP TABLE IF EXISTS users;
|
||||
@@ -1,77 +0,0 @@
|
||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY,
|
||||
username VARCHAR(100) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
role VARCHAR(16) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE folders (
|
||||
id UUID PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
parent_id UUID REFERENCES folders(id) ON DELETE SET NULL,
|
||||
path_cache VARCHAR(1000),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT folders_parent_name_unique UNIQUE (parent_id, name)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_folders_parent ON folders(parent_id);
|
||||
|
||||
CREATE TABLE documents (
|
||||
id UUID PRIMARY KEY,
|
||||
filename VARCHAR(255) NOT NULL,
|
||||
original_name VARCHAR(255) NOT NULL,
|
||||
content_type VARCHAR(100),
|
||||
folder_id UUID REFERENCES folders(id) ON DELETE SET NULL,
|
||||
current_version INTEGER NOT NULL,
|
||||
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb
|
||||
);
|
||||
|
||||
CREATE INDEX idx_documents_folder ON documents(folder_id);
|
||||
CREATE INDEX idx_documents_deleted_at ON documents(deleted_at);
|
||||
|
||||
CREATE TABLE document_versions (
|
||||
id UUID PRIMARY KEY,
|
||||
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
version_number INTEGER NOT NULL,
|
||||
s3_key VARCHAR(500) NOT NULL,
|
||||
size_bytes BIGINT NOT NULL,
|
||||
checksum VARCHAR(64) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
operations_summary JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
CONSTRAINT document_versions_unique_version UNIQUE (document_id, version_number)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_document_versions_document ON document_versions(document_id);
|
||||
|
||||
CREATE TABLE tags (
|
||||
id UUID PRIMARY KEY,
|
||||
label VARCHAR(100) NOT NULL UNIQUE,
|
||||
color VARCHAR(7),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE document_tags (
|
||||
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||
assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
assigned_by UUID REFERENCES users(id),
|
||||
PRIMARY KEY (document_id, tag_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_document_tags_tag ON document_tags(tag_id);
|
||||
|
||||
INSERT INTO users (id, username, password_hash, role)
|
||||
VALUES (
|
||||
gen_random_uuid(),
|
||||
'admin',
|
||||
'$argon2id$v=19$m=19456,t=2,p=1$UMkfsNut028fmZupy9JoQg$/YFvGQoEZ2hhMiDCyv68ZROF97GcwAxxRwRgwSbpX5U',
|
||||
'admin'
|
||||
);
|
||||
@@ -1,5 +0,0 @@
|
||||
DROP TRIGGER IF EXISTS trg_jobs_updated_at ON jobs;
|
||||
DROP FUNCTION IF EXISTS touch_jobs_updated_at;
|
||||
DROP INDEX IF EXISTS idx_jobs_job_type;
|
||||
DROP INDEX IF EXISTS idx_jobs_status_run_after;
|
||||
DROP TABLE IF EXISTS jobs;
|
||||
@@ -1,28 +0,0 @@
|
||||
CREATE TABLE jobs (
|
||||
id UUID PRIMARY KEY,
|
||||
job_type TEXT NOT NULL,
|
||||
payload JSONB NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
run_after TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
last_error TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT jobs_status_check CHECK (status IN ('queued', 'processing', 'succeeded', 'failed'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_jobs_status_run_after ON jobs (status, run_after);
|
||||
CREATE INDEX idx_jobs_job_type ON jobs (job_type);
|
||||
|
||||
CREATE OR REPLACE FUNCTION touch_jobs_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trg_jobs_updated_at
|
||||
BEFORE UPDATE ON jobs
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION touch_jobs_updated_at();
|
||||
@@ -1,3 +0,0 @@
|
||||
DROP INDEX IF EXISTS idx_document_assets_type;
|
||||
DROP INDEX IF EXISTS idx_document_assets_version;
|
||||
DROP TABLE IF EXISTS document_assets;
|
||||
@@ -1,15 +0,0 @@
|
||||
CREATE TABLE document_assets (
|
||||
id UUID PRIMARY KEY,
|
||||
document_version_id UUID NOT NULL REFERENCES document_versions(id) ON DELETE CASCADE,
|
||||
asset_type TEXT NOT NULL,
|
||||
s3_key TEXT NOT NULL,
|
||||
mime_type TEXT NOT NULL,
|
||||
width INTEGER,
|
||||
height INTEGER,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT document_assets_unique UNIQUE (document_version_id, asset_type)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_document_assets_version ON document_assets(document_version_id);
|
||||
CREATE INDEX idx_document_assets_type ON document_assets(asset_type);
|
||||
@@ -1,4 +0,0 @@
|
||||
DROP INDEX IF EXISTS folders_parent_name_unique_idx;
|
||||
|
||||
ALTER TABLE folders
|
||||
ADD CONSTRAINT folders_parent_name_unique UNIQUE (parent_id, name);
|
||||
@@ -1,5 +0,0 @@
|
||||
ALTER TABLE folders
|
||||
DROP CONSTRAINT IF EXISTS folders_parent_name_unique;
|
||||
|
||||
CREATE UNIQUE INDEX folders_parent_name_unique_idx
|
||||
ON folders (COALESCE(parent_id, '00000000-0000-0000-0000-000000000000'::uuid), name);
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE documents
|
||||
DROP COLUMN issued_at;
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE documents
|
||||
ADD COLUMN issued_at TIMESTAMPTZ;
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE documents
|
||||
DROP COLUMN name;
|
||||
@@ -1,11 +0,0 @@
|
||||
ALTER TABLE documents
|
||||
ADD COLUMN name VARCHAR(255);
|
||||
|
||||
UPDATE documents
|
||||
SET name = CASE
|
||||
WHEN filename ~ '\\.[^./]+$' THEN regexp_replace(filename, '\\.[^./]+$', '')
|
||||
ELSE filename
|
||||
END;
|
||||
|
||||
ALTER TABLE documents
|
||||
ALTER COLUMN name SET NOT NULL;
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE documents
|
||||
RENAME COLUMN title TO name;
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE documents
|
||||
RENAME COLUMN name TO title;
|
||||
@@ -1 +0,0 @@
|
||||
DROP TABLE refresh_tokens;
|
||||
@@ -1,13 +0,0 @@
|
||||
CREATE TABLE refresh_tokens (
|
||||
id UUID PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash TEXT NOT NULL,
|
||||
issued_at TIMESTAMPTZ NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id);
|
||||
CREATE INDEX idx_refresh_tokens_token_hash ON refresh_tokens(token_hash);
|
||||
@@ -1,13 +0,0 @@
|
||||
-- Restore width/height columns and repopulate from metadata where available.
|
||||
|
||||
ALTER TABLE document_assets
|
||||
ADD COLUMN width INTEGER,
|
||||
ADD COLUMN height INTEGER;
|
||||
|
||||
UPDATE document_assets
|
||||
SET width = (metadata->>'width')::INTEGER
|
||||
WHERE metadata ? 'width';
|
||||
|
||||
UPDATE document_assets
|
||||
SET height = (metadata->>'height')::INTEGER
|
||||
WHERE metadata ? 'height';
|
||||
@@ -1,15 +0,0 @@
|
||||
-- Backfill existing width/height values into metadata then drop the columns.
|
||||
|
||||
UPDATE document_assets
|
||||
SET metadata = metadata || jsonb_build_object('width', width)
|
||||
WHERE width IS NOT NULL
|
||||
AND NOT (metadata ? 'width');
|
||||
|
||||
UPDATE document_assets
|
||||
SET metadata = metadata || jsonb_build_object('height', height)
|
||||
WHERE height IS NOT NULL
|
||||
AND NOT (metadata ? 'height');
|
||||
|
||||
ALTER TABLE document_assets
|
||||
DROP COLUMN width,
|
||||
DROP COLUMN height;
|
||||
@@ -1,17 +0,0 @@
|
||||
ALTER TABLE documents ADD COLUMN current_version INT4;
|
||||
|
||||
UPDATE documents AS d
|
||||
SET current_version = dv.version_number
|
||||
FROM document_versions AS dv
|
||||
WHERE dv.id = d.current_version_id;
|
||||
|
||||
ALTER TABLE documents
|
||||
ALTER COLUMN current_version SET NOT NULL;
|
||||
|
||||
DROP INDEX IF EXISTS idx_documents_current_version_id;
|
||||
|
||||
ALTER TABLE documents
|
||||
DROP CONSTRAINT IF EXISTS documents_current_version_fk;
|
||||
|
||||
ALTER TABLE documents
|
||||
DROP COLUMN current_version_id;
|
||||
@@ -1,21 +0,0 @@
|
||||
ALTER TABLE documents ADD COLUMN current_version_id UUID;
|
||||
|
||||
UPDATE documents AS d
|
||||
SET current_version_id = dv.id
|
||||
FROM document_versions AS dv
|
||||
WHERE dv.document_id = d.id
|
||||
AND dv.version_number = d.current_version;
|
||||
|
||||
ALTER TABLE documents
|
||||
ALTER COLUMN current_version_id SET NOT NULL;
|
||||
|
||||
ALTER TABLE documents
|
||||
ADD CONSTRAINT documents_current_version_fk
|
||||
FOREIGN KEY (current_version_id)
|
||||
REFERENCES document_versions(id)
|
||||
DEFERRABLE INITIALLY DEFERRED;
|
||||
|
||||
CREATE INDEX idx_documents_current_version_id
|
||||
ON documents(current_version_id);
|
||||
|
||||
ALTER TABLE documents DROP COLUMN current_version;
|
||||
@@ -1,5 +0,0 @@
|
||||
DROP INDEX IF EXISTS idx_document_correspondents_role;
|
||||
DROP INDEX IF EXISTS idx_document_correspondents_correspondent;
|
||||
DROP INDEX IF EXISTS idx_document_correspondents_document;
|
||||
DROP TABLE IF EXISTS document_correspondents;
|
||||
DROP TABLE IF EXISTS correspondents;
|
||||
@@ -1,27 +0,0 @@
|
||||
CREATE TABLE correspondents (
|
||||
id UUID PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT correspondents_name_unique UNIQUE (name)
|
||||
);
|
||||
|
||||
CREATE TABLE document_correspondents (
|
||||
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
correspondent_id UUID NOT NULL REFERENCES correspondents(id) ON DELETE CASCADE,
|
||||
role VARCHAR(32) NOT NULL,
|
||||
assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
assigned_by UUID REFERENCES users(id),
|
||||
PRIMARY KEY (document_id, correspondent_id, role),
|
||||
CONSTRAINT document_correspondents_role_check CHECK (role IN ('sender', 'receiver', 'other'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_document_correspondents_document
|
||||
ON document_correspondents(document_id);
|
||||
|
||||
CREATE INDEX idx_document_correspondents_correspondent
|
||||
ON document_correspondents(correspondent_id);
|
||||
|
||||
CREATE INDEX idx_document_correspondents_role
|
||||
ON document_correspondents(role);
|
||||
@@ -1,2 +0,0 @@
|
||||
DROP INDEX IF EXISTS idx_documents_folder_filename;
|
||||
DROP INDEX IF EXISTS idx_documents_folder_title;
|
||||
@@ -1,13 +0,0 @@
|
||||
CREATE INDEX idx_documents_folder_title
|
||||
ON documents (
|
||||
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
title
|
||||
)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE INDEX idx_documents_folder_filename
|
||||
ON documents (
|
||||
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
filename
|
||||
)
|
||||
WHERE deleted_at IS NULL;
|
||||
@@ -1,16 +0,0 @@
|
||||
DROP INDEX IF EXISTS documents_unique_folder_filename;
|
||||
DROP INDEX IF EXISTS idx_documents_folder_title;
|
||||
|
||||
CREATE INDEX idx_documents_folder_title
|
||||
ON documents (
|
||||
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
title
|
||||
)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE INDEX idx_documents_folder_filename
|
||||
ON documents (
|
||||
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
filename
|
||||
)
|
||||
WHERE deleted_at IS NULL;
|
||||
@@ -1,18 +0,0 @@
|
||||
DROP INDEX IF EXISTS idx_documents_folder_title;
|
||||
DROP INDEX IF EXISTS idx_documents_folder_filename;
|
||||
DROP INDEX IF EXISTS documents_unique_folder_title;
|
||||
DROP INDEX IF EXISTS documents_unique_folder_filename;
|
||||
|
||||
CREATE INDEX idx_documents_folder_title
|
||||
ON documents (
|
||||
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
title
|
||||
)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE UNIQUE INDEX documents_unique_folder_filename
|
||||
ON documents (
|
||||
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
filename
|
||||
)
|
||||
WHERE deleted_at IS NULL;
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE document_versions
|
||||
DROP COLUMN metadata;
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE document_versions
|
||||
ADD COLUMN metadata JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE folders
|
||||
ADD COLUMN path_cache VARCHAR(1000);
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE folders
|
||||
DROP COLUMN path_cache;
|
||||
@@ -0,0 +1,21 @@
|
||||
DROP TRIGGER IF EXISTS trg_jobs_updated_at ON jobs;
|
||||
DROP FUNCTION IF EXISTS touch_jobs_updated_at();
|
||||
|
||||
ALTER TABLE documents DROP CONSTRAINT IF EXISTS documents_current_version_fk;
|
||||
|
||||
DROP TABLE IF EXISTS document_asset_objects;
|
||||
DROP TABLE IF EXISTS document_assets;
|
||||
DROP TABLE IF EXISTS document_versions;
|
||||
DROP TABLE IF EXISTS document_tags;
|
||||
DROP TABLE IF EXISTS document_correspondents;
|
||||
DROP TABLE IF EXISTS correspondents;
|
||||
DROP TABLE IF EXISTS documents;
|
||||
DROP TABLE IF EXISTS folders;
|
||||
DROP TABLE IF EXISTS tags;
|
||||
DROP TABLE IF EXISTS jobs;
|
||||
DROP TABLE IF EXISTS refresh_tokens;
|
||||
DROP TABLE IF EXISTS user_memberships;
|
||||
DROP TABLE IF EXISTS users;
|
||||
DROP TABLE IF EXISTS tenants;
|
||||
|
||||
DROP EXTENSION IF EXISTS "pgcrypto";
|
||||
@@ -0,0 +1,244 @@
|
||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||
|
||||
CREATE TABLE tenants (
|
||||
id UUID PRIMARY KEY,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
storage_root TEXT,
|
||||
quickwit_index TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
config JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX tenants_storage_root_unique
|
||||
ON tenants (storage_root)
|
||||
WHERE storage_root IS NOT NULL;
|
||||
|
||||
CREATE UNIQUE INDEX tenants_quickwit_index_unique
|
||||
ON tenants (quickwit_index)
|
||||
WHERE quickwit_index IS NOT NULL;
|
||||
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY,
|
||||
username VARCHAR(100) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE user_memberships (
|
||||
id UUID PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (user_id, tenant_id)
|
||||
);
|
||||
|
||||
CREATE INDEX user_memberships_tenant_id_idx ON user_memberships (tenant_id);
|
||||
CREATE INDEX user_memberships_user_id_idx ON user_memberships (user_id);
|
||||
|
||||
CREATE TABLE folders (
|
||||
id UUID PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
parent_id UUID REFERENCES folders(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_folders_parent ON folders(parent_id);
|
||||
CREATE INDEX folders_tenant_id_idx ON folders(tenant_id);
|
||||
CREATE UNIQUE INDEX folders_parent_name_unique_idx
|
||||
ON folders (COALESCE(parent_id, '00000000-0000-0000-0000-000000000000'::uuid), name);
|
||||
|
||||
CREATE TABLE documents (
|
||||
id UUID PRIMARY KEY,
|
||||
filename VARCHAR(255) NOT NULL,
|
||||
original_name VARCHAR(255) NOT NULL,
|
||||
content_type VARCHAR(100),
|
||||
folder_id UUID REFERENCES folders(id) ON DELETE SET NULL,
|
||||
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
issued_at TIMESTAMPTZ,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
current_version_id UUID NOT NULL,
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_documents_folder ON documents (folder_id);
|
||||
CREATE INDEX idx_documents_deleted_at ON documents (deleted_at);
|
||||
CREATE INDEX documents_tenant_id_idx ON documents (tenant_id);
|
||||
CREATE INDEX idx_documents_current_version_id ON documents (current_version_id);
|
||||
|
||||
CREATE INDEX idx_documents_folder_title
|
||||
ON documents (
|
||||
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
title
|
||||
)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE UNIQUE INDEX documents_unique_folder_filename
|
||||
ON documents (
|
||||
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
filename
|
||||
)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE TABLE document_versions (
|
||||
id UUID PRIMARY KEY,
|
||||
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
version_number INT NOT NULL,
|
||||
s3_key VARCHAR(500) NOT NULL,
|
||||
size_bytes BIGINT NOT NULL,
|
||||
checksum VARCHAR(64) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
operations_summary JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
||||
CONSTRAINT document_versions_unique_version UNIQUE (document_id, version_number)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_document_versions_document ON document_versions (document_id);
|
||||
CREATE INDEX document_versions_tenant_id_idx ON document_versions (tenant_id);
|
||||
|
||||
ALTER TABLE documents
|
||||
ADD CONSTRAINT documents_current_version_fk
|
||||
FOREIGN KEY (current_version_id)
|
||||
REFERENCES document_versions(id)
|
||||
DEFERRABLE INITIALLY DEFERRED;
|
||||
|
||||
CREATE TABLE tags (
|
||||
id UUID PRIMARY KEY,
|
||||
label VARCHAR(100) NOT NULL UNIQUE,
|
||||
color VARCHAR(7),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id)
|
||||
);
|
||||
|
||||
CREATE INDEX tags_tenant_id_idx ON tags (tenant_id);
|
||||
|
||||
CREATE TABLE document_tags (
|
||||
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
assigned_by UUID REFERENCES users(id),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
||||
PRIMARY KEY (document_id, tag_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_document_tags_tag ON document_tags (tag_id);
|
||||
CREATE INDEX document_tags_tenant_id_idx ON document_tags (tenant_id);
|
||||
|
||||
CREATE TABLE correspondents (
|
||||
id UUID PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
||||
CONSTRAINT correspondents_name_unique UNIQUE (name)
|
||||
);
|
||||
|
||||
CREATE INDEX correspondents_tenant_id_idx ON correspondents (tenant_id);
|
||||
|
||||
CREATE TABLE document_correspondents (
|
||||
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
correspondent_id UUID NOT NULL REFERENCES correspondents(id) ON DELETE CASCADE,
|
||||
role VARCHAR(32) NOT NULL,
|
||||
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
assigned_by UUID REFERENCES users(id),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
||||
PRIMARY KEY (document_id, correspondent_id, role),
|
||||
CONSTRAINT document_correspondents_role_check CHECK (role IN ('sender', 'receiver', 'other'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_document_correspondents_document ON document_correspondents (document_id);
|
||||
CREATE INDEX idx_document_correspondents_correspondent ON document_correspondents (correspondent_id);
|
||||
CREATE INDEX idx_document_correspondents_role ON document_correspondents (role);
|
||||
CREATE INDEX document_correspondents_tenant_id_idx ON document_correspondents (tenant_id);
|
||||
|
||||
CREATE TABLE document_assets (
|
||||
id UUID PRIMARY KEY,
|
||||
document_version_id UUID NOT NULL REFERENCES document_versions(id) ON DELETE CASCADE,
|
||||
asset_type TEXT NOT NULL,
|
||||
mime_type TEXT NOT NULL,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
cardinality INTEGER,
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
||||
CONSTRAINT document_assets_unique UNIQUE (document_version_id, asset_type),
|
||||
CONSTRAINT document_assets_cardinality_positive CHECK (cardinality IS NULL OR cardinality >= 1)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_document_assets_version ON document_assets (document_version_id);
|
||||
CREATE INDEX idx_document_assets_type ON document_assets (asset_type);
|
||||
CREATE INDEX document_assets_tenant_id_idx ON document_assets (tenant_id);
|
||||
|
||||
CREATE TABLE document_asset_objects (
|
||||
id UUID PRIMARY KEY,
|
||||
asset_id UUID NOT NULL REFERENCES document_assets(id) ON DELETE CASCADE,
|
||||
ordinal INTEGER NOT NULL,
|
||||
s3_key TEXT NOT NULL,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
||||
CONSTRAINT document_asset_objects_ordinal_positive CHECK (ordinal >= 1),
|
||||
CONSTRAINT document_asset_objects_asset_ordinal_unique UNIQUE (asset_id, ordinal)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_document_asset_objects_asset_ordinal
|
||||
ON document_asset_objects (asset_id, ordinal);
|
||||
|
||||
CREATE INDEX document_asset_objects_tenant_id_idx
|
||||
ON document_asset_objects (tenant_id);
|
||||
|
||||
CREATE TABLE jobs (
|
||||
id UUID PRIMARY KEY,
|
||||
job_type TEXT NOT NULL,
|
||||
payload JSONB NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
run_after TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_error TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
||||
CONSTRAINT jobs_status_check CHECK (status IN ('queued', 'processing', 'succeeded', 'failed'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_jobs_status_run_after ON jobs (status, run_after);
|
||||
CREATE INDEX idx_jobs_job_type ON jobs (job_type);
|
||||
CREATE INDEX jobs_tenant_id_idx ON jobs (tenant_id);
|
||||
|
||||
CREATE OR REPLACE FUNCTION touch_jobs_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trg_jobs_updated_at
|
||||
BEFORE UPDATE ON jobs
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION touch_jobs_updated_at();
|
||||
|
||||
CREATE TABLE refresh_tokens (
|
||||
id UUID PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash TEXT NOT NULL,
|
||||
issued_at TIMESTAMPTZ NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens (user_id);
|
||||
CREATE INDEX idx_refresh_tokens_token_hash ON refresh_tokens (token_hash);
|
||||
CREATE INDEX refresh_tokens_tenant_id_idx ON refresh_tokens (tenant_id);
|
||||
@@ -0,0 +1,6 @@
|
||||
DROP INDEX IF EXISTS folders_tenant_parent_name_unique_idx;
|
||||
CREATE UNIQUE INDEX folders_parent_name_unique_idx
|
||||
ON folders (
|
||||
COALESCE(parent_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
name
|
||||
);
|
||||
@@ -0,0 +1,7 @@
|
||||
DROP INDEX IF EXISTS folders_parent_name_unique_idx;
|
||||
CREATE UNIQUE INDEX folders_tenant_parent_name_unique_idx
|
||||
ON folders (
|
||||
tenant_id,
|
||||
COALESCE(parent_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
name
|
||||
);
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Revert correspondent uniqueness to global name
|
||||
DROP INDEX IF EXISTS correspondents_tenant_name_unique;
|
||||
ALTER TABLE correspondents ADD CONSTRAINT correspondents_name_unique UNIQUE (name);
|
||||
|
||||
-- Revert tag uniqueness to global label
|
||||
DROP INDEX IF EXISTS tags_tenant_label_unique;
|
||||
ALTER TABLE tags ADD CONSTRAINT tags_label_key UNIQUE (label);
|
||||
|
||||
-- Revert document filename uniqueness to global folder scope
|
||||
DROP INDEX IF EXISTS documents_tenant_folder_filename_unique;
|
||||
CREATE UNIQUE INDEX documents_unique_folder_filename
|
||||
ON documents (
|
||||
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
filename
|
||||
)
|
||||
WHERE deleted_at IS NULL;
|
||||
@@ -0,0 +1,20 @@
|
||||
-- Ensure document filenames are unique per tenant + folder
|
||||
DROP INDEX IF EXISTS documents_tenant_folder_filename_unique;
|
||||
DROP INDEX IF EXISTS documents_unique_folder_filename;
|
||||
CREATE UNIQUE INDEX documents_tenant_folder_filename_unique
|
||||
ON documents (
|
||||
tenant_id,
|
||||
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
filename
|
||||
)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
-- Ensure tag labels are unique per tenant
|
||||
ALTER TABLE tags DROP CONSTRAINT IF EXISTS tags_label_key;
|
||||
DROP INDEX IF EXISTS tags_tenant_label_unique;
|
||||
CREATE UNIQUE INDEX tags_tenant_label_unique ON tags (tenant_id, label);
|
||||
|
||||
-- Ensure correspondent names are unique per tenant
|
||||
ALTER TABLE correspondents DROP CONSTRAINT IF EXISTS correspondents_name_unique;
|
||||
DROP INDEX IF EXISTS correspondents_tenant_name_unique;
|
||||
CREATE UNIQUE INDEX correspondents_tenant_name_unique ON correspondents (tenant_id, name);
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE document_versions
|
||||
ADD COLUMN operations_summary JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE document_versions
|
||||
DROP COLUMN IF EXISTS operations_summary;
|
||||
@@ -0,0 +1,7 @@
|
||||
ALTER TABLE document_correspondents DROP CONSTRAINT document_correspondents_pkey;
|
||||
ALTER TABLE document_correspondents ADD COLUMN role VARCHAR(32) NOT NULL DEFAULT 'other';
|
||||
UPDATE document_correspondents SET role = 'other';
|
||||
ALTER TABLE document_correspondents ALTER COLUMN role DROP DEFAULT;
|
||||
ALTER TABLE document_correspondents
|
||||
ADD CONSTRAINT document_correspondents_pkey
|
||||
PRIMARY KEY (document_id, correspondent_id, role);
|
||||
@@ -0,0 +1,24 @@
|
||||
WITH ranked AS (
|
||||
SELECT
|
||||
document_id,
|
||||
correspondent_id,
|
||||
role,
|
||||
assigned_at,
|
||||
assigned_by,
|
||||
tenant_id,
|
||||
ROW_NUMBER() OVER (PARTITION BY document_id, correspondent_id ORDER BY assigned_at DESC) AS rn
|
||||
FROM document_correspondents
|
||||
)
|
||||
DELETE FROM document_correspondents dc
|
||||
USING ranked r
|
||||
WHERE dc.document_id = r.document_id
|
||||
AND dc.correspondent_id = r.correspondent_id
|
||||
AND dc.role = r.role
|
||||
AND dc.tenant_id = r.tenant_id
|
||||
AND r.rn > 1;
|
||||
|
||||
ALTER TABLE document_correspondents DROP CONSTRAINT document_correspondents_pkey;
|
||||
ALTER TABLE document_correspondents DROP COLUMN role;
|
||||
ALTER TABLE document_correspondents
|
||||
ADD CONSTRAINT document_correspondents_pkey
|
||||
PRIMARY KEY (document_id, correspondent_id);
|
||||
@@ -0,0 +1,3 @@
|
||||
DROP INDEX IF EXISTS webdav_tokens_user_tenant_idx;
|
||||
DROP INDEX IF EXISTS webdav_tokens_token_prefix_key;
|
||||
DROP TABLE IF EXISTS webdav_tokens;
|
||||
@@ -0,0 +1,16 @@
|
||||
CREATE TABLE webdav_tokens (
|
||||
id UUID PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
token_prefix TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL,
|
||||
label TEXT,
|
||||
scopes JSONB NOT NULL DEFAULT '["webdav"]'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_used_at TIMESTAMPTZ,
|
||||
expires_at TIMESTAMPTZ,
|
||||
revoked_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX webdav_tokens_token_prefix_key ON webdav_tokens(token_prefix);
|
||||
CREATE INDEX webdav_tokens_user_tenant_idx ON webdav_tokens(user_id, tenant_id);
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE webdav_tokens
|
||||
ADD COLUMN scopes JSONB NOT NULL DEFAULT '["webdav"]'::jsonb;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE webdav_tokens
|
||||
DROP COLUMN IF EXISTS scopes;
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE tenants
|
||||
DROP COLUMN IF EXISTS status,
|
||||
DROP COLUMN IF EXISTS created_by;
|
||||
|
||||
DROP TYPE IF EXISTS tenant_status;
|
||||
@@ -0,0 +1,19 @@
|
||||
ALTER TABLE tenants
|
||||
DROP COLUMN IF EXISTS status;
|
||||
|
||||
ALTER TABLE tenants
|
||||
DROP COLUMN IF EXISTS created_by;
|
||||
|
||||
DROP TYPE IF EXISTS tenant_status;
|
||||
|
||||
CREATE TYPE tenant_status AS ENUM ('creating', 'active', 'suspended', 'deleting', 'error');
|
||||
|
||||
ALTER TABLE tenants
|
||||
ADD COLUMN status tenant_status,
|
||||
ADD COLUMN created_by UUID;
|
||||
|
||||
UPDATE tenants
|
||||
SET status = 'active';
|
||||
|
||||
ALTER TABLE tenants
|
||||
ALTER COLUMN status SET NOT NULL;
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS user_passkeys;
|
||||
@@ -0,0 +1,19 @@
|
||||
CREATE TABLE user_passkeys (
|
||||
id UUID PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
credential_id BYTEA NOT NULL UNIQUE,
|
||||
public_key BYTEA NOT NULL,
|
||||
credential JSONB NOT NULL,
|
||||
sign_count BIGINT NOT NULL,
|
||||
transports TEXT[] NOT NULL DEFAULT '{}',
|
||||
aaguid UUID,
|
||||
nickname TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_used_at TIMESTAMPTZ,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
revoked_by UUID,
|
||||
revoked_reason TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX user_passkeys_user_id_idx ON user_passkeys (user_id);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS webauthn_challenges;
|
||||
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE webauthn_challenges (
|
||||
id UUID PRIMARY KEY,
|
||||
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||
purpose TEXT NOT NULL,
|
||||
challenge BYTEA NOT NULL,
|
||||
state BYTEA NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
CONSTRAINT webauthn_challenges_purpose_check CHECK (purpose IN ('registration', 'authentication'))
|
||||
);
|
||||
|
||||
CREATE INDEX webauthn_challenges_user_id_idx ON webauthn_challenges (user_id);
|
||||
CREATE INDEX webauthn_challenges_expires_at_idx ON webauthn_challenges (expires_at);
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE user_memberships
|
||||
ADD COLUMN role TEXT NOT NULL DEFAULT 'user';
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE user_memberships
|
||||
DROP COLUMN role;
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE tenants
|
||||
RENAME COLUMN name TO slug;
|
||||
|
||||
ALTER TABLE tenants
|
||||
RENAME CONSTRAINT tenants_name_key TO tenants_slug_key;
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE tenants
|
||||
RENAME COLUMN slug TO name;
|
||||
|
||||
ALTER TABLE tenants
|
||||
RENAME CONSTRAINT tenants_slug_key TO tenants_name_key;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE tenants
|
||||
ADD CONSTRAINT tenants_name_key UNIQUE (name);
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE tenants
|
||||
DROP CONSTRAINT IF EXISTS tenants_name_key;
|
||||
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE users
|
||||
ADD COLUMN password_hash VARCHAR(255) NOT NULL DEFAULT '';
|
||||
|
||||
-- Optional: remove the default if you need to reintroduce passwords later
|
||||
ALTER TABLE users
|
||||
ALTER COLUMN password_hash DROP DEFAULT;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE users
|
||||
DROP COLUMN password_hash;
|
||||
+90
-4
@@ -15,6 +15,10 @@ pub struct JwtService {
|
||||
expiry: Duration,
|
||||
download_audience: String,
|
||||
download_expiry: Duration,
|
||||
selector_audience: String,
|
||||
selector_expiry: Duration,
|
||||
signup_audience: String,
|
||||
signup_expiry: Duration,
|
||||
}
|
||||
|
||||
impl JwtService {
|
||||
@@ -27,16 +31,20 @@ impl JwtService {
|
||||
expiry: Duration::minutes(config.jwt_expiry_minutes),
|
||||
download_audience: config.download_token_audience.clone(),
|
||||
download_expiry: Duration::minutes(config.download_token_expiry_minutes),
|
||||
selector_audience: format!("{}:tenant-selector", config.jwt_audience),
|
||||
selector_expiry: Duration::minutes(15),
|
||||
signup_audience: format!("{}:signup", config.jwt_audience),
|
||||
signup_expiry: Duration::minutes(15),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn generate_token(&self, user_id: Uuid, username: &str, role: &str) -> Result<String> {
|
||||
pub fn generate_token(&self, user_id: Uuid, tenant_id: Uuid, username: &str) -> Result<String> {
|
||||
let now = Utc::now();
|
||||
let exp = now + self.expiry;
|
||||
let claims = Claims {
|
||||
sub: user_id,
|
||||
tenant_id,
|
||||
username: username.to_owned(),
|
||||
role: role.to_owned(),
|
||||
iss: self.issuer.clone(),
|
||||
aud: self.audience.clone(),
|
||||
iat: now.timestamp() as usize,
|
||||
@@ -54,12 +62,18 @@ impl JwtService {
|
||||
Ok(data.claims)
|
||||
}
|
||||
|
||||
pub fn generate_download_token(&self, document_id: Uuid, user_id: Uuid) -> Result<String> {
|
||||
pub fn generate_download_token(
|
||||
&self,
|
||||
document_id: Uuid,
|
||||
user_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
) -> Result<String> {
|
||||
let now = Utc::now();
|
||||
let exp = now + self.download_expiry;
|
||||
let claims = DownloadClaims {
|
||||
doc_id: document_id,
|
||||
user_id,
|
||||
tenant_id,
|
||||
iss: self.issuer.clone(),
|
||||
aud: self.download_audience.clone(),
|
||||
iat: now.timestamp() as usize,
|
||||
@@ -76,13 +90,64 @@ impl JwtService {
|
||||
let data = decode::<DownloadClaims>(token, &self.decoding, &validation)?;
|
||||
Ok(data.claims)
|
||||
}
|
||||
|
||||
pub fn generate_tenant_selector_token(&self, user_id: Uuid) -> Result<String> {
|
||||
let now = Utc::now();
|
||||
let exp = now + self.selector_expiry;
|
||||
let claims = TenantSelectionClaims {
|
||||
sub: user_id,
|
||||
iss: self.issuer.clone(),
|
||||
aud: self.selector_audience.clone(),
|
||||
iat: now.timestamp() as usize,
|
||||
exp: exp.timestamp() as usize,
|
||||
};
|
||||
|
||||
Ok(encode(&Header::default(), &claims, &self.encoding)?)
|
||||
}
|
||||
|
||||
pub fn verify_tenant_selector_token(&self, token: &str) -> Result<TenantSelectionClaims> {
|
||||
let mut validation = Validation::default();
|
||||
validation.set_audience(&[self.selector_audience.clone()]);
|
||||
validation.set_issuer(&[self.issuer.clone()]);
|
||||
let data = decode::<TenantSelectionClaims>(token, &self.decoding, &validation)?;
|
||||
Ok(data.claims)
|
||||
}
|
||||
|
||||
pub fn generate_signup_token(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
challenge_id: Uuid,
|
||||
username: String,
|
||||
) -> Result<String> {
|
||||
let now = Utc::now();
|
||||
let exp = now + self.signup_expiry;
|
||||
let claims = SignupClaims {
|
||||
sub: user_id,
|
||||
challenge_id,
|
||||
username,
|
||||
iss: self.issuer.clone(),
|
||||
aud: self.signup_audience.clone(),
|
||||
iat: now.timestamp() as usize,
|
||||
exp: exp.timestamp() as usize,
|
||||
};
|
||||
|
||||
Ok(encode(&Header::default(), &claims, &self.encoding)?)
|
||||
}
|
||||
|
||||
pub fn verify_signup_token(&self, token: &str) -> Result<SignupClaims> {
|
||||
let mut validation = Validation::default();
|
||||
validation.set_audience(&[self.signup_audience.clone()]);
|
||||
validation.set_issuer(&[self.issuer.clone()]);
|
||||
let data = decode::<SignupClaims>(token, &self.decoding, &validation)?;
|
||||
Ok(data.claims)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Claims {
|
||||
pub sub: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub username: String,
|
||||
pub role: String,
|
||||
pub iss: String,
|
||||
pub aud: String,
|
||||
pub iat: usize,
|
||||
@@ -93,6 +158,27 @@ pub struct Claims {
|
||||
pub struct DownloadClaims {
|
||||
pub doc_id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub iss: String,
|
||||
pub aud: String,
|
||||
pub iat: usize,
|
||||
pub exp: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TenantSelectionClaims {
|
||||
pub sub: Uuid,
|
||||
pub iss: String,
|
||||
pub aud: String,
|
||||
pub iat: usize,
|
||||
pub exp: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SignupClaims {
|
||||
pub sub: Uuid,
|
||||
pub challenge_id: Uuid,
|
||||
pub username: String,
|
||||
pub iss: String,
|
||||
pub aud: String,
|
||||
pub iat: usize,
|
||||
|
||||
+52
-4
@@ -1,18 +1,24 @@
|
||||
pub mod jwt;
|
||||
pub mod passkeys;
|
||||
pub mod password;
|
||||
pub mod webdav_tokens;
|
||||
|
||||
use axum::{async_trait, extract::FromRequestParts, http::request::Parts};
|
||||
use axum_extra::headers::{authorization::Bearer, Authorization};
|
||||
use axum_extra::TypedHeader;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{error::AppError, state::AppState};
|
||||
use crate::{
|
||||
error::AppError,
|
||||
state::{AppState, PgPooledConnection},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AuthenticatedUser {
|
||||
pub user_id: uuid::Uuid,
|
||||
pub username: String,
|
||||
pub role: String,
|
||||
pub tenant_id: uuid::Uuid,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -23,6 +29,10 @@ impl FromRequestParts<AppState> for AuthenticatedUser {
|
||||
parts: &mut Parts,
|
||||
state: &AppState,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
if let Some(user) = parts.extensions.get::<AuthenticatedUser>() {
|
||||
return Ok(user.clone());
|
||||
}
|
||||
|
||||
let TypedHeader(Authorization(bearer)) =
|
||||
TypedHeader::<Authorization<Bearer>>::from_request_parts(parts, state)
|
||||
.await
|
||||
@@ -33,10 +43,48 @@ impl FromRequestParts<AppState> for AuthenticatedUser {
|
||||
.verify_token(bearer.token())
|
||||
.map_err(|_| AppError::unauthorized())?;
|
||||
|
||||
Ok(AuthenticatedUser {
|
||||
let user = AuthenticatedUser {
|
||||
user_id: claims.sub,
|
||||
username: claims.username,
|
||||
role: claims.role,
|
||||
tenant_id: claims.tenant_id,
|
||||
};
|
||||
|
||||
parts.extensions.insert(user.clone());
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TenantScopedConn {
|
||||
pub conn: PgPooledConnection,
|
||||
pub tenant_id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub user: AuthenticatedUser,
|
||||
}
|
||||
|
||||
impl TenantScopedConn {
|
||||
pub fn conn(&mut self) -> &mut PgPooledConnection {
|
||||
&mut self.conn
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FromRequestParts<AppState> for TenantScopedConn {
|
||||
type Rejection = AppError;
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut Parts,
|
||||
state: &AppState,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
let user = AuthenticatedUser::from_request_parts(parts, state).await?;
|
||||
let tenant_id = user.tenant_id;
|
||||
let conn = state.db_for_tenant(tenant_id)?;
|
||||
|
||||
Ok(Self {
|
||||
conn,
|
||||
tenant_id,
|
||||
user_id: user.user_id,
|
||||
user,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,670 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::{Duration as ChronoDuration, NaiveDateTime, Utc};
|
||||
use diesel::{dsl::count_star, prelude::*};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
use webauthn_rs::prelude::{Credential, *};
|
||||
|
||||
use crate::{
|
||||
config::AppConfig,
|
||||
error::{AppError, AppResult},
|
||||
models::{NewUserPasskey, NewWebauthnChallenge, User, UserPasskey, WebauthnChallenge},
|
||||
schema::{user_passkeys::dsl as passkey_dsl, webauthn_challenges::dsl as challenge_dsl},
|
||||
};
|
||||
|
||||
const PURPOSE_REGISTRATION: &str = "registration";
|
||||
const PURPOSE_AUTHENTICATION: &str = "authentication";
|
||||
const DEFAULT_CHALLENGE_TTL_MINUTES: i64 = 10;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PasskeyService {
|
||||
webauthn: Arc<Webauthn>,
|
||||
challenge_ttl: ChronoDuration,
|
||||
}
|
||||
|
||||
pub struct PreparedPasskey {
|
||||
pub id: Uuid,
|
||||
pub credential_id: Vec<u8>,
|
||||
pub public_key: Vec<u8>,
|
||||
pub credential: serde_json::Value,
|
||||
pub sign_count: i64,
|
||||
pub transports: Vec<Option<String>>,
|
||||
pub aaguid: Option<Uuid>,
|
||||
}
|
||||
|
||||
impl PreparedPasskey {
|
||||
pub fn into_new_user_passkey(self, user_id: Uuid, nickname: Option<String>) -> NewUserPasskey {
|
||||
NewUserPasskey {
|
||||
id: self.id,
|
||||
user_id,
|
||||
credential_id: self.credential_id,
|
||||
public_key: self.public_key,
|
||||
credential: self.credential,
|
||||
sign_count: self.sign_count,
|
||||
transports: self.transports,
|
||||
aaguid: self.aaguid,
|
||||
nickname,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RegistrationChallengeResponse {
|
||||
pub challenge_id: Uuid,
|
||||
#[serde(flatten)]
|
||||
pub challenge: CreationChallengeResponse,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AuthenticationChallengeResponse {
|
||||
pub challenge_id: Uuid,
|
||||
#[serde(flatten)]
|
||||
pub challenge: RequestChallengeResponse,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PasskeySummary {
|
||||
pub id: Uuid,
|
||||
pub nickname: Option<String>,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub last_used_at: Option<NaiveDateTime>,
|
||||
pub transports: Vec<String>,
|
||||
pub revoked_at: Option<NaiveDateTime>,
|
||||
pub revoked_reason: Option<String>,
|
||||
}
|
||||
|
||||
impl PasskeyService {
|
||||
pub fn try_new(config: &AppConfig) -> Result<Option<Self>> {
|
||||
let rp_id = match config.webauthn_rp_id.as_deref().map(str::trim) {
|
||||
Some(rp_id) if !rp_id.is_empty() => rp_id,
|
||||
_ => return Ok(None),
|
||||
};
|
||||
let rp_origin = match config.webauthn_origin.as_ref().map(|s| s.trim()) {
|
||||
Some(origin) if !origin.is_empty() => origin,
|
||||
_ => return Ok(None),
|
||||
};
|
||||
|
||||
let origin = Url::parse(rp_origin).context("invalid webauthn_origin")?;
|
||||
|
||||
let builder = WebauthnBuilder::new(rp_id, &origin)
|
||||
.context("failed to initialise WebAuthn builder")?
|
||||
.rp_name(&config.webauthn_rp_name)
|
||||
.allow_subdomains(false)
|
||||
.allow_any_port(false);
|
||||
|
||||
let webauthn = builder
|
||||
.build()
|
||||
.context("failed to build WebAuthn instance")?;
|
||||
|
||||
Ok(Some(Self {
|
||||
webauthn: Arc::new(webauthn),
|
||||
challenge_ttl: ChronoDuration::minutes(DEFAULT_CHALLENGE_TTL_MINUTES),
|
||||
}))
|
||||
}
|
||||
|
||||
fn prune_expired(&self, conn: &mut PgConnection) {
|
||||
let now = Utc::now().naive_utc();
|
||||
let _ = diesel::delete(
|
||||
challenge_dsl::webauthn_challenges.filter(challenge_dsl::expires_at.le(now)),
|
||||
)
|
||||
.execute(conn);
|
||||
}
|
||||
|
||||
pub fn start_registration(
|
||||
&self,
|
||||
conn: &mut PgConnection,
|
||||
user: &User,
|
||||
) -> AppResult<RegistrationChallengeResponse> {
|
||||
self.prune_expired(conn);
|
||||
|
||||
let existing: Vec<UserPasskey> = passkey_dsl::user_passkeys
|
||||
.filter(passkey_dsl::user_id.eq(user.id))
|
||||
.filter(passkey_dsl::revoked_at.is_null())
|
||||
.load(conn)?;
|
||||
|
||||
let exclude = if existing.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
existing
|
||||
.iter()
|
||||
.map(|pk| CredentialID::from(pk.credential_id.clone()))
|
||||
.collect(),
|
||||
)
|
||||
};
|
||||
|
||||
let (challenge, state) = self
|
||||
.webauthn
|
||||
.start_passkey_registration(user.id, &user.username, &user.username, exclude)
|
||||
.map_err(|err| {
|
||||
tracing::error!(error = %err, "failed to start passkey registration");
|
||||
AppError::internal("failed to start passkey registration")
|
||||
})?;
|
||||
|
||||
let challenge_id = Uuid::new_v4();
|
||||
let expires_at = (Utc::now() + self.challenge_ttl).naive_utc();
|
||||
let challenge_bytes: Vec<u8> = challenge.public_key.challenge.clone().into();
|
||||
let state_bytes = serde_json::to_vec(&state)
|
||||
.context("failed to encode passkey registration state")
|
||||
.map_err(AppError::internal)?;
|
||||
|
||||
let record = NewWebauthnChallenge {
|
||||
id: challenge_id,
|
||||
user_id: Some(user.id),
|
||||
purpose: PURPOSE_REGISTRATION.to_string(),
|
||||
challenge: challenge_bytes,
|
||||
state: state_bytes,
|
||||
expires_at,
|
||||
};
|
||||
|
||||
diesel::insert_into(challenge_dsl::webauthn_challenges)
|
||||
.values(&record)
|
||||
.execute(conn)?;
|
||||
|
||||
Ok(RegistrationChallengeResponse {
|
||||
challenge_id,
|
||||
challenge,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn start_signup_registration(
|
||||
&self,
|
||||
conn: &mut PgConnection,
|
||||
user_id: Uuid,
|
||||
username: &str,
|
||||
) -> AppResult<RegistrationChallengeResponse> {
|
||||
self.prune_expired(conn);
|
||||
|
||||
let (challenge, state) = self
|
||||
.webauthn
|
||||
.start_passkey_registration(user_id, username, username, None)
|
||||
.map_err(|err| {
|
||||
tracing::error!(error = %err, "failed to start passkey registration");
|
||||
AppError::internal("failed to start passkey registration")
|
||||
})?;
|
||||
|
||||
let challenge_id = Uuid::new_v4();
|
||||
let expires_at = (Utc::now() + self.challenge_ttl).naive_utc();
|
||||
let challenge_bytes: Vec<u8> = challenge.public_key.challenge.clone().into();
|
||||
let state_bytes = serde_json::to_vec(&state)
|
||||
.context("failed to encode passkey registration state")
|
||||
.map_err(AppError::internal)?;
|
||||
|
||||
let record = NewWebauthnChallenge {
|
||||
id: challenge_id,
|
||||
user_id: None,
|
||||
purpose: PURPOSE_REGISTRATION.to_string(),
|
||||
challenge: challenge_bytes,
|
||||
state: state_bytes,
|
||||
expires_at,
|
||||
};
|
||||
|
||||
diesel::insert_into(challenge_dsl::webauthn_challenges)
|
||||
.values(&record)
|
||||
.execute(conn)?;
|
||||
|
||||
Ok(RegistrationChallengeResponse {
|
||||
challenge_id,
|
||||
challenge,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn finish_registration(
|
||||
&self,
|
||||
conn: &mut PgConnection,
|
||||
user: &User,
|
||||
challenge_id: Uuid,
|
||||
credential: RegisterPublicKeyCredential,
|
||||
nickname: Option<String>,
|
||||
) -> AppResult<UserPasskey> {
|
||||
let record: WebauthnChallenge = challenge_dsl::webauthn_challenges
|
||||
.find(challenge_id)
|
||||
.first(conn)
|
||||
.map_err(|err| {
|
||||
if matches!(err, diesel::result::Error::NotFound) {
|
||||
AppError::bad_request("challenge not found")
|
||||
} else {
|
||||
AppError::from(err)
|
||||
}
|
||||
})?;
|
||||
|
||||
if record.purpose != PURPOSE_REGISTRATION {
|
||||
return Err(AppError::bad_request("challenge is not for registration"));
|
||||
}
|
||||
|
||||
if record.user_id != Some(user.id) {
|
||||
return Err(AppError::unauthorized());
|
||||
}
|
||||
|
||||
if record.expires_at < Utc::now().naive_utc() {
|
||||
diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?;
|
||||
return Err(AppError::bad_request("challenge expired"));
|
||||
}
|
||||
|
||||
let state: PasskeyRegistration = serde_json::from_slice(&record.state)
|
||||
.context("failed to decode registration state")
|
||||
.map_err(AppError::internal)?;
|
||||
|
||||
let passkey = self
|
||||
.webauthn
|
||||
.finish_passkey_registration(&credential, &state)
|
||||
.map_err(|err| {
|
||||
tracing::warn!(error = %err, "passkey registration validation failed");
|
||||
AppError::bad_request("invalid passkey attestation")
|
||||
})?;
|
||||
|
||||
let credential_struct: Credential = passkey.clone().into();
|
||||
let credential_id_vec: Vec<u8> = credential_struct.cred_id.clone().into();
|
||||
|
||||
let duplicate = passkey_dsl::user_passkeys
|
||||
.filter(passkey_dsl::credential_id.eq(&credential_id_vec))
|
||||
.first::<UserPasskey>(conn)
|
||||
.optional()?;
|
||||
if duplicate.is_some() {
|
||||
diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?;
|
||||
return Err(AppError::conflict("credential already registered"));
|
||||
}
|
||||
|
||||
let public_key_bytes = serde_cbor_2::to_vec(&credential_struct.cred)
|
||||
.context("failed to encode credential public key")
|
||||
.map_err(AppError::internal)?;
|
||||
|
||||
let transports: Vec<Option<String>> = credential_struct
|
||||
.transports
|
||||
.clone()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|transport| Some(transport.as_ref().to_string()))
|
||||
.collect();
|
||||
|
||||
let aaguid = match credential_struct.attestation.metadata {
|
||||
AttestationMetadata::Packed { aaguid } | AttestationMetadata::Tpm { aaguid, .. } => {
|
||||
Some(aaguid)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let credential_json = serde_json::to_value(&passkey)
|
||||
.context("failed to serialise passkey")
|
||||
.map_err(AppError::internal)?;
|
||||
|
||||
let new_passkey = NewUserPasskey {
|
||||
id: Uuid::new_v4(),
|
||||
user_id: user.id,
|
||||
credential_id: credential_id_vec,
|
||||
public_key: public_key_bytes,
|
||||
credential: credential_json,
|
||||
sign_count: credential_struct.counter as i64,
|
||||
transports,
|
||||
aaguid,
|
||||
nickname,
|
||||
};
|
||||
|
||||
diesel::insert_into(passkey_dsl::user_passkeys)
|
||||
.values(&new_passkey)
|
||||
.execute(conn)?;
|
||||
|
||||
diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?;
|
||||
|
||||
let created: UserPasskey = passkey_dsl::user_passkeys
|
||||
.find(new_passkey.id)
|
||||
.select(UserPasskey::as_select())
|
||||
.first(conn)?;
|
||||
|
||||
Ok(created)
|
||||
}
|
||||
|
||||
pub fn start_authentication(
|
||||
&self,
|
||||
conn: &mut PgConnection,
|
||||
user: &User,
|
||||
) -> AppResult<AuthenticationChallengeResponse> {
|
||||
self.prune_expired(conn);
|
||||
|
||||
let stored: Vec<UserPasskey> = passkey_dsl::user_passkeys
|
||||
.filter(passkey_dsl::user_id.eq(user.id))
|
||||
.filter(passkey_dsl::revoked_at.is_null())
|
||||
.select(UserPasskey::as_select())
|
||||
.load(conn)?;
|
||||
|
||||
if stored.is_empty() {
|
||||
return Err(AppError::bad_request("no passkeys registered"));
|
||||
}
|
||||
|
||||
let mut passkeys = Vec::with_capacity(stored.len());
|
||||
for pk in &stored {
|
||||
let passkey: Passkey = serde_json::from_value(pk.credential.clone())
|
||||
.context("failed to parse stored passkey")
|
||||
.map_err(AppError::internal)?;
|
||||
passkeys.push(passkey);
|
||||
}
|
||||
|
||||
let (challenge, state) = self
|
||||
.webauthn
|
||||
.start_passkey_authentication(&passkeys)
|
||||
.map_err(|err| {
|
||||
tracing::error!(error = %err, "failed to start passkey authentication");
|
||||
AppError::internal("failed to start passkey authentication")
|
||||
})?;
|
||||
|
||||
let challenge_id = Uuid::new_v4();
|
||||
let expires_at = (Utc::now() + self.challenge_ttl).naive_utc();
|
||||
let challenge_bytes: Vec<u8> = challenge.public_key.challenge.clone().into();
|
||||
let state_bytes = serde_json::to_vec(&state)
|
||||
.context("failed to encode authentication state")
|
||||
.map_err(AppError::internal)?;
|
||||
|
||||
let record = NewWebauthnChallenge {
|
||||
id: challenge_id,
|
||||
user_id: Some(user.id),
|
||||
purpose: PURPOSE_AUTHENTICATION.to_string(),
|
||||
challenge: challenge_bytes,
|
||||
state: state_bytes,
|
||||
expires_at,
|
||||
};
|
||||
|
||||
diesel::insert_into(challenge_dsl::webauthn_challenges)
|
||||
.values(&record)
|
||||
.execute(conn)?;
|
||||
|
||||
Ok(AuthenticationChallengeResponse {
|
||||
challenge_id,
|
||||
challenge,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_for_user(
|
||||
&self,
|
||||
conn: &mut PgConnection,
|
||||
user_id: Uuid,
|
||||
) -> AppResult<Vec<PasskeySummary>> {
|
||||
let passkeys: Vec<UserPasskey> = passkey_dsl::user_passkeys
|
||||
.filter(passkey_dsl::user_id.eq(user_id))
|
||||
.order(passkey_dsl::created_at.asc())
|
||||
.select(UserPasskey::as_select())
|
||||
.load(conn)?;
|
||||
|
||||
Ok(passkeys.into_iter().map(PasskeySummary::from).collect())
|
||||
}
|
||||
|
||||
pub fn active_passkey_count(&self, conn: &mut PgConnection, user_id: Uuid) -> AppResult<i64> {
|
||||
let count: i64 = passkey_dsl::user_passkeys
|
||||
.filter(passkey_dsl::user_id.eq(user_id))
|
||||
.filter(passkey_dsl::revoked_at.is_null())
|
||||
.select(count_star())
|
||||
.first(conn)?;
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
pub fn consume_signup_challenge(
|
||||
&self,
|
||||
conn: &mut PgConnection,
|
||||
challenge_id: Uuid,
|
||||
credential: &RegisterPublicKeyCredential,
|
||||
) -> AppResult<PreparedPasskey> {
|
||||
let record: WebauthnChallenge = challenge_dsl::webauthn_challenges
|
||||
.find(challenge_id)
|
||||
.first(conn)
|
||||
.map_err(|err| {
|
||||
if matches!(err, diesel::result::Error::NotFound) {
|
||||
AppError::bad_request("challenge not found")
|
||||
} else {
|
||||
AppError::from(err)
|
||||
}
|
||||
})?;
|
||||
|
||||
if record.purpose != PURPOSE_REGISTRATION {
|
||||
return Err(AppError::bad_request("challenge is not for registration"));
|
||||
}
|
||||
|
||||
if record.expires_at < Utc::now().naive_utc() {
|
||||
diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?;
|
||||
return Err(AppError::bad_request("challenge expired"));
|
||||
}
|
||||
|
||||
let state: PasskeyRegistration = serde_json::from_slice(&record.state)
|
||||
.context("failed to decode registration state")
|
||||
.map_err(AppError::internal)?;
|
||||
|
||||
let passkey = self
|
||||
.webauthn
|
||||
.finish_passkey_registration(credential, &state)
|
||||
.map_err(|err| {
|
||||
tracing::warn!(error = %err, "passkey registration validation failed");
|
||||
AppError::bad_request("invalid passkey attestation")
|
||||
})?;
|
||||
|
||||
let credential_struct: Credential = passkey.clone().into();
|
||||
let credential_id_vec: Vec<u8> = credential_struct.cred_id.clone().into();
|
||||
|
||||
let duplicate = passkey_dsl::user_passkeys
|
||||
.filter(passkey_dsl::credential_id.eq(&credential_id_vec))
|
||||
.first::<UserPasskey>(conn)
|
||||
.optional()?;
|
||||
if duplicate.is_some() {
|
||||
diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?;
|
||||
return Err(AppError::conflict("credential already registered"));
|
||||
}
|
||||
|
||||
let public_key_bytes = serde_cbor_2::to_vec(&credential_struct.cred)
|
||||
.context("failed to encode credential public key")
|
||||
.map_err(AppError::internal)?;
|
||||
|
||||
let transports: Vec<Option<String>> = credential_struct
|
||||
.transports
|
||||
.clone()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|transport| Some(transport.as_ref().to_string()))
|
||||
.collect();
|
||||
|
||||
let aaguid = match credential_struct.attestation.metadata {
|
||||
AttestationMetadata::Packed { aaguid } | AttestationMetadata::Tpm { aaguid, .. } => {
|
||||
Some(aaguid)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let credential_json = serde_json::to_value(&passkey)
|
||||
.context("failed to serialise passkey")
|
||||
.map_err(AppError::internal)?;
|
||||
|
||||
diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?;
|
||||
|
||||
Ok(PreparedPasskey {
|
||||
id: Uuid::new_v4(),
|
||||
credential_id: credential_id_vec,
|
||||
public_key: public_key_bytes,
|
||||
credential: credential_json,
|
||||
sign_count: credential_struct.counter as i64,
|
||||
transports,
|
||||
aaguid,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn revoke_passkey(
|
||||
&self,
|
||||
conn: &mut PgConnection,
|
||||
user_id: Uuid,
|
||||
passkey_id: Uuid,
|
||||
reason: Option<String>,
|
||||
) -> AppResult<()> {
|
||||
let now = Utc::now().naive_utc();
|
||||
let updated = diesel::update(
|
||||
passkey_dsl::user_passkeys
|
||||
.filter(passkey_dsl::id.eq(passkey_id))
|
||||
.filter(passkey_dsl::user_id.eq(user_id))
|
||||
.filter(passkey_dsl::revoked_at.is_null()),
|
||||
)
|
||||
.set((
|
||||
passkey_dsl::revoked_at.eq(Some(now)),
|
||||
passkey_dsl::revoked_reason.eq(reason),
|
||||
passkey_dsl::updated_at.eq(now),
|
||||
))
|
||||
.execute(conn)?;
|
||||
|
||||
if updated == 0 {
|
||||
return Err(AppError::not_found());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn finish_authentication(
|
||||
&self,
|
||||
conn: &mut PgConnection,
|
||||
challenge_id: Uuid,
|
||||
credential: PublicKeyCredential,
|
||||
) -> AppResult<(User, UserPasskey, AuthenticationResult)> {
|
||||
let record: WebauthnChallenge = challenge_dsl::webauthn_challenges
|
||||
.find(challenge_id)
|
||||
.first(conn)
|
||||
.map_err(|err| {
|
||||
if matches!(err, diesel::result::Error::NotFound) {
|
||||
AppError::bad_request("challenge not found")
|
||||
} else {
|
||||
AppError::from(err)
|
||||
}
|
||||
})?;
|
||||
|
||||
if record.purpose != PURPOSE_AUTHENTICATION {
|
||||
return Err(AppError::bad_request("challenge is not for authentication"));
|
||||
}
|
||||
|
||||
let user_id = record
|
||||
.user_id
|
||||
.ok_or_else(|| AppError::bad_request("challenge missing user context"))?;
|
||||
|
||||
if record.expires_at < Utc::now().naive_utc() {
|
||||
diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?;
|
||||
return Err(AppError::bad_request("challenge expired"));
|
||||
}
|
||||
|
||||
let state: PasskeyAuthentication = serde_json::from_slice(&record.state)
|
||||
.context("failed to decode authentication state")
|
||||
.map_err(AppError::internal)?;
|
||||
|
||||
let auth_result = self
|
||||
.webauthn
|
||||
.finish_passkey_authentication(&credential, &state)
|
||||
.map_err(|err| {
|
||||
tracing::warn!(error = %err, "passkey authentication failed");
|
||||
AppError::unauthorized()
|
||||
})?;
|
||||
|
||||
let credential_id_vec: Vec<u8> = auth_result.cred_id().clone().into();
|
||||
|
||||
let mut passkey: UserPasskey = passkey_dsl::user_passkeys
|
||||
.filter(passkey_dsl::user_id.eq(user_id))
|
||||
.filter(passkey_dsl::credential_id.eq(&credential_id_vec))
|
||||
.filter(passkey_dsl::revoked_at.is_null())
|
||||
.select(UserPasskey::as_select())
|
||||
.first(conn)
|
||||
.map_err(|err| {
|
||||
if matches!(err, diesel::result::Error::NotFound) {
|
||||
AppError::unauthorized()
|
||||
} else {
|
||||
AppError::from(err)
|
||||
}
|
||||
})?;
|
||||
|
||||
let mut passkey_model: Passkey = serde_json::from_value(passkey.credential.clone())
|
||||
.context("failed to parse stored passkey")
|
||||
.map_err(AppError::internal)?;
|
||||
|
||||
if auth_result.needs_update() {
|
||||
let _ = passkey_model.update_credential(&auth_result);
|
||||
}
|
||||
|
||||
let credential_struct: Credential = passkey_model.clone().into();
|
||||
let public_key_bytes = serde_cbor_2::to_vec(&credential_struct.cred)
|
||||
.context("failed to encode credential public key")
|
||||
.map_err(AppError::internal)?;
|
||||
|
||||
let transports: Vec<Option<String>> = credential_struct
|
||||
.transports
|
||||
.clone()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|transport| Some(transport.as_ref().to_string()))
|
||||
.collect();
|
||||
|
||||
let credential_json = serde_json::to_value(&passkey_model)
|
||||
.context("failed to serialise passkey")
|
||||
.map_err(AppError::internal)?;
|
||||
|
||||
let now = Utc::now().naive_utc();
|
||||
diesel::update(passkey_dsl::user_passkeys.find(passkey.id))
|
||||
.set((
|
||||
passkey_dsl::sign_count.eq(auth_result.counter() as i64),
|
||||
passkey_dsl::transports.eq(&transports),
|
||||
passkey_dsl::credential.eq(credential_json.clone()),
|
||||
passkey_dsl::public_key.eq(public_key_bytes),
|
||||
passkey_dsl::last_used_at.eq(Some(now)),
|
||||
passkey_dsl::updated_at.eq(now),
|
||||
))
|
||||
.execute(conn)?;
|
||||
|
||||
passkey.sign_count = auth_result.counter() as i64;
|
||||
passkey.transports = transports;
|
||||
passkey.credential = credential_json;
|
||||
passkey.last_used_at = Some(now);
|
||||
passkey.updated_at = now;
|
||||
|
||||
diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?;
|
||||
|
||||
let user = crate::schema::users::table
|
||||
.find(user_id)
|
||||
.first::<User>(conn)?;
|
||||
|
||||
Ok((user, passkey, auth_result))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<UserPasskey> for PasskeySummary {
|
||||
fn from(passkey: UserPasskey) -> Self {
|
||||
let transports = passkey
|
||||
.transports
|
||||
.into_iter()
|
||||
.filter_map(|value| value)
|
||||
.collect();
|
||||
|
||||
Self {
|
||||
id: passkey.id,
|
||||
nickname: passkey.nickname,
|
||||
created_at: passkey.created_at,
|
||||
last_used_at: passkey.last_used_at,
|
||||
transports,
|
||||
revoked_at: passkey.revoked_at,
|
||||
revoked_reason: passkey.revoked_reason,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PasskeyRegistrationFinishPayload {
|
||||
pub challenge_id: Uuid,
|
||||
pub credential: RegisterPublicKeyCredential,
|
||||
#[serde(default)]
|
||||
pub nickname: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PasskeyLoginStartPayload {
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PasskeyLoginFinishPayload {
|
||||
pub challenge_id: Uuid,
|
||||
pub credential: PublicKeyCredential,
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use argon2::{
|
||||
password_hash::{PasswordHash, PasswordVerifier},
|
||||
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
|
||||
Argon2,
|
||||
};
|
||||
use rand::rngs::OsRng;
|
||||
|
||||
pub fn verify_password(password: &str, password_hash: &str) -> Result<bool> {
|
||||
let parsed_hash = PasswordHash::new(password_hash).map_err(|err| anyhow!(err))?;
|
||||
@@ -10,3 +11,11 @@ pub fn verify_password(password: &str, password_hash: &str) -> Result<bool> {
|
||||
.verify_password(password.as_bytes(), &parsed_hash)
|
||||
.is_ok())
|
||||
}
|
||||
|
||||
pub fn hash_password(password: &str) -> Result<String> {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let hash = Argon2::default()
|
||||
.hash_password(password.as_bytes(), &salt)
|
||||
.map_err(|err| anyhow!(err))?;
|
||||
Ok(hash.to_string())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
use argon2::{
|
||||
password_hash::{PasswordHasher, SaltString},
|
||||
Argon2,
|
||||
};
|
||||
use chrono::{NaiveDateTime, Utc};
|
||||
use diesel::prelude::*;
|
||||
use rand::rngs::OsRng;
|
||||
use rand::RngCore;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
error::AppError,
|
||||
models::{NewWebdavToken, WebdavToken},
|
||||
schema::webdav_tokens,
|
||||
state::PgPooledConnection,
|
||||
};
|
||||
|
||||
const TOKEN_PREFIX_LENGTH: usize = 12;
|
||||
const TOKEN_SECRET_LENGTH: usize = 32;
|
||||
|
||||
pub struct IssuedWebdavToken {
|
||||
pub token: String,
|
||||
pub record: WebdavToken,
|
||||
}
|
||||
|
||||
pub fn create_webdav_token(
|
||||
conn: &mut PgPooledConnection,
|
||||
user_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
label: Option<String>,
|
||||
expires_at: Option<NaiveDateTime>,
|
||||
) -> Result<IssuedWebdavToken, AppError> {
|
||||
let raw_secret = generate_secret()?;
|
||||
let token_prefix = raw_secret[..TOKEN_PREFIX_LENGTH].to_string();
|
||||
let token_hash = hash_secret(&raw_secret)?;
|
||||
let new_token = NewWebdavToken {
|
||||
id: Uuid::new_v4(),
|
||||
user_id,
|
||||
tenant_id,
|
||||
token_prefix,
|
||||
token_hash,
|
||||
label,
|
||||
expires_at,
|
||||
};
|
||||
|
||||
let record = diesel::insert_into(webdav_tokens::table)
|
||||
.values(&new_token)
|
||||
.get_result::<WebdavToken>(conn)?;
|
||||
|
||||
Ok(IssuedWebdavToken {
|
||||
token: raw_secret,
|
||||
record,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_webdav_tokens(
|
||||
conn: &mut PgPooledConnection,
|
||||
user_id: Uuid,
|
||||
tenant_id: Option<Uuid>,
|
||||
) -> Result<Vec<WebdavToken>, AppError> {
|
||||
let mut query = webdav_tokens::table
|
||||
.filter(webdav_tokens::user_id.eq(user_id))
|
||||
.into_boxed();
|
||||
|
||||
if let Some(tenant_id) = tenant_id {
|
||||
query = query.filter(webdav_tokens::tenant_id.eq(tenant_id));
|
||||
}
|
||||
|
||||
let tokens = query
|
||||
.order(webdav_tokens::created_at.asc())
|
||||
.load::<WebdavToken>(conn)?;
|
||||
|
||||
Ok(tokens)
|
||||
}
|
||||
|
||||
pub fn find_active_token_by_secret(
|
||||
conn: &mut PgPooledConnection,
|
||||
user_id: Uuid,
|
||||
tenant_id: Option<Uuid>,
|
||||
secret: &str,
|
||||
) -> Result<Option<WebdavToken>, AppError> {
|
||||
if secret.len() < TOKEN_PREFIX_LENGTH {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let prefix = &secret[..TOKEN_PREFIX_LENGTH];
|
||||
let mut query = webdav_tokens::table
|
||||
.filter(webdav_tokens::user_id.eq(user_id))
|
||||
.filter(webdav_tokens::token_prefix.eq(prefix))
|
||||
.filter(webdav_tokens::revoked_at.is_null())
|
||||
.into_boxed();
|
||||
|
||||
let now = Utc::now().naive_utc();
|
||||
query = query.filter(
|
||||
webdav_tokens::expires_at
|
||||
.is_null()
|
||||
.or(webdav_tokens::expires_at.gt(now)),
|
||||
);
|
||||
|
||||
if let Some(tenant_id) = tenant_id {
|
||||
query = query.filter(webdav_tokens::tenant_id.eq(tenant_id));
|
||||
}
|
||||
|
||||
let candidates = query.load::<WebdavToken>(conn)?;
|
||||
|
||||
for token in candidates {
|
||||
if verify_token_secret(secret, &token.token_hash)? {
|
||||
return Ok(Some(token));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub fn revoke_webdav_token(
|
||||
conn: &mut PgPooledConnection,
|
||||
token_id: Uuid,
|
||||
user_id: Uuid,
|
||||
) -> Result<(), AppError> {
|
||||
let affected = diesel::update(
|
||||
webdav_tokens::table
|
||||
.filter(webdav_tokens::id.eq(token_id))
|
||||
.filter(webdav_tokens::user_id.eq(user_id)),
|
||||
)
|
||||
.set(webdav_tokens::revoked_at.eq(Utc::now().naive_utc()))
|
||||
.execute(conn)?;
|
||||
|
||||
if affected == 0 {
|
||||
return Err(AppError::not_found());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn touch_webdav_token(conn: &mut PgPooledConnection, token_id: Uuid) -> Result<(), AppError> {
|
||||
diesel::update(webdav_tokens::table.filter(webdav_tokens::id.eq(token_id)))
|
||||
.set(webdav_tokens::last_used_at.eq(Utc::now().naive_utc()))
|
||||
.execute(conn)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn verify_token_secret(secret: &str, token_hash: &str) -> Result<bool, AppError> {
|
||||
crate::auth::password::verify_password(secret, token_hash).map_err(|err| {
|
||||
tracing::error!(error = ?err, "failed to verify token");
|
||||
AppError::internal("failed to verify token")
|
||||
})
|
||||
}
|
||||
|
||||
fn generate_secret() -> Result<String, AppError> {
|
||||
let mut buffer = [0u8; TOKEN_SECRET_LENGTH];
|
||||
OsRng.try_fill_bytes(&mut buffer).map_err(|err| {
|
||||
tracing::error!(error = ?err, "failed to generate token");
|
||||
AppError::internal("failed to generate token")
|
||||
})?;
|
||||
Ok(hex::encode(buffer))
|
||||
}
|
||||
|
||||
fn hash_secret(secret: &str) -> Result<String, AppError> {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let hash = Argon2::default()
|
||||
.hash_password(secret.as_bytes(), &salt)
|
||||
.map_err(|err| {
|
||||
tracing::error!(error = ?err, "failed to hash token");
|
||||
AppError::internal("failed to hash token")
|
||||
})?;
|
||||
Ok(hash.to_string())
|
||||
}
|
||||
|
||||
fn _ensure_constants() {
|
||||
assert!(TOKEN_PREFIX_LENGTH < TOKEN_SECRET_LENGTH * 2);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn generated_secret_has_expected_length() {
|
||||
let secret = generate_secret().unwrap();
|
||||
assert_eq!(secret.len(), TOKEN_SECRET_LENGTH * 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_and_verify_secret_round_trip() {
|
||||
let secret = generate_secret().unwrap();
|
||||
let hash = hash_secret(&secret).unwrap();
|
||||
assert!(verify_token_secret(&secret, &hash).unwrap());
|
||||
assert!(!verify_token_secret("wrong", &hash).unwrap());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,557 @@
|
||||
use std::env;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use diesel::{dsl::exists, prelude::*, select};
|
||||
use reqwest::{Client, Method, StatusCode};
|
||||
use uuid::Uuid;
|
||||
|
||||
use backend::{
|
||||
config::AppConfig,
|
||||
db::{self, PgPool},
|
||||
documents::search::ensure_quickwit_index,
|
||||
jobs::{enqueue_job, JOB_ANALYZE_DOCUMENT},
|
||||
models::{
|
||||
DocumentAsset, DocumentAssetObject, NewUser, NewUserMembership, Tenant, TenantStatus, User,
|
||||
},
|
||||
s3,
|
||||
schema::{
|
||||
document_asset_objects, document_assets, documents, tenants, user_memberships, users,
|
||||
},
|
||||
storage::{ObjectStorage, S3Storage, TenantStorage},
|
||||
tenants::TenantService,
|
||||
utils::tracing::init_tracing,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
enum Command {
|
||||
CreateUser {
|
||||
username: String,
|
||||
},
|
||||
ListUsers,
|
||||
DeleteUser {
|
||||
username: String,
|
||||
},
|
||||
CreateTenant {
|
||||
name: String,
|
||||
storage_root: Option<String>,
|
||||
quickwit_index: Option<String>,
|
||||
},
|
||||
DeleteTenant {
|
||||
tenant_id: Uuid,
|
||||
},
|
||||
AddUserToTenant {
|
||||
username: String,
|
||||
tenant_id: Uuid,
|
||||
},
|
||||
RemoveUserFromTenant {
|
||||
username: String,
|
||||
tenant_id: Uuid,
|
||||
},
|
||||
ReanalyzeDocuments {
|
||||
tenant_id: Uuid,
|
||||
},
|
||||
ListTenants,
|
||||
DeleteAssets(Uuid),
|
||||
QuickwitCreate(Uuid),
|
||||
QuickwitDelete(Uuid),
|
||||
}
|
||||
|
||||
impl Command {
|
||||
fn usage() -> &'static str {
|
||||
"Usage: admin\n\
|
||||
create-user <username>\n\
|
||||
list-users\n\
|
||||
delete-user <username>\n\
|
||||
create-tenant <name> [storage_root] [quickwit_index]\n\
|
||||
delete-tenant <tenant-id>\n\
|
||||
add-user-to-tenant <username> <tenant-id>\n\
|
||||
remove-user-from-tenant <username> <tenant-id>\n\
|
||||
reanalyze-documents <tenant-id>\n\
|
||||
list-tenants\n\
|
||||
delete-assets <tenant-id>\n\
|
||||
quickwit-create-index <tenant-id>\n\
|
||||
quickwit-delete-index <tenant-id>"
|
||||
}
|
||||
|
||||
fn parse_tenant_id(arg: Option<String>) -> Result<Uuid> {
|
||||
let raw = arg.ok_or_else(|| anyhow!("tenant id required"))?;
|
||||
Uuid::parse_str(&raw).map_err(|_| anyhow!("invalid tenant id: {}", raw))
|
||||
}
|
||||
|
||||
fn parse() -> Result<Self> {
|
||||
let mut args = env::args().skip(1);
|
||||
match args.next().as_deref() {
|
||||
Some("create-user") => Ok(Self::CreateUser {
|
||||
username: args.next().ok_or_else(|| anyhow!("username required"))?,
|
||||
}),
|
||||
Some("list-users") => Ok(Self::ListUsers),
|
||||
Some("delete-user") => Ok(Self::DeleteUser {
|
||||
username: args.next().ok_or_else(|| anyhow!("username required"))?,
|
||||
}),
|
||||
Some("create-tenant") => Ok(Self::CreateTenant {
|
||||
name: args.next().ok_or_else(|| anyhow!("tenant name required"))?,
|
||||
storage_root: args.next(),
|
||||
quickwit_index: args.next(),
|
||||
}),
|
||||
Some("delete-tenant") => Ok(Self::DeleteTenant {
|
||||
tenant_id: Self::parse_tenant_id(args.next())?,
|
||||
}),
|
||||
Some("add-user-to-tenant") => Ok(Self::AddUserToTenant {
|
||||
username: args.next().ok_or_else(|| anyhow!("username required"))?,
|
||||
tenant_id: Self::parse_tenant_id(args.next())?,
|
||||
}),
|
||||
Some("remove-user-from-tenant") => Ok(Self::RemoveUserFromTenant {
|
||||
username: args.next().ok_or_else(|| anyhow!("username required"))?,
|
||||
tenant_id: Self::parse_tenant_id(args.next())?,
|
||||
}),
|
||||
Some("reanalyze-documents") => Ok(Self::ReanalyzeDocuments {
|
||||
tenant_id: Self::parse_tenant_id(args.next())?,
|
||||
}),
|
||||
Some("list-tenants") => Ok(Self::ListTenants),
|
||||
Some("delete-assets") => Ok(Self::DeleteAssets(Self::parse_tenant_id(args.next())?)),
|
||||
Some("quickwit-create-index") => {
|
||||
Ok(Self::QuickwitCreate(Self::parse_tenant_id(args.next())?))
|
||||
}
|
||||
Some("quickwit-delete-index") => {
|
||||
Ok(Self::QuickwitDelete(Self::parse_tenant_id(args.next())?))
|
||||
}
|
||||
_ => Err(anyhow!(Self::usage())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
init_tracing("info");
|
||||
let command = Command::parse()?;
|
||||
let config = AppConfig::load_and_log("admin")?;
|
||||
let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?;
|
||||
|
||||
match command {
|
||||
Command::CreateUser { username } => create_user(&pool, &username)?,
|
||||
Command::ListUsers => list_users(&pool)?,
|
||||
Command::DeleteUser { username } => delete_user(&pool, &username)?,
|
||||
Command::CreateTenant {
|
||||
name,
|
||||
storage_root,
|
||||
quickwit_index,
|
||||
} => create_tenant(&pool, &name, storage_root, quickwit_index)?,
|
||||
Command::DeleteTenant { tenant_id } => delete_tenant(&pool, tenant_id)?,
|
||||
Command::AddUserToTenant {
|
||||
username,
|
||||
tenant_id,
|
||||
} => add_user_to_tenant(&pool, &username, tenant_id)?,
|
||||
Command::RemoveUserFromTenant {
|
||||
username,
|
||||
tenant_id,
|
||||
} => remove_user_from_tenant(&pool, &username, tenant_id)?,
|
||||
Command::ReanalyzeDocuments { tenant_id } => reanalyze_documents(&pool, tenant_id)?,
|
||||
Command::ListTenants => list_tenants(&pool)?,
|
||||
Command::DeleteAssets(tenant_id) => {
|
||||
delete_assets_for_tenant(&config, &pool, tenant_id).await?
|
||||
}
|
||||
Command::QuickwitCreate(tenant_id) => {
|
||||
quickwit_index(&config, &pool, tenant_id, Method::POST).await?
|
||||
}
|
||||
Command::QuickwitDelete(tenant_id) => {
|
||||
quickwit_index(&config, &pool, tenant_id, Method::DELETE).await?
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_user(pool: &PgPool, username: &str) -> Result<()> {
|
||||
if username.trim().is_empty() {
|
||||
bail!("username must not be empty");
|
||||
}
|
||||
|
||||
let mut conn = pool.get().context("failed to get database connection")?;
|
||||
let exists: bool =
|
||||
select(exists(users::table.filter(users::username.eq(username)))).get_result(&mut conn)?;
|
||||
if exists {
|
||||
bail!("user '{}' already exists", username);
|
||||
}
|
||||
|
||||
let new_user = NewUser {
|
||||
id: Uuid::new_v4(),
|
||||
username: username.to_string(),
|
||||
};
|
||||
|
||||
diesel::insert_into(users::table)
|
||||
.values(&new_user)
|
||||
.execute(&mut conn)?;
|
||||
|
||||
println!("created user '{}' (id: {})", username, new_user.id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn list_users(pool: &PgPool) -> Result<()> {
|
||||
let mut conn = pool.get().context("failed to get database connection")?;
|
||||
|
||||
let users_list: Vec<User> = users::table.order(users::username.asc()).load(&mut conn)?;
|
||||
if users_list.is_empty() {
|
||||
println!("No users found.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for user in users_list {
|
||||
let memberships: Vec<String> = user_memberships::table
|
||||
.inner_join(tenants::table)
|
||||
.filter(user_memberships::user_id.eq(user.id))
|
||||
.select(tenants::name)
|
||||
.order(tenants::name.asc())
|
||||
.load(&mut conn)?;
|
||||
|
||||
if memberships.is_empty() {
|
||||
println!("{} ({})", user.username, user.id);
|
||||
} else {
|
||||
println!(
|
||||
"{} ({}) -> {}",
|
||||
user.username,
|
||||
user.id,
|
||||
memberships.join(", ")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn delete_user(pool: &PgPool, username: &str) -> Result<()> {
|
||||
let mut conn = pool.get().context("failed to get database connection")?;
|
||||
|
||||
let user: User = users::table
|
||||
.filter(users::username.eq(username))
|
||||
.first(&mut conn)
|
||||
.optional()?
|
||||
.ok_or_else(|| anyhow!("user '{}' not found", username))?;
|
||||
|
||||
diesel::delete(user_memberships::table.filter(user_memberships::user_id.eq(user.id)))
|
||||
.execute(&mut conn)?;
|
||||
diesel::delete(users::table.filter(users::id.eq(user.id))).execute(&mut conn)?;
|
||||
|
||||
println!("deleted user '{}'", username);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_tenant(
|
||||
pool: &PgPool,
|
||||
name: &str,
|
||||
storage_root_arg: Option<String>,
|
||||
quickwit_index_arg: Option<String>,
|
||||
) -> Result<()> {
|
||||
let service = TenantService::new(pool.clone());
|
||||
let tenant = service
|
||||
.create_tenant(
|
||||
name,
|
||||
storage_root_arg.as_deref(),
|
||||
quickwit_index_arg.as_deref(),
|
||||
TenantStatus::Creating,
|
||||
&[],
|
||||
None,
|
||||
)
|
||||
.map_err(|err| anyhow!(format!("{err:?}")))?;
|
||||
|
||||
let storage_root = tenant.storage_root.as_deref().unwrap_or("<none>");
|
||||
let quickwit_index = tenant.quickwit_index.as_deref().unwrap_or("<none>");
|
||||
|
||||
println!(
|
||||
"created tenant '{}' with id {}, storage_root '{}', quickwit_index '{}', status '{}'",
|
||||
tenant.name,
|
||||
tenant.id,
|
||||
storage_root,
|
||||
quickwit_index,
|
||||
tenant.status.as_str()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn delete_tenant(pool: &PgPool, tenant_id: Uuid) -> Result<()> {
|
||||
let mut conn = pool.get().context("failed to get database connection")?;
|
||||
|
||||
let tenant: Tenant = tenants::table
|
||||
.find(tenant_id)
|
||||
.first(&mut conn)
|
||||
.optional()?
|
||||
.ok_or_else(|| anyhow!("tenant '{}' not found", tenant_id))?;
|
||||
|
||||
let member_exists: bool = select(exists(
|
||||
user_memberships::table.filter(user_memberships::tenant_id.eq(tenant.id)),
|
||||
))
|
||||
.get_result(&mut conn)?;
|
||||
if member_exists {
|
||||
bail!("tenant '{}' still has user memberships", tenant.name);
|
||||
}
|
||||
|
||||
diesel::delete(tenants::table.filter(tenants::id.eq(tenant.id))).execute(&mut conn)?;
|
||||
println!("deleted tenant '{}'", tenant.name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn add_user_to_tenant(pool: &PgPool, username: &str, tenant_id: Uuid) -> Result<()> {
|
||||
let mut conn = pool.get().context("failed to get database connection")?;
|
||||
|
||||
let user: User = users::table
|
||||
.filter(users::username.eq(username))
|
||||
.first(&mut conn)
|
||||
.optional()?
|
||||
.ok_or_else(|| anyhow!("user '{}' not found", username))?;
|
||||
|
||||
let tenant: Tenant = tenants::table
|
||||
.find(tenant_id)
|
||||
.first(&mut conn)
|
||||
.optional()?
|
||||
.ok_or_else(|| anyhow!("tenant '{}' not found", tenant_id))?;
|
||||
|
||||
let membership = NewUserMembership {
|
||||
id: Uuid::new_v4(),
|
||||
user_id: user.id,
|
||||
tenant_id: tenant.id,
|
||||
};
|
||||
|
||||
diesel::insert_into(user_memberships::table)
|
||||
.values(&membership)
|
||||
.on_conflict((user_memberships::user_id, user_memberships::tenant_id))
|
||||
.do_nothing()
|
||||
.execute(&mut conn)?;
|
||||
|
||||
println!("added user '{}' to tenant '{}'", username, tenant.name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_user_from_tenant(pool: &PgPool, username: &str, tenant_id: Uuid) -> Result<()> {
|
||||
let mut conn = pool.get().context("failed to get database connection")?;
|
||||
|
||||
let user: User = users::table
|
||||
.filter(users::username.eq(username))
|
||||
.first(&mut conn)
|
||||
.optional()?
|
||||
.ok_or_else(|| anyhow!("user '{}' not found", username))?;
|
||||
|
||||
let tenant: Tenant = tenants::table
|
||||
.find(tenant_id)
|
||||
.first(&mut conn)
|
||||
.optional()?
|
||||
.ok_or_else(|| anyhow!("tenant '{}' not found", tenant_id))?;
|
||||
|
||||
let removed = diesel::delete(
|
||||
user_memberships::table
|
||||
.filter(user_memberships::user_id.eq(user.id))
|
||||
.filter(user_memberships::tenant_id.eq(tenant.id)),
|
||||
)
|
||||
.execute(&mut conn)?;
|
||||
|
||||
if removed == 0 {
|
||||
println!(
|
||||
"user '{}' was not a member of tenant '{}'",
|
||||
username, tenant.name
|
||||
);
|
||||
} else {
|
||||
println!("removed user '{}' from tenant '{}'", username, tenant.name);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reanalyze_documents(pool: &PgPool, tenant_id: Uuid) -> Result<()> {
|
||||
let mut conn = pool.get().context("failed to get database connection")?;
|
||||
|
||||
let tenant: Tenant = tenants::table
|
||||
.find(tenant_id)
|
||||
.first(&mut conn)
|
||||
.optional()?
|
||||
.ok_or_else(|| anyhow!("tenant '{}' not found", tenant_id))?;
|
||||
|
||||
let targets: Vec<(Uuid, Uuid)> = documents::table
|
||||
.filter(documents::tenant_id.eq(tenant.id))
|
||||
.filter(documents::deleted_at.is_null())
|
||||
.select((documents::id, documents::current_version_id))
|
||||
.load(&mut conn)?;
|
||||
|
||||
if targets.is_empty() {
|
||||
println!("tenant '{}' has no active documents", tenant.name);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut queued = 0usize;
|
||||
for (document_id, version_id) in targets {
|
||||
enqueue_job(
|
||||
&mut conn,
|
||||
tenant.id,
|
||||
JOB_ANALYZE_DOCUMENT,
|
||||
serde_json::json!({
|
||||
"document_id": document_id,
|
||||
"document_version_id": version_id,
|
||||
"force": true,
|
||||
}),
|
||||
None,
|
||||
)
|
||||
.map_err(|err| anyhow!("failed to enqueue analyze job: {}", err))?;
|
||||
queued += 1;
|
||||
}
|
||||
|
||||
println!(
|
||||
"queued {} documents for re-analysis in tenant '{}'",
|
||||
queued, tenant.name
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn list_tenants(pool: &PgPool) -> Result<()> {
|
||||
let mut conn = pool.get().context("failed to get database connection")?;
|
||||
let tenants: Vec<Tenant> = tenants::table
|
||||
.order(tenants::name.asc())
|
||||
.load(&mut conn)
|
||||
.context("failed to load tenants")?;
|
||||
|
||||
if tenants.is_empty() {
|
||||
println!("No tenants found.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for tenant in tenants {
|
||||
println!("{} {}", tenant.id, tenant.name);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_assets_for_tenant(
|
||||
config: &AppConfig,
|
||||
pool: &PgPool,
|
||||
tenant_id: Uuid,
|
||||
) -> Result<()> {
|
||||
let s3_client = s3::build_client(config).await?;
|
||||
let storage: Arc<dyn ObjectStorage> =
|
||||
Arc::new(S3Storage::new(s3_client, config.s3_bucket.clone()));
|
||||
|
||||
let mut conn = pool.get().context("failed to get database connection")?;
|
||||
let tenant: Tenant = tenants::table
|
||||
.find(tenant_id)
|
||||
.first(&mut conn)
|
||||
.optional()?
|
||||
.ok_or_else(|| anyhow!("tenant '{}' not found", tenant_id))?;
|
||||
|
||||
let tenant_storage = TenantStorage::new(Arc::clone(&storage), &tenant)
|
||||
.with_context(|| format!("missing storage root for tenant {}", tenant.name))?;
|
||||
|
||||
let assets: Vec<DocumentAsset> = document_assets::table
|
||||
.filter(document_assets::tenant_id.eq(tenant.id))
|
||||
.load(&mut conn)
|
||||
.with_context(|| format!("failed to load assets for tenant {}", tenant.name))?;
|
||||
|
||||
if assets.is_empty() {
|
||||
println!("Tenant {}: no assets", tenant.name);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!(
|
||||
"Tenant {} ({}): deleting {} assets…",
|
||||
tenant.name,
|
||||
tenant.id,
|
||||
assets.len()
|
||||
);
|
||||
|
||||
let asset_ids: Vec<Uuid> = assets.iter().map(|asset| asset.id).collect();
|
||||
|
||||
let objects: Vec<DocumentAssetObject> = document_asset_objects::table
|
||||
.filter(document_asset_objects::tenant_id.eq(tenant.id))
|
||||
.filter(document_asset_objects::asset_id.eq_any(&asset_ids))
|
||||
.load(&mut conn)
|
||||
.with_context(|| format!("failed to load asset objects for tenant {}", tenant.name))?;
|
||||
|
||||
for object in &objects {
|
||||
if let Err(err) = tenant_storage.delete_object(&object.s3_key).await {
|
||||
eprintln!(
|
||||
"Failed to delete object {} (tenant {}): {err}",
|
||||
object.s3_key, tenant.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
diesel::delete(
|
||||
document_asset_objects::table
|
||||
.filter(document_asset_objects::tenant_id.eq(tenant.id))
|
||||
.filter(document_asset_objects::asset_id.eq_any(&asset_ids)),
|
||||
)
|
||||
.execute(&mut conn)
|
||||
.with_context(|| format!("failed to remove asset objects for tenant {}", tenant.name))?;
|
||||
|
||||
diesel::delete(document_assets::table.filter(document_assets::tenant_id.eq(tenant.id)))
|
||||
.execute(&mut conn)
|
||||
.with_context(|| format!("failed to remove asset records for tenant {}", tenant.name))?;
|
||||
|
||||
println!("Tenant {}: asset records deleted.", tenant.name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn quickwit_index(
|
||||
config: &AppConfig,
|
||||
pool: &PgPool,
|
||||
tenant_id: Uuid,
|
||||
method: Method,
|
||||
) -> Result<()> {
|
||||
let endpoint = config
|
||||
.quickwit_endpoint
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow!("quickwit endpoint not configured"))?;
|
||||
|
||||
let mut conn = pool.get().context("failed to get database connection")?;
|
||||
let tenant: Tenant = tenants::table
|
||||
.find(tenant_id)
|
||||
.first(&mut conn)
|
||||
.optional()?
|
||||
.ok_or_else(|| anyhow!("tenant '{}' not found", tenant_id))?;
|
||||
|
||||
let client = Client::new();
|
||||
let index_id = format!("documents-{}", tenant.id);
|
||||
let base_endpoint = endpoint.trim_end_matches('/');
|
||||
|
||||
match method {
|
||||
Method::POST => {
|
||||
ensure_quickwit_index(&client, base_endpoint, &index_id)
|
||||
.await
|
||||
.context("failed to ensure quickwit index")?;
|
||||
|
||||
diesel::update(tenants::table.filter(tenants::id.eq(tenant.id)))
|
||||
.set(tenants::quickwit_index.eq(Some(index_id.clone())))
|
||||
.execute(&mut conn)
|
||||
.context("failed to update tenant quickwit_index")?;
|
||||
|
||||
println!(
|
||||
"Tenant '{}' quickwit index set to '{}'.",
|
||||
tenant.name, index_id
|
||||
);
|
||||
}
|
||||
Method::DELETE => {
|
||||
let response = client
|
||||
.delete(format!("{}/api/v1/indexes/{}", base_endpoint, index_id))
|
||||
.send()
|
||||
.await
|
||||
.context("failed to send delete index request")?;
|
||||
|
||||
match response.status() {
|
||||
status if status.is_success() || status == StatusCode::NOT_FOUND => {
|
||||
diesel::update(tenants::table.filter(tenants::id.eq(tenant.id)))
|
||||
.set(tenants::quickwit_index.eq::<Option<String>>(None))
|
||||
.execute(&mut conn)
|
||||
.context("failed to clear tenant quickwit_index")?;
|
||||
|
||||
println!("Tenant '{}' quickwit index cleared.", tenant.name);
|
||||
}
|
||||
status => {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
bail!(
|
||||
"quickwit delete index failed with status {}: {}",
|
||||
status,
|
||||
body
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
use std::env;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use diesel::prelude::*;
|
||||
|
||||
use backend::{
|
||||
config::AppConfig,
|
||||
db,
|
||||
models::DocumentAsset,
|
||||
s3,
|
||||
schema::document_assets,
|
||||
storage::{ObjectStorage, S3Storage},
|
||||
};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let mut args = env::args().skip(1);
|
||||
match args.next().as_deref() {
|
||||
Some("delete-assets") => delete_all_assets().await?,
|
||||
Some(cmd) => {
|
||||
eprintln!("Unknown command: {cmd}\nUsage: maintenance delete-assets");
|
||||
std::process::exit(1);
|
||||
}
|
||||
None => {
|
||||
eprintln!("Usage: maintenance delete-assets");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_all_assets() -> Result<()> {
|
||||
let config = AppConfig::from_env()?;
|
||||
tracing::info!(
|
||||
component = "maintenance",
|
||||
database_url = %config.redacted_database_url(),
|
||||
pool_size = config.database_max_pool_size,
|
||||
s3_bucket = %config.s3_bucket,
|
||||
"loaded backend configuration"
|
||||
);
|
||||
let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?;
|
||||
|
||||
let s3_client = s3::build_client(&config).await?;
|
||||
let storage = S3Storage::new(s3_client, config.s3_bucket.clone());
|
||||
|
||||
let mut conn = pool.get().context("failed to get database connection")?;
|
||||
|
||||
let assets: Vec<DocumentAsset> = document_assets::table
|
||||
.load(&mut conn)
|
||||
.context("failed to load document assets")?;
|
||||
|
||||
if assets.is_empty() {
|
||||
println!("No assets found.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("Deleting {} assets…", assets.len());
|
||||
|
||||
for asset in &assets {
|
||||
if let Err(err) = storage.delete_object(&asset.s3_key).await {
|
||||
eprintln!(
|
||||
"Failed to delete object {} from storage: {err}",
|
||||
asset.s3_key
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
diesel::delete(document_assets::table)
|
||||
.execute(&mut conn)
|
||||
.context("failed to remove asset records")?;
|
||||
|
||||
println!("Asset records deleted.");
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
use backend::openapi::ApiDoc;
|
||||
use utoipa::OpenApi;
|
||||
|
||||
fn main() {
|
||||
let spec = ApiDoc::openapi();
|
||||
let json = serde_json::to_string_pretty(&spec).expect("serialize openapi");
|
||||
println!("{}", json);
|
||||
}
|
||||
@@ -1,47 +1,24 @@
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::net::TcpListener;
|
||||
use tower::make::Shared;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
use backend::auth::jwt::JwtService;
|
||||
use backend::config::AppConfig;
|
||||
use backend::db;
|
||||
use backend::routes::webdav;
|
||||
use backend::s3::build_client;
|
||||
use backend::state::AppState;
|
||||
use backend::storage::S3Storage;
|
||||
use backend::{routes::webdav, utils::bootstrap::init_component};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
dotenv::dotenv().ok();
|
||||
init_tracing();
|
||||
|
||||
let config = AppConfig::from_env()?;
|
||||
let state = init_component("webdav", None).await?;
|
||||
let webdav_host = state.config.webdav_host.clone();
|
||||
let webdav_port = state.config.webdav_port;
|
||||
tracing::info!(
|
||||
component = "webdav",
|
||||
database_url = %config.redacted_database_url(),
|
||||
pool_size = config.database_max_pool_size,
|
||||
server_host = %config.server_host,
|
||||
server_port = config.server_port,
|
||||
webdav_host = %config.webdav_host,
|
||||
webdav_port = config.webdav_port,
|
||||
quickwit_enabled = config.quickwit_endpoint.is_some(),
|
||||
s3_bucket = %config.s3_bucket,
|
||||
"loaded backend configuration"
|
||||
webdav_host = %webdav_host,
|
||||
webdav_port,
|
||||
"starting webdav server"
|
||||
);
|
||||
let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?;
|
||||
let s3_client = build_client(&config).await?;
|
||||
let storage = Arc::new(S3Storage::new(s3_client, config.s3_bucket.clone()));
|
||||
let jwt = JwtService::from_config(&config)?;
|
||||
|
||||
let state = AppState::new(pool, config, storage, jwt);
|
||||
let listen_addr: SocketAddr = {
|
||||
let config = state.config.clone();
|
||||
format!("{}:{}", config.webdav_host, config.webdav_port).parse()?
|
||||
};
|
||||
let router = webdav::create_router().with_state(state);
|
||||
let listen_addr: SocketAddr = format!("{}:{}", webdav_host, webdav_port).parse()?;
|
||||
let router = webdav::create_router().with_state(state.as_ref().clone());
|
||||
|
||||
let listener = TcpListener::bind(listen_addr).await?;
|
||||
tracing::info!("listening for WebDAV on {}", listen_addr);
|
||||
@@ -49,12 +26,3 @@ async fn main() -> anyhow::Result<()> {
|
||||
axum::serve(listener, Shared::new(router)).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn init_tracing() {
|
||||
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(filter)
|
||||
.with_target(false)
|
||||
.compact()
|
||||
.init();
|
||||
}
|
||||
|
||||
@@ -1,33 +1,13 @@
|
||||
use std::{sync::Arc, time::Duration};
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::signal;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
use backend::{
|
||||
auth::jwt::JwtService, config::AppConfig, db, default_handlers, s3::build_client,
|
||||
state::AppState, storage::S3Storage, Worker,
|
||||
};
|
||||
use backend::{default_handlers, utils::bootstrap::init_component, Worker};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
dotenv::dotenv().ok();
|
||||
init_tracing();
|
||||
|
||||
let config = AppConfig::from_env()?;
|
||||
tracing::info!(
|
||||
component = "worker",
|
||||
database_url = %config.redacted_database_url(),
|
||||
pool_size = 1,
|
||||
quickwit_enabled = config.quickwit_endpoint.is_some(),
|
||||
s3_bucket = %config.s3_bucket,
|
||||
"loaded backend configuration"
|
||||
);
|
||||
let pool = db::init_pool_with_size(&config.database_url, 1)?;
|
||||
let s3_client = build_client(&config).await?;
|
||||
let storage = Arc::new(S3Storage::new(s3_client, config.s3_bucket.clone()));
|
||||
let jwt = JwtService::from_config(&config)?;
|
||||
|
||||
let state = Arc::new(AppState::new(pool, config, storage, jwt));
|
||||
let state = init_component("worker", Some(1)).await?;
|
||||
tracing::info!(component = "worker", "starting worker process");
|
||||
let worker = Worker::new(state, default_handlers(), Duration::from_secs(2));
|
||||
|
||||
tokio::select! {
|
||||
@@ -39,12 +19,3 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn init_tracing() {
|
||||
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(filter)
|
||||
.with_target(false)
|
||||
.compact()
|
||||
.init();
|
||||
}
|
||||
|
||||
+150
-74
@@ -1,110 +1,93 @@
|
||||
use std::env;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use url::Url;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_aux::field_attributes::deserialize_bool_from_anything;
|
||||
|
||||
use crate::db::DEFAULT_MAX_POOL_SIZE;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct AppConfig {
|
||||
pub database_url: String,
|
||||
#[serde(default = "default_database_max_pool_size")]
|
||||
pub database_max_pool_size: u32,
|
||||
#[serde(default = "default_server_host")]
|
||||
pub server_host: String,
|
||||
#[serde(default = "default_server_port")]
|
||||
pub server_port: u16,
|
||||
#[serde(default = "default_webdav_host")]
|
||||
pub webdav_host: String,
|
||||
#[serde(default = "default_webdav_port")]
|
||||
pub webdav_port: u16,
|
||||
pub jwt_secret: String,
|
||||
#[serde(default = "default_jwt_issuer")]
|
||||
pub jwt_issuer: String,
|
||||
#[serde(default = "default_jwt_audience")]
|
||||
pub jwt_audience: String,
|
||||
#[serde(default = "default_jwt_expiry_minutes")]
|
||||
pub jwt_expiry_minutes: i64,
|
||||
#[serde(default = "default_download_token_audience")]
|
||||
pub download_token_audience: String,
|
||||
#[serde(default = "default_download_token_expiry_minutes")]
|
||||
pub download_token_expiry_minutes: i64,
|
||||
#[serde(default = "default_refresh_token_expiry_days")]
|
||||
pub refresh_token_expiry_days: i64,
|
||||
#[serde(
|
||||
default = "default_refresh_cookie_secure",
|
||||
deserialize_with = "deserialize_bool_from_anything"
|
||||
)]
|
||||
pub refresh_cookie_secure: bool,
|
||||
#[serde(default)]
|
||||
pub refresh_cookie_domain: Option<String>,
|
||||
#[serde(default)]
|
||||
pub cors_allowed_origin: Option<String>,
|
||||
#[serde(default)]
|
||||
pub aws_endpoint_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub aws_access_key_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub aws_secret_access_key: Option<String>,
|
||||
#[serde(default = "default_aws_region")]
|
||||
pub aws_region: String,
|
||||
pub s3_bucket: String,
|
||||
#[serde(default)]
|
||||
pub quickwit_endpoint: Option<String>,
|
||||
#[serde(default)]
|
||||
pub quickwit_index: Option<String>,
|
||||
#[serde(default = "default_worker_max_document_bytes")]
|
||||
pub worker_max_document_bytes: u64,
|
||||
#[serde(default = "default_upload_body_limit_bytes")]
|
||||
pub upload_body_limit_bytes: u64,
|
||||
#[serde(default)]
|
||||
pub webauthn_rp_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub webauthn_origin: Option<String>,
|
||||
#[serde(default = "default_webauthn_rp_name")]
|
||||
pub webauthn_rp_name: String,
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
pub fn from_env() -> Result<Self> {
|
||||
let database_url = env::var("DATABASE_URL").context("DATABASE_URL must be set")?;
|
||||
let database_max_pool_size = env::var("DATABASE_MAX_POOL_SIZE")
|
||||
.ok()
|
||||
.and_then(|value| value.parse().ok())
|
||||
.unwrap_or(DEFAULT_MAX_POOL_SIZE);
|
||||
let server_host = env::var("SERVER_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
|
||||
let server_port = env::var("SERVER_PORT")
|
||||
.unwrap_or_else(|_| "3000".to_string())
|
||||
.parse()
|
||||
.context("SERVER_PORT must be a valid u16")?;
|
||||
let webdav_host = env::var("WEBDAV_HOST").unwrap_or_else(|_| server_host.clone());
|
||||
let webdav_port = env::var("WEBDAV_PORT")
|
||||
.unwrap_or_else(|_| "3001".to_string())
|
||||
.parse()
|
||||
.context("WEBDAV_PORT must be a valid u16")?;
|
||||
let jwt_secret = env::var("JWT_SECRET").context("JWT_SECRET must be set")?;
|
||||
let jwt_issuer = env::var("JWT_ISSUER").unwrap_or_else(|_| "papercrate".to_string());
|
||||
let jwt_audience =
|
||||
env::var("JWT_AUDIENCE").unwrap_or_else(|_| "papercrate-clients".to_string());
|
||||
let jwt_expiry_minutes = env::var("JWT_EXPIRY_MINUTES")
|
||||
.unwrap_or_else(|_| "60".to_string())
|
||||
.parse()
|
||||
.context("JWT_EXPIRY_MINUTES must be an integer")?;
|
||||
let download_token_audience = env::var("DOWNLOAD_TOKEN_AUDIENCE")
|
||||
.unwrap_or_else(|_| "papercrate-download".to_string());
|
||||
let download_token_expiry_minutes = env::var("DOWNLOAD_TOKEN_EXPIRY_MINUTES")
|
||||
.unwrap_or_else(|_| "60".to_string())
|
||||
.parse()
|
||||
.context("DOWNLOAD_TOKEN_EXPIRY_MINUTES must be an integer")?;
|
||||
let refresh_token_expiry_days = env::var("REFRESH_TOKEN_EXPIRY_DAYS")
|
||||
.unwrap_or_else(|_| "30".to_string())
|
||||
.parse()
|
||||
.context("REFRESH_TOKEN_EXPIRY_DAYS must be an integer")?;
|
||||
let refresh_cookie_secure = env::var("REFRESH_COOKIE_SECURE")
|
||||
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||
.unwrap_or(false);
|
||||
let refresh_cookie_domain = env::var("REFRESH_COOKIE_DOMAIN").ok();
|
||||
let cors_allowed_origin = env::var("CORS_ALLOWED_ORIGIN").ok();
|
||||
let aws_endpoint_url = env::var("AWS_ENDPOINT_URL").ok();
|
||||
let aws_access_key_id = env::var("AWS_ACCESS_KEY_ID").ok();
|
||||
let aws_secret_access_key = env::var("AWS_SECRET_ACCESS_KEY").ok();
|
||||
let aws_region = env::var("AWS_REGION").unwrap_or_else(|_| "us-east-1".to_string());
|
||||
let s3_bucket = env::var("S3_BUCKET").context("S3_BUCKET must be set")?;
|
||||
let quickwit_endpoint = env::var("QUICKWIT_ENDPOINT").ok();
|
||||
let quickwit_index = env::var("QUICKWIT_INDEX").ok();
|
||||
pub fn load_and_log(component: &str) -> Result<Self> {
|
||||
dotenv::dotenv().ok();
|
||||
let config = Self::from_env()?;
|
||||
tracing::info!(
|
||||
component,
|
||||
database_url = %config.redacted_database_url(),
|
||||
pool_size = config.database_max_pool_size,
|
||||
quickwit_enabled = config.quickwit_endpoint.is_some(),
|
||||
passkeys_enabled = config.webauthn_origin.is_some(),
|
||||
s3_bucket = %config.s3_bucket,
|
||||
worker_max_document_bytes = config.worker_max_document_bytes,
|
||||
upload_body_limit_bytes = config.upload_body_limit_bytes,
|
||||
"loaded backend configuration"
|
||||
);
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
database_url,
|
||||
database_max_pool_size,
|
||||
server_host,
|
||||
server_port,
|
||||
webdav_host,
|
||||
webdav_port,
|
||||
jwt_secret,
|
||||
jwt_issuer,
|
||||
jwt_audience,
|
||||
jwt_expiry_minutes,
|
||||
download_token_audience,
|
||||
download_token_expiry_minutes,
|
||||
refresh_token_expiry_days,
|
||||
refresh_cookie_secure,
|
||||
refresh_cookie_domain,
|
||||
cors_allowed_origin,
|
||||
aws_endpoint_url,
|
||||
aws_access_key_id,
|
||||
aws_secret_access_key,
|
||||
aws_region,
|
||||
s3_bucket,
|
||||
quickwit_endpoint,
|
||||
quickwit_index,
|
||||
})
|
||||
pub fn from_env() -> Result<Self> {
|
||||
let config: AppConfig = envy::from_env()
|
||||
.context("failed to parse application configuration from environment")?;
|
||||
Ok(config.normalize())
|
||||
}
|
||||
|
||||
pub fn redacted_database_url(&self) -> String {
|
||||
@@ -112,11 +95,104 @@ impl AppConfig {
|
||||
}
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
fn normalize(mut self) -> Self {
|
||||
if self.webdav_host.is_empty() {
|
||||
self.webdav_host = self.server_host.clone();
|
||||
}
|
||||
|
||||
if self.webauthn_rp_id.is_none() {
|
||||
self.webauthn_rp_id = Some(self.server_host.clone());
|
||||
}
|
||||
|
||||
if self.webauthn_origin.is_none() {
|
||||
let scheme = if self.server_host == "127.0.0.1" || self.server_host == "localhost" {
|
||||
"http"
|
||||
} else {
|
||||
"https"
|
||||
};
|
||||
self.webauthn_origin = Some(format!(
|
||||
"{scheme}://{}:{}",
|
||||
self.server_host, self.server_port
|
||||
));
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
fn default_database_max_pool_size() -> u32 {
|
||||
DEFAULT_MAX_POOL_SIZE
|
||||
}
|
||||
|
||||
fn default_server_host() -> String {
|
||||
"127.0.0.1".to_string()
|
||||
}
|
||||
|
||||
fn default_server_port() -> u16 {
|
||||
3000
|
||||
}
|
||||
|
||||
fn default_webdav_host() -> String {
|
||||
String::new()
|
||||
}
|
||||
|
||||
fn default_webdav_port() -> u16 {
|
||||
3001
|
||||
}
|
||||
|
||||
fn default_jwt_issuer() -> String {
|
||||
"papercrate".to_string()
|
||||
}
|
||||
|
||||
fn default_jwt_audience() -> String {
|
||||
"papercrate-clients".to_string()
|
||||
}
|
||||
|
||||
fn default_jwt_expiry_minutes() -> i64 {
|
||||
60
|
||||
}
|
||||
|
||||
fn default_download_token_audience() -> String {
|
||||
"papercrate-download".to_string()
|
||||
}
|
||||
|
||||
fn default_download_token_expiry_minutes() -> i64 {
|
||||
60
|
||||
}
|
||||
|
||||
fn default_refresh_token_expiry_days() -> i64 {
|
||||
30
|
||||
}
|
||||
|
||||
fn default_refresh_cookie_secure() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn default_aws_region() -> String {
|
||||
"us-east-1".to_string()
|
||||
}
|
||||
|
||||
fn default_worker_max_document_bytes() -> u64 {
|
||||
200 * 1024 * 1024
|
||||
}
|
||||
|
||||
fn default_upload_body_limit_bytes() -> u64 {
|
||||
128 * 1024 * 1024
|
||||
}
|
||||
|
||||
fn default_webauthn_rp_name() -> String {
|
||||
"Papercrate".to_string()
|
||||
}
|
||||
|
||||
fn redact_database_url(raw: &str) -> String {
|
||||
match Url::parse(raw) {
|
||||
Ok(mut parsed) => {
|
||||
if parsed.password().is_some() {
|
||||
let _ = parsed.set_password(Some("*****"));
|
||||
parsed.to_string()
|
||||
} else {
|
||||
raw.to_string()
|
||||
}
|
||||
}
|
||||
Err(_) => "***".to_string(),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path as FsPath;
|
||||
|
||||
use diesel::prelude::*;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::models::{Document, DocumentAsset, DocumentAssetObject, DocumentVersion};
|
||||
use crate::schema::{document_asset_objects, document_assets, document_versions};
|
||||
use crate::state::AppState;
|
||||
use crate::utils::time::to_iso;
|
||||
|
||||
#[derive(Serialize, Clone, ToSchema)]
|
||||
pub struct DocumentAssetResponse {
|
||||
pub id: Uuid,
|
||||
pub asset_type: String,
|
||||
pub mime_type: String,
|
||||
pub metadata: Value,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cardinality: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone, ToSchema)]
|
||||
pub struct DocumentAssetObjectResponse {
|
||||
pub id: Uuid,
|
||||
pub ordinal: i32,
|
||||
pub metadata: Value,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub expires_at: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct DocumentAssetDetailResponse {
|
||||
pub id: Uuid,
|
||||
pub asset_type: String,
|
||||
pub mime_type: String,
|
||||
pub metadata: Value,
|
||||
pub created_at: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cardinality: Option<i32>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub objects: Vec<DocumentAssetObjectResponse>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone, ToSchema)]
|
||||
pub struct DocumentVersionResponse {
|
||||
pub id: Uuid,
|
||||
pub version_number: i32,
|
||||
pub size_bytes: i64,
|
||||
pub checksum: String,
|
||||
pub created_at: String,
|
||||
pub metadata: Value,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone, ToSchema)]
|
||||
pub struct DocumentVersionDetailResponse {
|
||||
#[serde(flatten)]
|
||||
pub version: DocumentVersionResponse,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub assets: Vec<DocumentAssetResponse>,
|
||||
pub download_path: String,
|
||||
}
|
||||
|
||||
pub fn build_download_path(
|
||||
state: &AppState,
|
||||
document: &Document,
|
||||
user_id: Uuid,
|
||||
) -> AppResult<String> {
|
||||
state
|
||||
.jwt
|
||||
.generate_download_token(document.id, user_id, document.tenant_id)
|
||||
.map(|token| format!("/download/{token}"))
|
||||
.map_err(|err| {
|
||||
tracing::error!(error = ?err, "failed to generate download token");
|
||||
AppError::internal("failed to generate download token")
|
||||
})
|
||||
}
|
||||
|
||||
pub fn to_version_response(version: DocumentVersion) -> DocumentVersionResponse {
|
||||
DocumentVersionResponse {
|
||||
id: version.id,
|
||||
version_number: version.version_number,
|
||||
size_bytes: version.size_bytes,
|
||||
checksum: version.checksum,
|
||||
created_at: to_iso(version.created_at),
|
||||
metadata: version.metadata,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_asset_summary(asset: DocumentAsset) -> DocumentAssetResponse {
|
||||
DocumentAssetResponse {
|
||||
id: asset.id,
|
||||
asset_type: asset.asset_type,
|
||||
mime_type: asset.mime_type,
|
||||
metadata: asset.metadata,
|
||||
cardinality: asset.cardinality,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_asset_detail_response(
|
||||
asset: DocumentAsset,
|
||||
objects: Vec<DocumentAssetObjectResponse>,
|
||||
) -> DocumentAssetDetailResponse {
|
||||
DocumentAssetDetailResponse {
|
||||
id: asset.id,
|
||||
asset_type: asset.asset_type,
|
||||
mime_type: asset.mime_type,
|
||||
metadata: asset.metadata,
|
||||
created_at: to_iso(asset.created_at),
|
||||
cardinality: asset.cardinality,
|
||||
objects,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_asset_object_response(
|
||||
object: DocumentAssetObject,
|
||||
url: Option<String>,
|
||||
expires_at: Option<i64>,
|
||||
) -> DocumentAssetObjectResponse {
|
||||
DocumentAssetObjectResponse {
|
||||
id: object.id,
|
||||
ordinal: object.ordinal,
|
||||
metadata: object.metadata,
|
||||
url,
|
||||
expires_at,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete_asset(state: &AppState, tenant_id: Uuid, asset_id: Uuid) -> AppResult<()> {
|
||||
let mut conn = state.db_for_tenant(tenant_id)?;
|
||||
|
||||
diesel::delete(
|
||||
document_assets::table
|
||||
.filter(document_assets::id.eq(asset_id))
|
||||
.filter(document_assets::tenant_id.eq(tenant_id)),
|
||||
)
|
||||
.execute(&mut conn)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn load_asset_responses(
|
||||
state: &AppState,
|
||||
tenant_id: Uuid,
|
||||
version_id: Uuid,
|
||||
) -> AppResult<Vec<DocumentAssetResponse>> {
|
||||
let mut conn = state.db_for_tenant(tenant_id)?;
|
||||
let assets: Vec<(DocumentAsset, Option<DocumentAssetObject>)> = document_assets::table
|
||||
.left_outer_join(
|
||||
document_asset_objects::table.on(document_asset_objects::asset_id
|
||||
.eq(document_assets::id)
|
||||
.and(document_asset_objects::ordinal.eq(1))),
|
||||
)
|
||||
.filter(document_assets::document_version_id.eq(version_id))
|
||||
.filter(document_assets::tenant_id.eq(tenant_id))
|
||||
.order(document_assets::created_at.asc())
|
||||
.select((
|
||||
document_assets::all_columns,
|
||||
document_asset_objects::all_columns.nullable(),
|
||||
))
|
||||
.load(&mut conn)?;
|
||||
drop(conn);
|
||||
|
||||
Ok(assets
|
||||
.into_iter()
|
||||
.map(|(asset, _)| to_asset_summary(asset))
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn load_primary_assets(
|
||||
state: &AppState,
|
||||
tenant_id: Uuid,
|
||||
documents: &[Document],
|
||||
) -> AppResult<HashMap<Uuid, (DocumentVersionResponse, Vec<DocumentAssetResponse>)>> {
|
||||
if documents.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let mut doc_to_version: HashMap<Uuid, Uuid> = HashMap::with_capacity(documents.len());
|
||||
let mut version_ids: Vec<Uuid> = Vec::with_capacity(documents.len());
|
||||
for doc in documents {
|
||||
doc_to_version.insert(doc.id, doc.current_version_id);
|
||||
version_ids.push(doc.current_version_id);
|
||||
}
|
||||
|
||||
version_ids.sort();
|
||||
version_ids.dedup();
|
||||
|
||||
let mut conn = state.db_for_tenant(tenant_id)?;
|
||||
let versions: Vec<DocumentVersion> = document_versions::table
|
||||
.filter(document_versions::id.eq_any(&version_ids))
|
||||
.load(&mut conn)?;
|
||||
|
||||
let mut version_map: HashMap<Uuid, DocumentVersion> = HashMap::new();
|
||||
for version in versions {
|
||||
version_map.insert(version.id, version);
|
||||
}
|
||||
|
||||
let assets: Vec<(DocumentAsset, Option<DocumentAssetObject>)> = document_assets::table
|
||||
.left_outer_join(
|
||||
document_asset_objects::table.on(document_asset_objects::asset_id
|
||||
.eq(document_assets::id)
|
||||
.and(document_asset_objects::ordinal.eq(1))),
|
||||
)
|
||||
.filter(document_assets::document_version_id.eq_any(&version_ids))
|
||||
.order((
|
||||
document_assets::document_version_id.asc(),
|
||||
document_assets::created_at.asc(),
|
||||
))
|
||||
.select((
|
||||
document_assets::all_columns,
|
||||
document_asset_objects::all_columns.nullable(),
|
||||
))
|
||||
.load(&mut conn)?;
|
||||
|
||||
drop(conn);
|
||||
|
||||
let mut assets_by_version: HashMap<Uuid, Vec<DocumentAssetResponse>> = HashMap::new();
|
||||
for (asset, _object) in assets {
|
||||
let version_id = asset.document_version_id;
|
||||
let response = to_asset_summary(asset);
|
||||
assets_by_version
|
||||
.entry(version_id)
|
||||
.or_default()
|
||||
.push(response);
|
||||
}
|
||||
|
||||
let mut result: HashMap<Uuid, (DocumentVersionResponse, Vec<DocumentAssetResponse>)> =
|
||||
HashMap::with_capacity(doc_to_version.len());
|
||||
for (doc_id, version_id) in doc_to_version {
|
||||
if let Some(version) = version_map.remove(&version_id) {
|
||||
let assets = assets_by_version.remove(&version_id).unwrap_or_default();
|
||||
result.insert(doc_id, (to_version_response(version), assets));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn derive_document_title(original: &str) -> String {
|
||||
let trimmed = original.trim();
|
||||
if trimmed.is_empty() {
|
||||
return "Document".to_string();
|
||||
}
|
||||
|
||||
let stem = FsPath::new(trimmed)
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
stem.unwrap_or_else(|| trimmed.to_string())
|
||||
}
|
||||
|
||||
pub fn filename_with_retained_extension(title: &str, current_filename: &str) -> String {
|
||||
let extension = FsPath::new(current_filename)
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str());
|
||||
|
||||
if let Some(ext) = extension {
|
||||
if title
|
||||
.rsplit_once('.')
|
||||
.map(|(_, existing_ext)| existing_ext.eq_ignore_ascii_case(ext))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
title.to_string()
|
||||
} else {
|
||||
format!("{title}.{ext}")
|
||||
}
|
||||
} else {
|
||||
title.to_string()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chrono::Utc;
|
||||
use diesel::prelude::*;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::models::{Correspondent, DocumentCorrespondent, NewDocumentCorrespondent};
|
||||
use crate::schema::{correspondents, document_correspondents, documents};
|
||||
use crate::utils::time::to_iso;
|
||||
|
||||
#[derive(Serialize, Clone, ToSchema)]
|
||||
pub struct DocumentCorrespondentResponse {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub metadata: Value,
|
||||
pub assigned_at: String,
|
||||
}
|
||||
|
||||
pub fn normalize_correspondent_ids(ids: &[Uuid]) -> AppResult<Vec<Uuid>> {
|
||||
let mut unique: Vec<Uuid> = ids.iter().copied().collect();
|
||||
unique.sort_unstable();
|
||||
unique.dedup();
|
||||
|
||||
if unique.is_empty() {
|
||||
return Err(AppError::bad_request(
|
||||
"assignments must contain at least one correspondent",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(unique)
|
||||
}
|
||||
|
||||
pub fn insert_document_correspondents(
|
||||
conn: &mut PgConnection,
|
||||
tenant_id: Uuid,
|
||||
document_id: Uuid,
|
||||
user_id: Uuid,
|
||||
correspondent_ids: &[Uuid],
|
||||
) -> AppResult<usize> {
|
||||
let ids = normalize_correspondent_ids(correspondent_ids)?;
|
||||
|
||||
let existing: Vec<Uuid> = correspondents::table
|
||||
.filter(correspondents::id.eq_any(&ids))
|
||||
.filter(correspondents::tenant_id.eq(tenant_id))
|
||||
.select(correspondents::id)
|
||||
.load(conn)?;
|
||||
|
||||
if existing.len() != ids.len() {
|
||||
return Err(AppError::bad_request(
|
||||
"one or more correspondents do not exist",
|
||||
));
|
||||
}
|
||||
|
||||
let new_rows: Vec<NewDocumentCorrespondent> = ids
|
||||
.into_iter()
|
||||
.map(|correspondent_id| NewDocumentCorrespondent {
|
||||
document_id,
|
||||
correspondent_id,
|
||||
assigned_by: Some(user_id),
|
||||
tenant_id,
|
||||
})
|
||||
.collect();
|
||||
|
||||
if new_rows.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let inserted = diesel::insert_into(document_correspondents::table)
|
||||
.values(&new_rows)
|
||||
.on_conflict_do_nothing()
|
||||
.execute(conn)?;
|
||||
|
||||
if inserted > 0 {
|
||||
diesel::update(
|
||||
documents::table
|
||||
.find(document_id)
|
||||
.filter(documents::tenant_id.eq(tenant_id)),
|
||||
)
|
||||
.set(documents::updated_at.eq(Utc::now().naive_utc()))
|
||||
.execute(conn)?;
|
||||
}
|
||||
|
||||
Ok(inserted)
|
||||
}
|
||||
|
||||
pub fn load_correspondents_for_documents(
|
||||
conn: &mut PgConnection,
|
||||
document_ids: &[Uuid],
|
||||
) -> AppResult<HashMap<Uuid, Vec<DocumentCorrespondentResponse>>> {
|
||||
if document_ids.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let rows: Vec<(DocumentCorrespondent, Correspondent)> = document_correspondents::table
|
||||
.inner_join(correspondents::table)
|
||||
.filter(document_correspondents::document_id.eq_any(document_ids))
|
||||
.order((
|
||||
document_correspondents::document_id.asc(),
|
||||
document_correspondents::assigned_at.asc(),
|
||||
))
|
||||
.load(conn)?;
|
||||
|
||||
let mut map: HashMap<Uuid, Vec<DocumentCorrespondentResponse>> = HashMap::new();
|
||||
for (assignment, correspondent) in rows {
|
||||
map.entry(assignment.document_id)
|
||||
.or_default()
|
||||
.push(DocumentCorrespondentResponse {
|
||||
id: correspondent.id,
|
||||
name: correspondent.name,
|
||||
metadata: correspondent.metadata,
|
||||
assigned_at: to_iso(assignment.assigned_at),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(map)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
use diesel::dsl::exists;
|
||||
use diesel::prelude::*;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppResult;
|
||||
use crate::schema::folders;
|
||||
use crate::utils::validation::ensure_exists;
|
||||
|
||||
pub fn ensure_folder_exists_on_conn(
|
||||
conn: &mut PgConnection,
|
||||
tenant_id: Uuid,
|
||||
folder_id: Uuid,
|
||||
) -> AppResult<()> {
|
||||
let exists: bool = diesel::select(exists(
|
||||
folders::table
|
||||
.filter(folders::id.eq(folder_id))
|
||||
.filter(folders::tenant_id.eq(tenant_id)),
|
||||
))
|
||||
.get_result(conn)?;
|
||||
ensure_exists(exists, "folder")
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
use serde_json::{map::Entry, Map, Value};
|
||||
|
||||
use crate::error::{AppError, AppResult};
|
||||
|
||||
pub fn merge_document_metadata(existing: Value, updates: Value) -> AppResult<Value> {
|
||||
let mut base = match existing {
|
||||
Value::Object(map) => map,
|
||||
Value::Null => Map::new(),
|
||||
_ => {
|
||||
return Err(AppError::bad_request(
|
||||
"existing metadata is not an object; set replace=true to overwrite",
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let incoming = match updates {
|
||||
Value::Object(map) => map,
|
||||
_ => {
|
||||
return Err(AppError::bad_request(
|
||||
"metadata value must be a JSON object when replace is false",
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
merge_metadata_maps(&mut base, incoming);
|
||||
Ok(Value::Object(base))
|
||||
}
|
||||
|
||||
fn merge_metadata_maps(target: &mut Map<String, Value>, updates: Map<String, Value>) {
|
||||
for (key, value) in updates {
|
||||
match target.entry(key) {
|
||||
Entry::Occupied(mut entry) => {
|
||||
let existing = entry.get_mut();
|
||||
match value {
|
||||
Value::Object(update_map) => {
|
||||
if let Value::Object(existing_map) = existing {
|
||||
merge_metadata_maps(existing_map, update_map);
|
||||
} else {
|
||||
*existing = Value::Object(update_map);
|
||||
}
|
||||
}
|
||||
other => {
|
||||
*existing = other;
|
||||
}
|
||||
}
|
||||
}
|
||||
Entry::Vacant(entry) => {
|
||||
entry.insert(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
pub mod asset;
|
||||
pub mod correspondents;
|
||||
pub mod folders;
|
||||
pub mod metadata;
|
||||
pub mod search;
|
||||
pub mod tags;
|
||||
@@ -0,0 +1,290 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use anyhow::{anyhow, bail, Result};
|
||||
use reqwest::{Client, StatusCode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use tracing::{debug, error};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::models::{Document, DocumentVersion};
|
||||
|
||||
pub const QUICKWIT_MAX_HITS: usize = 200;
|
||||
|
||||
pub fn build_quickwit_query(input: &str) -> Option<String> {
|
||||
let tokens: Vec<String> = input
|
||||
.split_whitespace()
|
||||
.filter(|token| !token.is_empty())
|
||||
.map(|token| {
|
||||
let normalized = token.to_lowercase();
|
||||
escape_quickwit_token(&normalized)
|
||||
})
|
||||
.collect();
|
||||
|
||||
if tokens.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let parts: Vec<String> = tokens
|
||||
.into_iter()
|
||||
.map(|token| format!("(title:{token} OR text:{token})"))
|
||||
.collect();
|
||||
|
||||
Some(parts.join(" AND "))
|
||||
}
|
||||
|
||||
pub fn escape_quickwit_token(token: &str) -> String {
|
||||
let mut escaped = String::with_capacity(token.len());
|
||||
for ch in token.chars() {
|
||||
match ch {
|
||||
'+' | '-' | '&' | '|' | '!' | '(' | ')' | '{' | '}' | '[' | ']' | '^' | '"' | '~'
|
||||
| '*' | '?' | ':' | '\\' | '/' => {
|
||||
escaped.push('\\');
|
||||
escaped.push(ch);
|
||||
}
|
||||
_ => escaped.push(ch),
|
||||
}
|
||||
}
|
||||
escaped
|
||||
}
|
||||
|
||||
pub async fn quickwit_search(
|
||||
endpoint: &str,
|
||||
index: &str,
|
||||
tenant_id: Uuid,
|
||||
query: &str,
|
||||
) -> Result<Vec<Uuid>> {
|
||||
let tenant_clause = format!("tenant_id:{}", tenant_id);
|
||||
let quickwit_query = match build_quickwit_query(query) {
|
||||
Some(q) => {
|
||||
debug!(%query, quickwit_query = %q, "built quickwit search query");
|
||||
format!("{} AND ({})", tenant_clause, q)
|
||||
}
|
||||
None => {
|
||||
debug!(%query, "quickwit search skipped because query produced no tokens");
|
||||
return Ok(vec![]);
|
||||
}
|
||||
};
|
||||
|
||||
let client = Client::new();
|
||||
let url = format!("{}/api/v1/{}/search", endpoint.trim_end_matches('/'), index);
|
||||
|
||||
let payload = json!({
|
||||
"query": quickwit_query,
|
||||
"max_hits": QUICKWIT_MAX_HITS,
|
||||
});
|
||||
|
||||
debug!(%url, payload = %payload, "sending quickwit search request");
|
||||
let response = client.post(url).json(&payload).send().await?;
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
error!(%status, body = %body, "quickwit search request failed");
|
||||
return Err(anyhow!(
|
||||
"quickwit search failed with status {status}: {body}"
|
||||
));
|
||||
}
|
||||
|
||||
let data: QuickwitSearchResponse = response.json().await?;
|
||||
debug!("quickwit search response parsed successfully");
|
||||
let QuickwitSearchResponse { hits } = data;
|
||||
|
||||
let total_hits = hits.len();
|
||||
let mut seen = HashSet::new();
|
||||
let mut doc_ids = Vec::with_capacity(total_hits);
|
||||
|
||||
for hit in hits {
|
||||
if let Some(doc_id) = extract_document_id(&hit) {
|
||||
if seen.insert(doc_id) {
|
||||
doc_ids.push(doc_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debug!(
|
||||
total_hits = total_hits,
|
||||
unique_ids = doc_ids.len(),
|
||||
"quickwit search completed"
|
||||
);
|
||||
Ok(doc_ids)
|
||||
}
|
||||
|
||||
pub fn quickwit_index_template(index_id: &str) -> Value {
|
||||
json!({
|
||||
"version": "0.8",
|
||||
"index_id": index_id,
|
||||
"doc_mapping": {
|
||||
"tokenizers": [
|
||||
{
|
||||
"name": "substring",
|
||||
"type": "ngram",
|
||||
"min_gram": 2,
|
||||
"max_gram": 20,
|
||||
"prefix_only": false
|
||||
}
|
||||
],
|
||||
"field_mappings": [
|
||||
{ "name": "tenant_id", "type": "text", "stored": true },
|
||||
{ "name": "document_id", "type": "text", "stored": true },
|
||||
{ "name": "version_id", "type": "text", "stored": true },
|
||||
{ "name": "title", "type": "text", "tokenizer": "substring", "stored": true },
|
||||
{ "name": "text", "type": "text", "tokenizer": "substring", "record": "position" }
|
||||
]
|
||||
},
|
||||
"search_settings": {
|
||||
"default_search_fields": ["title", "text"]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn ensure_quickwit_index(client: &Client, endpoint: &str, index_id: &str) -> Result<()> {
|
||||
let base = endpoint.trim_end_matches('/');
|
||||
let create_url = format!("{}/api/v1/indexes", base);
|
||||
let payload = quickwit_index_template(index_id);
|
||||
|
||||
let response = client.post(&create_url).json(&payload).send().await?;
|
||||
match response.status() {
|
||||
status if status.is_success() => Ok(()),
|
||||
StatusCode::CONFLICT => {
|
||||
let lookup_url = format!("{}/api/v1/indexes/{}", base, index_id);
|
||||
let lookup = client.get(&lookup_url).send().await?;
|
||||
if lookup.status().is_success() {
|
||||
Ok(())
|
||||
} else {
|
||||
let status = lookup.status();
|
||||
let body = lookup.text().await.unwrap_or_default();
|
||||
bail!("quickwit index lookup failed with status {status}: {body}");
|
||||
}
|
||||
}
|
||||
status => {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
bail!("quickwit create index failed with status {status}: {body}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn extract_document_id(hit: &Value) -> Option<Uuid> {
|
||||
for key in ["_source", "source", "fields", "stored_fields"] {
|
||||
if let Some(value) = hit.get(key) {
|
||||
if let Some(uuid) = extract_uuid_from_value(value) {
|
||||
return Some(uuid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(value) = hit.get("document_id") {
|
||||
if let Some(uuid) = extract_uuid_from_value(value) {
|
||||
return Some(uuid);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn extract_uuid_from_value(value: &Value) -> Option<Uuid> {
|
||||
if let Some(obj) = value.as_object() {
|
||||
if let Some(inner) = obj.get("document_id") {
|
||||
return parse_uuid_value(inner);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(arr) = value.as_array() {
|
||||
for item in arr {
|
||||
if let Some(uuid) = extract_uuid_from_value(item) {
|
||||
return Some(uuid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
parse_uuid_value(value)
|
||||
}
|
||||
|
||||
pub fn parse_uuid_value(value: &Value) -> Option<Uuid> {
|
||||
if let Some(s) = value.as_str() {
|
||||
return Uuid::parse_str(s).ok();
|
||||
}
|
||||
|
||||
if let Some(arr) = value.as_array() {
|
||||
for item in arr {
|
||||
if let Some(uuid) = parse_uuid_value(item) {
|
||||
return Some(uuid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct QuickwitSearchResponse {
|
||||
#[serde(default)]
|
||||
hits: Vec<Value>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct QuickwitIngestRecord {
|
||||
pub document_id: Uuid,
|
||||
pub version_id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub title: String,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
pub fn build_quickwit_ingest_record(
|
||||
document: &Document,
|
||||
version: &DocumentVersion,
|
||||
tenant_id: Uuid,
|
||||
text: &str,
|
||||
) -> QuickwitIngestRecord {
|
||||
QuickwitIngestRecord {
|
||||
document_id: document.id,
|
||||
version_id: version.id,
|
||||
tenant_id,
|
||||
title: document.title.to_lowercase(),
|
||||
text: text.to_lowercase(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn quickwit_ingest(
|
||||
client: &Client,
|
||||
endpoint: &str,
|
||||
index: &str,
|
||||
records: &[QuickwitIngestRecord],
|
||||
) -> Result<()> {
|
||||
if records.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let url = format!(
|
||||
"{}/api/v1/{}/ingest?commit=auto",
|
||||
endpoint.trim_end_matches('/'),
|
||||
index
|
||||
);
|
||||
|
||||
let mut body = String::new();
|
||||
for record in records {
|
||||
let line = serde_json::to_string(record)?;
|
||||
body.push_str(&line);
|
||||
body.push('\n');
|
||||
}
|
||||
|
||||
debug!(%url, lines = records.len(), "sending quickwit ingest request");
|
||||
let response = client
|
||||
.post(url)
|
||||
.header("content-type", "application/x-ndjson")
|
||||
.body(body)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
error!(%status, %body, "quickwit ingest request failed");
|
||||
return Err(anyhow!(
|
||||
"quickwit ingest failed with status {status}: {body}"
|
||||
));
|
||||
}
|
||||
|
||||
debug!("quickwit ingest request succeeded");
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use diesel::prelude::*;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::models::{Document, NewDocumentTag, Tag};
|
||||
use crate::schema::{document_tags, tags};
|
||||
|
||||
pub fn assign_tags(
|
||||
conn: &mut PgConnection,
|
||||
tenant_id: Uuid,
|
||||
document: &Document,
|
||||
raw_tag_ids: &[Uuid],
|
||||
assigned_by: Option<Uuid>,
|
||||
) -> AppResult<usize> {
|
||||
if raw_tag_ids.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let mut tag_ids: Vec<Uuid> = raw_tag_ids.iter().copied().collect();
|
||||
tag_ids.sort_unstable();
|
||||
tag_ids.dedup();
|
||||
|
||||
if tag_ids.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let existing: Vec<Uuid> = tags::table
|
||||
.filter(tags::id.eq_any(&tag_ids))
|
||||
.filter(tags::tenant_id.eq(tenant_id))
|
||||
.select(tags::id)
|
||||
.load(conn)?;
|
||||
|
||||
if existing.len() != tag_ids.len() {
|
||||
return Err(AppError::bad_request("one or more tags do not exist"));
|
||||
}
|
||||
|
||||
let new_tags: Vec<NewDocumentTag> = tag_ids
|
||||
.into_iter()
|
||||
.map(|tag_id| NewDocumentTag {
|
||||
document_id: document.id,
|
||||
tag_id,
|
||||
assigned_by,
|
||||
tenant_id,
|
||||
})
|
||||
.collect();
|
||||
|
||||
if new_tags.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let inserted = diesel::insert_into(document_tags::table)
|
||||
.values(&new_tags)
|
||||
.on_conflict_do_nothing()
|
||||
.execute(conn)?;
|
||||
|
||||
Ok(inserted)
|
||||
}
|
||||
|
||||
pub fn load_tags_for_documents(
|
||||
conn: &mut PgConnection,
|
||||
document_ids: &[Uuid],
|
||||
) -> AppResult<HashMap<Uuid, Vec<Tag>>> {
|
||||
if document_ids.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let rows: Vec<(Uuid, Tag)> = document_tags::table
|
||||
.inner_join(tags::table)
|
||||
.filter(document_tags::document_id.eq_any(document_ids))
|
||||
.select((document_tags::document_id, tags::all_columns))
|
||||
.load(conn)?;
|
||||
|
||||
let mut map: HashMap<Uuid, Vec<Tag>> = HashMap::new();
|
||||
for (doc_id, tag) in rows {
|
||||
map.entry(doc_id).or_default().push(tag);
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
+18
-1
@@ -12,6 +12,7 @@ pub type AppResult<T> = Result<T, AppError>;
|
||||
pub struct AppError {
|
||||
status: StatusCode,
|
||||
message: String,
|
||||
code: Option<String>,
|
||||
}
|
||||
|
||||
impl AppError {
|
||||
@@ -19,6 +20,7 @@ impl AppError {
|
||||
Self {
|
||||
status,
|
||||
message: message.into(),
|
||||
code: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +28,10 @@ impl AppError {
|
||||
Self::new(StatusCode::BAD_REQUEST, message)
|
||||
}
|
||||
|
||||
pub fn conflict(message: impl Into<String>) -> Self {
|
||||
Self::new(StatusCode::CONFLICT, message)
|
||||
}
|
||||
|
||||
pub fn unauthorized() -> Self {
|
||||
Self::new(StatusCode::UNAUTHORIZED, "unauthorized")
|
||||
}
|
||||
@@ -37,6 +43,11 @@ impl AppError {
|
||||
pub fn internal<E: Display>(error: E) -> Self {
|
||||
Self::new(StatusCode::INTERNAL_SERVER_ERROR, error.to_string())
|
||||
}
|
||||
|
||||
pub fn with_code(mut self, code: impl Into<String>) -> Self {
|
||||
self.code = Some(code.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
@@ -44,6 +55,7 @@ impl IntoResponse for AppError {
|
||||
let status = self.status;
|
||||
let body = Json(ErrorResponse {
|
||||
error: self.message,
|
||||
code: self.code,
|
||||
});
|
||||
(status, body).into_response()
|
||||
}
|
||||
@@ -52,13 +64,18 @@ impl IntoResponse for AppError {
|
||||
#[derive(Serialize)]
|
||||
struct ErrorResponse {
|
||||
error: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
code: Option<String>,
|
||||
}
|
||||
|
||||
impl From<diesel::result::Error> for AppError {
|
||||
fn from(value: diesel::result::Error) -> Self {
|
||||
match value {
|
||||
diesel::result::Error::NotFound => AppError::not_found(),
|
||||
_ => AppError::internal(value),
|
||||
other => {
|
||||
tracing::error!(error = ?other, "database operation failed");
|
||||
AppError::internal("database operation failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ pub const JOB_ANALYZE_DOCUMENT: &str = "analyze-document";
|
||||
pub const JOB_GENERATE_THUMBNAILS: &str = "generate-thumbnails";
|
||||
pub const JOB_GENERATE_OCR_TEXT: &str = "generate-ocr-text";
|
||||
pub const JOB_INDEX_DOCUMENT_TEXT: &str = "index-document-text";
|
||||
pub const JOB_PROVISION_TENANT: &str = "provision-tenant";
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum JobQueueError {
|
||||
@@ -30,6 +31,7 @@ pub type JobQueueResult<T> = Result<T, JobQueueError>;
|
||||
|
||||
pub fn enqueue_job(
|
||||
conn: &mut PgConnection,
|
||||
tenant_id: Uuid,
|
||||
job_type: &str,
|
||||
payload: Value,
|
||||
run_after: Option<NaiveDateTime>,
|
||||
@@ -40,6 +42,7 @@ pub fn enqueue_job(
|
||||
payload,
|
||||
status: STATUS_QUEUED.to_string(),
|
||||
run_after: run_after.unwrap_or_else(|| Utc::now().naive_utc()),
|
||||
tenant_id,
|
||||
};
|
||||
|
||||
diesel::insert_into(jobs::table)
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
pub mod auth;
|
||||
pub mod config;
|
||||
pub mod db;
|
||||
pub mod documents;
|
||||
pub mod error;
|
||||
pub mod jobs;
|
||||
pub mod models;
|
||||
pub mod openapi;
|
||||
pub mod routes;
|
||||
pub mod s3;
|
||||
pub mod schema;
|
||||
pub mod state;
|
||||
pub mod storage;
|
||||
pub mod tenants;
|
||||
pub mod utils;
|
||||
pub mod workers;
|
||||
pub use workers::{default_handlers, Worker};
|
||||
|
||||
+9
-38
@@ -1,57 +1,28 @@
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::net::TcpListener;
|
||||
use tower::make::Shared;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
use backend::auth::jwt::JwtService;
|
||||
use backend::config::AppConfig;
|
||||
use backend::db;
|
||||
use backend::routes;
|
||||
use backend::s3::build_client;
|
||||
use backend::state::AppState;
|
||||
use backend::storage::S3Storage;
|
||||
use backend::{routes, utils::bootstrap::init_component};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
dotenv::dotenv().ok();
|
||||
init_tracing();
|
||||
|
||||
let config = AppConfig::from_env()?;
|
||||
let state = init_component("api", None).await?;
|
||||
let server_host = state.config.server_host.clone();
|
||||
let server_port = state.config.server_port;
|
||||
tracing::info!(
|
||||
component = "api",
|
||||
database_url = %config.redacted_database_url(),
|
||||
pool_size = config.database_max_pool_size,
|
||||
server_host = %config.server_host,
|
||||
server_port = config.server_port,
|
||||
quickwit_enabled = config.quickwit_endpoint.is_some(),
|
||||
s3_bucket = %config.s3_bucket,
|
||||
"loaded backend configuration"
|
||||
server_host = %server_host,
|
||||
server_port,
|
||||
"starting api server"
|
||||
);
|
||||
let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?;
|
||||
let s3_client = build_client(&config).await?;
|
||||
let storage = Arc::new(S3Storage::new(s3_client, config.s3_bucket.clone()));
|
||||
let jwt = JwtService::from_config(&config)?;
|
||||
|
||||
let state = AppState::new(pool, config, storage, jwt);
|
||||
let router = routes::create_router(state.as_ref().clone());
|
||||
|
||||
let router = routes::create_router(state.clone());
|
||||
|
||||
let addr: SocketAddr =
|
||||
format!("{}:{}", state.config.server_host, state.config.server_port).parse()?;
|
||||
let addr: SocketAddr = format!("{}:{}", server_host, server_port).parse()?;
|
||||
let listener = TcpListener::bind(addr).await?;
|
||||
tracing::info!("listening on {}", addr);
|
||||
|
||||
axum::serve(listener, Shared::new(router)).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn init_tracing() {
|
||||
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(filter)
|
||||
.with_target(false)
|
||||
.compact()
|
||||
.init();
|
||||
}
|
||||
|
||||
+237
-11
@@ -1,16 +1,117 @@
|
||||
use chrono::NaiveDateTime;
|
||||
use diesel::deserialize::FromSql;
|
||||
use diesel::pg::{Pg, PgValue};
|
||||
use diesel::prelude::*;
|
||||
use diesel::serialize::{IsNull, Output, ToSql};
|
||||
use diesel::{deserialize, serialize, AsExpression, FromSqlRow};
|
||||
use serde_json::Value;
|
||||
use std::fmt;
|
||||
use std::io::Write;
|
||||
use std::str;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::schema::sql_types::TenantStatus as TenantStatusSql;
|
||||
use crate::schema::*;
|
||||
|
||||
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
||||
#[diesel(table_name = user_memberships)]
|
||||
#[diesel(belongs_to(User, foreign_key = user_id))]
|
||||
#[diesel(belongs_to(Tenant, foreign_key = tenant_id))]
|
||||
pub struct UserMembership {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Insertable)]
|
||||
#[diesel(table_name = user_memberships)]
|
||||
pub struct NewUserMembership {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, AsExpression, FromSqlRow)]
|
||||
#[diesel(sql_type = TenantStatusSql)]
|
||||
pub enum TenantStatus {
|
||||
Creating,
|
||||
Active,
|
||||
Suspended,
|
||||
Deleting,
|
||||
Error,
|
||||
}
|
||||
|
||||
impl TenantStatus {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
TenantStatus::Creating => "creating",
|
||||
TenantStatus::Active => "active",
|
||||
TenantStatus::Suspended => "suspended",
|
||||
TenantStatus::Deleting => "deleting",
|
||||
TenantStatus::Error => "error",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(value: &str) -> Option<Self> {
|
||||
match value {
|
||||
"creating" => Some(TenantStatus::Creating),
|
||||
"active" => Some(TenantStatus::Active),
|
||||
"suspended" => Some(TenantStatus::Suspended),
|
||||
"deleting" => Some(TenantStatus::Deleting),
|
||||
"error" => Some(TenantStatus::Error),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for TenantStatus {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl ToSql<TenantStatusSql, Pg> for TenantStatus {
|
||||
fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result {
|
||||
out.write_all(self.as_str().as_bytes())?;
|
||||
Ok(IsNull::No)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromSql<TenantStatusSql, Pg> for TenantStatus {
|
||||
fn from_sql(bytes: PgValue<'_>) -> deserialize::Result<Self> {
|
||||
let value = str::from_utf8(bytes.as_bytes())
|
||||
.map_err(|err| Box::<dyn std::error::Error + Send + Sync>::from(err))?;
|
||||
TenantStatus::from_str(value).ok_or_else(|| {
|
||||
Box::<dyn std::error::Error + Send + Sync>::from(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!("invalid tenant status '{value}'"),
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Queryable, Identifiable)]
|
||||
#[diesel(table_name = tenants)]
|
||||
#[diesel(primary_key(id))]
|
||||
pub struct Tenant {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub storage_root: Option<String>,
|
||||
pub quickwit_index: Option<String>,
|
||||
pub config: Value,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
pub status: TenantStatus,
|
||||
pub created_by: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Queryable, Identifiable)]
|
||||
#[diesel(table_name = users)]
|
||||
pub struct User {
|
||||
pub id: Uuid,
|
||||
pub username: String,
|
||||
pub password_hash: String,
|
||||
pub role: String,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
}
|
||||
@@ -20,8 +121,94 @@ pub struct User {
|
||||
pub struct NewUser {
|
||||
pub id: Uuid,
|
||||
pub username: String,
|
||||
pub password_hash: String,
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Queryable, Identifiable, Associations, Selectable)]
|
||||
#[diesel(table_name = user_passkeys)]
|
||||
#[diesel(belongs_to(User))]
|
||||
pub struct UserPasskey {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub credential_id: Vec<u8>,
|
||||
pub public_key: Vec<u8>,
|
||||
pub credential: serde_json::Value,
|
||||
pub sign_count: i64,
|
||||
pub transports: Vec<Option<String>>,
|
||||
pub aaguid: Option<Uuid>,
|
||||
pub nickname: Option<String>,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
pub last_used_at: Option<NaiveDateTime>,
|
||||
pub revoked_at: Option<NaiveDateTime>,
|
||||
pub revoked_by: Option<Uuid>,
|
||||
pub revoked_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Insertable)]
|
||||
#[diesel(table_name = user_passkeys)]
|
||||
pub struct NewUserPasskey {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub credential_id: Vec<u8>,
|
||||
pub public_key: Vec<u8>,
|
||||
pub credential: serde_json::Value,
|
||||
pub sign_count: i64,
|
||||
pub transports: Vec<Option<String>>,
|
||||
pub aaguid: Option<Uuid>,
|
||||
pub nickname: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
||||
#[diesel(table_name = webauthn_challenges)]
|
||||
#[diesel(belongs_to(User))]
|
||||
pub struct WebauthnChallenge {
|
||||
pub id: Uuid,
|
||||
pub user_id: Option<Uuid>,
|
||||
pub purpose: String,
|
||||
pub challenge: Vec<u8>,
|
||||
pub state: Vec<u8>,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub expires_at: NaiveDateTime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Insertable)]
|
||||
#[diesel(table_name = webauthn_challenges)]
|
||||
pub struct NewWebauthnChallenge {
|
||||
pub id: Uuid,
|
||||
pub user_id: Option<Uuid>,
|
||||
pub purpose: String,
|
||||
pub challenge: Vec<u8>,
|
||||
pub state: Vec<u8>,
|
||||
pub expires_at: NaiveDateTime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
||||
#[diesel(table_name = webdav_tokens)]
|
||||
#[diesel(belongs_to(User))]
|
||||
#[diesel(belongs_to(Tenant))]
|
||||
pub struct WebdavToken {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub token_prefix: String,
|
||||
pub token_hash: String,
|
||||
pub label: Option<String>,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub last_used_at: Option<NaiveDateTime>,
|
||||
pub expires_at: Option<NaiveDateTime>,
|
||||
pub revoked_at: Option<NaiveDateTime>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Insertable)]
|
||||
#[diesel(table_name = webdav_tokens)]
|
||||
pub struct NewWebdavToken {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub token_prefix: String,
|
||||
pub token_hash: String,
|
||||
pub label: Option<String>,
|
||||
pub expires_at: Option<NaiveDateTime>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Queryable, Identifiable)]
|
||||
@@ -32,6 +219,7 @@ pub struct Folder {
|
||||
pub parent_id: Option<Uuid>,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Insertable)]
|
||||
@@ -40,6 +228,7 @@ pub struct NewFolder {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub parent_id: Option<Uuid>,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
||||
@@ -58,6 +247,7 @@ pub struct Document {
|
||||
pub issued_at: Option<NaiveDateTime>,
|
||||
pub title: String,
|
||||
pub current_version_id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Insertable)]
|
||||
@@ -72,6 +262,7 @@ pub struct NewDocument {
|
||||
pub metadata: serde_json::Value,
|
||||
pub issued_at: Option<NaiveDateTime>,
|
||||
pub title: String,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
||||
@@ -85,8 +276,8 @@ pub struct DocumentVersion {
|
||||
pub size_bytes: i64,
|
||||
pub checksum: String,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub operations_summary: serde_json::Value,
|
||||
pub metadata: serde_json::Value,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Insertable)]
|
||||
@@ -98,8 +289,8 @@ pub struct NewDocumentVersion {
|
||||
pub s3_key: String,
|
||||
pub size_bytes: i64,
|
||||
pub checksum: String,
|
||||
pub operations_summary: serde_json::Value,
|
||||
pub metadata: serde_json::Value,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
||||
@@ -109,10 +300,11 @@ pub struct DocumentAsset {
|
||||
pub id: Uuid,
|
||||
pub document_version_id: Uuid,
|
||||
pub asset_type: String,
|
||||
pub s3_key: String,
|
||||
pub mime_type: String,
|
||||
pub metadata: serde_json::Value,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub cardinality: Option<i32>,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Insertable)]
|
||||
@@ -121,9 +313,33 @@ pub struct NewDocumentAsset {
|
||||
pub id: Uuid,
|
||||
pub document_version_id: Uuid,
|
||||
pub asset_type: String,
|
||||
pub s3_key: String,
|
||||
pub mime_type: String,
|
||||
pub metadata: serde_json::Value,
|
||||
pub cardinality: Option<i32>,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
||||
#[diesel(table_name = document_asset_objects)]
|
||||
#[diesel(belongs_to(DocumentAsset, foreign_key = asset_id))]
|
||||
pub struct DocumentAssetObject {
|
||||
pub id: Uuid,
|
||||
pub asset_id: Uuid,
|
||||
pub ordinal: i32,
|
||||
pub s3_key: String,
|
||||
pub metadata: serde_json::Value,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Insertable)]
|
||||
#[diesel(table_name = document_asset_objects)]
|
||||
pub struct NewDocumentAssetObject {
|
||||
pub id: Uuid,
|
||||
pub asset_id: Uuid,
|
||||
pub ordinal: i32,
|
||||
pub s3_key: String,
|
||||
pub metadata: serde_json::Value,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Queryable, Identifiable)]
|
||||
@@ -138,6 +354,7 @@ pub struct Job {
|
||||
pub last_error: Option<String>,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Insertable)]
|
||||
@@ -148,6 +365,7 @@ pub struct NewJob {
|
||||
pub payload: serde_json::Value,
|
||||
pub status: String,
|
||||
pub run_after: NaiveDateTime,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Queryable, Identifiable)]
|
||||
@@ -157,6 +375,7 @@ pub struct Tag {
|
||||
pub label: String,
|
||||
pub color: Option<String>,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Insertable)]
|
||||
@@ -165,6 +384,7 @@ pub struct NewTag {
|
||||
pub id: Uuid,
|
||||
pub label: String,
|
||||
pub color: Option<String>,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
@@ -178,6 +398,7 @@ pub struct DocumentTag {
|
||||
pub tag_id: Uuid,
|
||||
pub assigned_at: NaiveDateTime,
|
||||
pub assigned_by: Option<Uuid>,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Insertable)]
|
||||
@@ -186,6 +407,7 @@ pub struct NewDocumentTag {
|
||||
pub document_id: Uuid,
|
||||
pub tag_id: Uuid,
|
||||
pub assigned_by: Option<Uuid>,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Queryable, Identifiable)]
|
||||
@@ -196,6 +418,7 @@ pub struct Correspondent {
|
||||
pub metadata: serde_json::Value,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Insertable)]
|
||||
@@ -204,19 +427,20 @@ pub struct NewCorrespondent {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub metadata: serde_json::Value,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Queryable, Associations)]
|
||||
#[diesel(table_name = document_correspondents)]
|
||||
#[diesel(belongs_to(Document))]
|
||||
#[diesel(belongs_to(Correspondent))]
|
||||
#[diesel(primary_key(document_id, correspondent_id, role))]
|
||||
#[diesel(primary_key(document_id, correspondent_id))]
|
||||
pub struct DocumentCorrespondent {
|
||||
pub document_id: Uuid,
|
||||
pub correspondent_id: Uuid,
|
||||
pub role: String,
|
||||
pub assigned_at: NaiveDateTime,
|
||||
pub assigned_by: Option<Uuid>,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Insertable)]
|
||||
@@ -224,8 +448,8 @@ pub struct DocumentCorrespondent {
|
||||
pub struct NewDocumentCorrespondent {
|
||||
pub document_id: Uuid,
|
||||
pub correspondent_id: Uuid,
|
||||
pub role: String,
|
||||
pub assigned_by: Option<Uuid>,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
||||
@@ -240,6 +464,7 @@ pub struct RefreshToken {
|
||||
pub revoked_at: Option<NaiveDateTime>,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Insertable)]
|
||||
@@ -250,4 +475,5 @@ pub struct NewRefreshToken {
|
||||
pub token_hash: String,
|
||||
pub issued_at: NaiveDateTime,
|
||||
pub expires_at: NaiveDateTime,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+421
-82
@@ -1,25 +1,39 @@
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::{header::SET_COOKIE, HeaderMap, HeaderValue, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use axum_extra::{headers::Cookie, typed_header::TypedHeader};
|
||||
use axum_extra::{
|
||||
headers::{authorization::Bearer, Authorization, Cookie},
|
||||
typed_header::TypedHeader,
|
||||
};
|
||||
use chrono::{Duration as ChronoDuration, Utc};
|
||||
use diesel::prelude::*;
|
||||
use diesel::{pg::PgConnection, prelude::*};
|
||||
use rand::{rngs::OsRng, RngCore};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
auth::{password, AuthenticatedUser},
|
||||
auth::{
|
||||
passkeys::{
|
||||
AuthenticationChallengeResponse, PasskeyLoginFinishPayload, PasskeyLoginStartPayload,
|
||||
PasskeyRegistrationFinishPayload, PasskeySummary, RegistrationChallengeResponse,
|
||||
},
|
||||
AuthenticatedUser,
|
||||
},
|
||||
error::{AppError, AppResult},
|
||||
models::{NewRefreshToken, RefreshToken, User},
|
||||
schema::{refresh_tokens, users::dsl},
|
||||
models::{NewRefreshToken, NewUser, RefreshToken, Tenant, TenantStatus, User, UserMembership},
|
||||
schema::{
|
||||
refresh_tokens, tenants::dsl as tenant_dsl, user_memberships::dsl as memberships_dsl,
|
||||
user_passkeys::dsl as passkey_dsl, users::dsl,
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
use crate::schema::refresh_tokens::dsl as refresh_dsl;
|
||||
use webauthn_rs::prelude::RegisterPublicKeyCredential;
|
||||
|
||||
const REFRESH_COOKIE_NAME: &str = "refresh_token";
|
||||
|
||||
@@ -27,81 +41,169 @@ const REFRESH_COOKIE_NAME: &str = "refresh_token";
|
||||
pub struct LoginRequest {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
#[serde(default)]
|
||||
pub preferred_tenant_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[derive(Deserialize, Serialize)]
|
||||
pub struct LoginResponse {
|
||||
pub access_token: String,
|
||||
pub token_type: String,
|
||||
pub expires_in: i64,
|
||||
pub tenant: TenantSnippet,
|
||||
}
|
||||
|
||||
pub async fn login(
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct TenantSnippet {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct TenantSelectionResponse {
|
||||
pub access_token: String,
|
||||
pub tenants: Vec<TenantSnippet>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct TenantListResponse {
|
||||
pub tenants: Vec<TenantSnippet>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct TenantSelectionRequest {
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SignupStartRequest {
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct SignupStartResponse {
|
||||
pub signup_token: String,
|
||||
pub challenge: RegistrationChallengeResponse,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SignupFinishRequest {
|
||||
pub signup_token: String,
|
||||
pub credential: RegisterPublicKeyCredential,
|
||||
pub nickname: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn login(_state: State<AppState>, _payload: Json<LoginRequest>) -> AppResult<Response> {
|
||||
Err(AppError::bad_request(
|
||||
"password authentication is no longer supported",
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn signup_start(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<LoginRequest>,
|
||||
) -> AppResult<(HeaderMap, Json<LoginResponse>)> {
|
||||
let mut conn = state.db()?;
|
||||
|
||||
let user: User = dsl::users
|
||||
.filter(dsl::username.eq(&payload.username))
|
||||
.first(&mut conn)?;
|
||||
|
||||
let valid = password::verify_password(&payload.password, &user.password_hash)
|
||||
.map_err(|_| AppError::unauthorized())?;
|
||||
|
||||
if !valid {
|
||||
return Err(AppError::unauthorized());
|
||||
Json(payload): Json<SignupStartRequest>,
|
||||
) -> AppResult<Json<SignupStartResponse>> {
|
||||
let username = payload.username.trim();
|
||||
if username.is_empty() {
|
||||
return Err(AppError::bad_request("username must not be empty"));
|
||||
}
|
||||
|
||||
let access_token = state
|
||||
let mut conn = state.db_unscoped()?;
|
||||
let exists: bool = dsl::users
|
||||
.filter(dsl::username.eq(username))
|
||||
.first::<User>(&mut conn)
|
||||
.optional()?
|
||||
.is_some();
|
||||
if exists {
|
||||
return Err(AppError::conflict("username already exists"));
|
||||
}
|
||||
|
||||
let user_id = Uuid::new_v4();
|
||||
let challenge = state
|
||||
.passkeys
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?
|
||||
.start_signup_registration(&mut conn, user_id, username)?;
|
||||
|
||||
let signup_token = state
|
||||
.jwt
|
||||
.generate_token(user.id, &user.username, &user.role)
|
||||
.generate_signup_token(user_id, challenge.challenge_id, username.to_owned())
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
let now = Utc::now();
|
||||
let refresh_value = generate_refresh_token();
|
||||
let refresh_hash = hash_refresh_token(&refresh_value);
|
||||
let refresh_expires_at = now + ChronoDuration::days(state.config.refresh_token_expiry_days);
|
||||
Ok(Json(SignupStartResponse {
|
||||
signup_token,
|
||||
challenge,
|
||||
}))
|
||||
}
|
||||
|
||||
let new_refresh = NewRefreshToken {
|
||||
id: Uuid::new_v4(),
|
||||
user_id: user.id,
|
||||
token_hash: refresh_hash,
|
||||
issued_at: now.naive_utc(),
|
||||
expires_at: refresh_expires_at.naive_utc(),
|
||||
};
|
||||
pub async fn signup_finish(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<SignupFinishRequest>,
|
||||
) -> AppResult<Response> {
|
||||
let claims = state
|
||||
.jwt
|
||||
.verify_signup_token(&payload.signup_token)
|
||||
.map_err(|_| AppError::unauthorized())?;
|
||||
|
||||
diesel::insert_into(refresh_tokens::table)
|
||||
.values(&new_refresh)
|
||||
.execute(&mut conn)?;
|
||||
let mut conn = state.db_unscoped()?;
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
SET_COOKIE,
|
||||
build_refresh_cookie(&state, &refresh_value, refresh_expires_at),
|
||||
);
|
||||
let exists: bool = dsl::users
|
||||
.filter(dsl::username.eq(&claims.username))
|
||||
.first::<User>(&mut conn)
|
||||
.optional()?
|
||||
.is_some();
|
||||
if exists {
|
||||
return Err(AppError::conflict("username already exists"));
|
||||
}
|
||||
|
||||
Ok((
|
||||
headers,
|
||||
Json(LoginResponse {
|
||||
access_token,
|
||||
token_type: "Bearer".to_string(),
|
||||
expires_in: state.config.jwt_expiry_minutes * 60,
|
||||
}),
|
||||
))
|
||||
let service = state
|
||||
.passkeys
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
|
||||
|
||||
let prepared_passkey =
|
||||
service.consume_signup_challenge(&mut conn, claims.challenge_id, &payload.credential)?;
|
||||
|
||||
let state_clone = state.clone();
|
||||
let response = conn.transaction::<Response, AppError, _>(|conn| {
|
||||
insert_user(conn, claims.sub, &claims.username)?;
|
||||
|
||||
let tenant = state_clone.tenants.create_tenant_with_conn(
|
||||
conn,
|
||||
&claims.username,
|
||||
None,
|
||||
None,
|
||||
TenantStatus::Creating,
|
||||
&[claims.sub],
|
||||
Some(claims.sub),
|
||||
)?;
|
||||
|
||||
let passkey_insert =
|
||||
prepared_passkey.into_new_user_passkey(claims.sub, payload.nickname.clone());
|
||||
|
||||
diesel::insert_into(passkey_dsl::user_passkeys)
|
||||
.values(&passkey_insert)
|
||||
.execute(conn)
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
let user: User = dsl::users.find(claims.sub).first(conn)?;
|
||||
issue_session(&state_clone, conn, &user, tenant.id)
|
||||
})?;
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub async fn refresh(
|
||||
State(state): State<AppState>,
|
||||
jar: Option<TypedHeader<Cookie>>,
|
||||
) -> AppResult<(HeaderMap, Json<LoginResponse>)> {
|
||||
) -> AppResult<Response> {
|
||||
let cookies = jar.ok_or_else(AppError::unauthorized)?;
|
||||
let refresh_value = cookies
|
||||
.get(REFRESH_COOKIE_NAME)
|
||||
.ok_or_else(AppError::unauthorized)?;
|
||||
|
||||
let hashed = hash_refresh_token(refresh_value);
|
||||
let mut conn = state.db()?;
|
||||
let mut conn = state.db_unscoped()?;
|
||||
let now = Utc::now();
|
||||
let now_naive = now.naive_utc();
|
||||
|
||||
@@ -128,41 +230,56 @@ pub async fn refresh(
|
||||
.first(&mut conn)
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
let access_token = state
|
||||
.jwt
|
||||
.generate_token(user.id, &user.username, &user.role)
|
||||
.map_err(AppError::from)?;
|
||||
issue_session(&state, &mut conn, &user, token.tenant_id)
|
||||
}
|
||||
|
||||
let new_refresh_value = generate_refresh_token();
|
||||
let new_refresh_hash = hash_refresh_token(&new_refresh_value);
|
||||
let new_refresh_expires = now + ChronoDuration::days(state.config.refresh_token_expiry_days);
|
||||
|
||||
let new_refresh = NewRefreshToken {
|
||||
id: Uuid::new_v4(),
|
||||
user_id: user.id,
|
||||
token_hash: new_refresh_hash,
|
||||
issued_at: now_naive,
|
||||
expires_at: new_refresh_expires.naive_utc(),
|
||||
fn insert_user(conn: &mut PgConnection, id: Uuid, username: &str) -> AppResult<()> {
|
||||
let new_user = NewUser {
|
||||
id,
|
||||
username: username.to_string(),
|
||||
};
|
||||
|
||||
diesel::insert_into(refresh_tokens::table)
|
||||
.values(&new_refresh)
|
||||
.execute(&mut conn)?;
|
||||
diesel::insert_into(dsl::users)
|
||||
.values(&new_user)
|
||||
.execute(conn)
|
||||
.map(|_| ())
|
||||
.map_err(AppError::from)
|
||||
}
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
SET_COOKIE,
|
||||
build_refresh_cookie(&state, &new_refresh_value, new_refresh_expires),
|
||||
);
|
||||
pub async fn select_tenant(
|
||||
State(state): State<AppState>,
|
||||
TypedHeader(Authorization(bearer)): TypedHeader<Authorization<Bearer>>,
|
||||
Json(payload): Json<TenantSelectionRequest>,
|
||||
) -> AppResult<Response> {
|
||||
let user_id = match state.jwt.verify_tenant_selector_token(bearer.token()) {
|
||||
Ok(claims) => claims.sub,
|
||||
Err(_) => state
|
||||
.jwt
|
||||
.verify_token(bearer.token())
|
||||
.map(|claims| claims.sub)
|
||||
.map_err(|_| AppError::unauthorized())?,
|
||||
};
|
||||
|
||||
Ok((
|
||||
headers,
|
||||
Json(LoginResponse {
|
||||
access_token,
|
||||
token_type: "Bearer".to_string(),
|
||||
expires_in: state.config.jwt_expiry_minutes * 60,
|
||||
}),
|
||||
))
|
||||
let mut conn = state.db_unscoped()?;
|
||||
|
||||
let membership_exists = memberships_dsl::user_memberships
|
||||
.filter(memberships_dsl::user_id.eq(user_id))
|
||||
.filter(memberships_dsl::tenant_id.eq(payload.tenant_id))
|
||||
.inner_join(tenant_dsl::tenants)
|
||||
.select(memberships_dsl::id)
|
||||
.first::<Uuid>(&mut conn)
|
||||
.optional()?;
|
||||
|
||||
if membership_exists.is_none() {
|
||||
return Err(AppError::unauthorized());
|
||||
}
|
||||
|
||||
let user: User = dsl::users
|
||||
.find(user_id)
|
||||
.first(&mut conn)
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
issue_session(&state, &mut conn, &user, payload.tenant_id)
|
||||
}
|
||||
|
||||
pub async fn logout(
|
||||
@@ -170,7 +287,7 @@ pub async fn logout(
|
||||
user: AuthenticatedUser,
|
||||
jar: Option<TypedHeader<Cookie>>,
|
||||
) -> AppResult<(HeaderMap, StatusCode)> {
|
||||
let mut conn = state.db()?;
|
||||
let mut conn = state.db_unscoped()?;
|
||||
let now = Utc::now().naive_utc();
|
||||
let mut rows_affected = 0;
|
||||
|
||||
@@ -214,6 +331,228 @@ pub async fn me(user: AuthenticatedUser) -> Json<AuthenticatedUser> {
|
||||
Json(user)
|
||||
}
|
||||
|
||||
pub async fn list_tenants(
|
||||
State(state): State<AppState>,
|
||||
auth: Option<TypedHeader<Authorization<Bearer>>>,
|
||||
) -> AppResult<Json<TenantListResponse>> {
|
||||
let bearer = auth.ok_or_else(AppError::unauthorized)?;
|
||||
let token = bearer.token();
|
||||
|
||||
let user_id = match state.jwt.verify_token(token) {
|
||||
Ok(claims) => claims.sub,
|
||||
Err(_) => {
|
||||
let claims = state
|
||||
.jwt
|
||||
.verify_tenant_selector_token(token)
|
||||
.map_err(|_| AppError::unauthorized())?;
|
||||
claims.sub
|
||||
}
|
||||
};
|
||||
|
||||
let mut conn = state.db_unscoped()?;
|
||||
|
||||
let tenants = memberships_dsl::user_memberships
|
||||
.inner_join(tenant_dsl::tenants)
|
||||
.filter(memberships_dsl::user_id.eq(user_id))
|
||||
.select((tenant_dsl::id, tenant_dsl::name))
|
||||
.load::<(Uuid, String)>(&mut conn)?
|
||||
.into_iter()
|
||||
.map(|(id, name)| TenantSnippet { id, name })
|
||||
.collect();
|
||||
|
||||
Ok(Json(TenantListResponse { tenants }))
|
||||
}
|
||||
|
||||
pub async fn passkey_register_start(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
) -> AppResult<Json<RegistrationChallengeResponse>> {
|
||||
let service = state
|
||||
.passkeys
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
|
||||
|
||||
let mut conn = state.db_unscoped()?;
|
||||
let current_user: User = dsl::users.find(user.user_id).first(&mut conn)?;
|
||||
let challenge = service.start_registration(&mut conn, ¤t_user)?;
|
||||
Ok(Json(challenge))
|
||||
}
|
||||
|
||||
pub async fn passkey_register_finish(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Json(payload): Json<PasskeyRegistrationFinishPayload>,
|
||||
) -> AppResult<Json<PasskeySummary>> {
|
||||
let service = state
|
||||
.passkeys
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
|
||||
|
||||
let mut conn = state.db_unscoped()?;
|
||||
let current_user: User = dsl::users.find(user.user_id).first(&mut conn)?;
|
||||
|
||||
let PasskeyRegistrationFinishPayload {
|
||||
challenge_id,
|
||||
credential,
|
||||
nickname,
|
||||
} = payload;
|
||||
|
||||
let passkey = service.finish_registration(
|
||||
&mut conn,
|
||||
¤t_user,
|
||||
challenge_id,
|
||||
credential,
|
||||
nickname,
|
||||
)?;
|
||||
|
||||
Ok(Json(PasskeySummary::from(passkey)))
|
||||
}
|
||||
|
||||
pub async fn passkey_login_start(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<PasskeyLoginStartPayload>,
|
||||
) -> AppResult<Json<AuthenticationChallengeResponse>> {
|
||||
let service = state
|
||||
.passkeys
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
|
||||
|
||||
let username = payload.username.trim();
|
||||
if username.is_empty() {
|
||||
return Err(AppError::bad_request("username must not be empty"));
|
||||
}
|
||||
|
||||
let mut conn = state.db_unscoped()?;
|
||||
let user: User = dsl::users
|
||||
.filter(dsl::username.eq(username))
|
||||
.first(&mut conn)?;
|
||||
|
||||
let challenge = service.start_authentication(&mut conn, &user)?;
|
||||
Ok(Json(challenge))
|
||||
}
|
||||
|
||||
pub async fn passkey_login_finish(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<PasskeyLoginFinishPayload>,
|
||||
) -> AppResult<Response> {
|
||||
let service = state
|
||||
.passkeys
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
|
||||
|
||||
let mut conn = state.db_unscoped()?;
|
||||
let (user, _passkey, auth_result) =
|
||||
service.finish_authentication(&mut conn, payload.challenge_id, payload.credential)?;
|
||||
|
||||
if !auth_result.user_verified() {
|
||||
return Err(AppError::unauthorized());
|
||||
}
|
||||
|
||||
complete_login(&state, &mut conn, &user, None)
|
||||
}
|
||||
|
||||
fn complete_login(
|
||||
state: &AppState,
|
||||
conn: &mut PgConnection,
|
||||
user: &User,
|
||||
preferred_tenant_id: Option<Uuid>,
|
||||
) -> AppResult<Response> {
|
||||
let memberships: Vec<(UserMembership, Tenant)> = memberships_dsl::user_memberships
|
||||
.inner_join(tenant_dsl::tenants)
|
||||
.filter(memberships_dsl::user_id.eq(user.id))
|
||||
.load(conn)?;
|
||||
|
||||
if memberships.is_empty() {
|
||||
return Err(AppError::unauthorized());
|
||||
}
|
||||
|
||||
if let Some(preferred_id) = preferred_tenant_id {
|
||||
if let Some((_, tenant)) = memberships
|
||||
.iter()
|
||||
.find(|(_, tenant)| tenant.id == preferred_id)
|
||||
{
|
||||
return issue_session(state, conn, user, tenant.id);
|
||||
}
|
||||
}
|
||||
|
||||
if memberships.len() == 1 {
|
||||
return issue_session(state, conn, user, memberships[0].1.id);
|
||||
}
|
||||
|
||||
let selection_token = state
|
||||
.jwt
|
||||
.generate_tenant_selector_token(user.id)
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
let tenants = memberships
|
||||
.into_iter()
|
||||
.map(|(_, tenant)| TenantSnippet {
|
||||
id: tenant.id,
|
||||
name: tenant.name,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(TenantSelectionResponse {
|
||||
access_token: selection_token,
|
||||
tenants,
|
||||
})
|
||||
.into_response())
|
||||
}
|
||||
|
||||
fn issue_session(
|
||||
state: &AppState,
|
||||
conn: &mut PgConnection,
|
||||
user: &User,
|
||||
tenant_id: Uuid,
|
||||
) -> AppResult<Response> {
|
||||
let now = Utc::now();
|
||||
let access_token = state
|
||||
.jwt
|
||||
.generate_token(user.id, tenant_id, &user.username)
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
let tenant_name: String = tenant_dsl::tenants
|
||||
.find(tenant_id)
|
||||
.select(tenant_dsl::name)
|
||||
.first(conn)
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
let refresh_value = generate_refresh_token();
|
||||
let refresh_hash = hash_refresh_token(&refresh_value);
|
||||
let refresh_expires_at = now + ChronoDuration::days(state.config.refresh_token_expiry_days);
|
||||
|
||||
let new_refresh = NewRefreshToken {
|
||||
id: Uuid::new_v4(),
|
||||
user_id: user.id,
|
||||
token_hash: refresh_hash,
|
||||
issued_at: now.naive_utc(),
|
||||
expires_at: refresh_expires_at.naive_utc(),
|
||||
tenant_id,
|
||||
};
|
||||
|
||||
diesel::insert_into(refresh_tokens::table)
|
||||
.values(&new_refresh)
|
||||
.execute(conn)?;
|
||||
|
||||
let mut response = Json(LoginResponse {
|
||||
access_token,
|
||||
token_type: "Bearer".to_string(),
|
||||
expires_in: state.config.jwt_expiry_minutes * 60,
|
||||
tenant: TenantSnippet {
|
||||
id: tenant_id,
|
||||
name: tenant_name,
|
||||
},
|
||||
})
|
||||
.into_response();
|
||||
|
||||
response.headers_mut().insert(
|
||||
SET_COOKIE,
|
||||
build_refresh_cookie(state, &refresh_value, refresh_expires_at),
|
||||
);
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn hash_refresh_token(token: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(token.as_bytes());
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
};
|
||||
use axum::{extract::Path, http::StatusCode, Json};
|
||||
use chrono::Utc;
|
||||
use diesel::{dsl::count_star, prelude::*, result::DatabaseErrorKind, PgConnection};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -13,19 +8,19 @@ use serde_json::Value;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
auth::TenantScopedConn,
|
||||
error::{AppError, AppResult},
|
||||
models::{Correspondent, NewCorrespondent},
|
||||
schema::{correspondents, document_correspondents},
|
||||
state::AppState,
|
||||
utils::{
|
||||
db::{no_content, EnsureEntity, IntoJsonResponse},
|
||||
time::to_iso,
|
||||
},
|
||||
};
|
||||
|
||||
use super::documents::to_iso;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct CorrespondentUsage {
|
||||
pub total: i64,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub by_role: BTreeMap<String, i64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -59,45 +54,43 @@ struct CorrespondentChangeset<'a> {
|
||||
}
|
||||
|
||||
pub async fn list_correspondents(
|
||||
State(state): State<AppState>,
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
) -> AppResult<Json<Vec<CorrespondentSummary>>> {
|
||||
let mut conn = state.db()?;
|
||||
|
||||
let correspondents_list: Vec<Correspondent> = correspondents::table
|
||||
.filter(correspondents::tenant_id.eq(tenant_id))
|
||||
.order(correspondents::name.asc())
|
||||
.load(&mut conn)?;
|
||||
|
||||
let usage_rows: Vec<(Uuid, String, i64)> = document_correspondents::table
|
||||
.group_by((
|
||||
document_correspondents::correspondent_id,
|
||||
document_correspondents::role,
|
||||
))
|
||||
.select((
|
||||
document_correspondents::correspondent_id,
|
||||
document_correspondents::role,
|
||||
count_star(),
|
||||
))
|
||||
let usage_rows: Vec<(Uuid, i64)> = document_correspondents::table
|
||||
.filter(document_correspondents::tenant_id.eq(tenant_id))
|
||||
.group_by(document_correspondents::correspondent_id)
|
||||
.select((document_correspondents::correspondent_id, count_star()))
|
||||
.load(&mut conn)?;
|
||||
|
||||
let mut usage_map: HashMap<Uuid, BTreeMap<String, i64>> = HashMap::new();
|
||||
for (correspondent_id, role, count) in usage_rows {
|
||||
usage_map
|
||||
.entry(correspondent_id)
|
||||
.or_default()
|
||||
.insert(role, count);
|
||||
let mut usage_map: HashMap<Uuid, i64> = HashMap::new();
|
||||
for (correspondent_id, count) in usage_rows {
|
||||
usage_map.insert(correspondent_id, count);
|
||||
}
|
||||
|
||||
let mut response = Vec::with_capacity(correspondents_list.len());
|
||||
for correspondent in correspondents_list {
|
||||
let role_counts = usage_map.remove(&correspondent.id).unwrap_or_default();
|
||||
response.push(build_summary(correspondent, role_counts));
|
||||
let total = usage_map.remove(&correspondent.id).unwrap_or(0);
|
||||
response.push(build_summary(correspondent, total));
|
||||
}
|
||||
|
||||
Ok(Json(response))
|
||||
response.into_json()
|
||||
}
|
||||
|
||||
pub async fn create_correspondent(
|
||||
State(state): State<AppState>,
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
Json(payload): Json<CreateCorrespondentRequest>,
|
||||
) -> AppResult<Json<CorrespondentSummary>> {
|
||||
let name = payload.name.trim();
|
||||
@@ -111,9 +104,9 @@ pub async fn create_correspondent(
|
||||
id: new_id,
|
||||
name: name.to_string(),
|
||||
metadata: metadata_value,
|
||||
tenant_id,
|
||||
};
|
||||
|
||||
let mut conn = state.db()?;
|
||||
match diesel::insert_into(correspondents::table)
|
||||
.values(&new_correspondent)
|
||||
.execute(&mut conn)
|
||||
@@ -125,19 +118,29 @@ pub async fn create_correspondent(
|
||||
Err(err) => return Err(AppError::from(err)),
|
||||
}
|
||||
|
||||
let correspondent: Correspondent = correspondents::table.find(new_id).first(&mut conn)?;
|
||||
Ok(Json(build_summary(correspondent, BTreeMap::new())))
|
||||
let correspondent: Correspondent = correspondents::table
|
||||
.find(new_id)
|
||||
.filter(correspondents::tenant_id.eq(tenant_id))
|
||||
.first(&mut conn)
|
||||
.one()?;
|
||||
|
||||
build_summary(correspondent, 0).into_json()
|
||||
}
|
||||
|
||||
pub async fn update_correspondent(
|
||||
State(state): State<AppState>,
|
||||
Path(correspondent_id): Path<Uuid>,
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
Json(payload): Json<UpdateCorrespondentRequest>,
|
||||
) -> AppResult<Json<CorrespondentSummary>> {
|
||||
let mut conn = state.db()?;
|
||||
let existing: Correspondent = correspondents::table
|
||||
.find(correspondent_id)
|
||||
.first(&mut conn)?;
|
||||
.filter(correspondents::tenant_id.eq(tenant_id))
|
||||
.first(&mut conn)
|
||||
.one()?;
|
||||
|
||||
let mut new_name: Option<String> = None;
|
||||
if let Some(ref candidate) = payload.name {
|
||||
@@ -149,6 +152,7 @@ pub async fn update_correspondent(
|
||||
let duplicate = correspondents::table
|
||||
.filter(correspondents::name.eq(trimmed))
|
||||
.filter(correspondents::id.ne(correspondent_id))
|
||||
.filter(correspondents::tenant_id.eq(tenant_id))
|
||||
.first::<Correspondent>(&mut conn)
|
||||
.optional()?;
|
||||
if duplicate.is_some() {
|
||||
@@ -167,8 +171,8 @@ pub async fn update_correspondent(
|
||||
}
|
||||
|
||||
if new_name.is_none() && new_metadata.is_none() {
|
||||
let usage = load_usage_for_correspondent(&mut conn, correspondent_id)?;
|
||||
return Ok(Json(build_summary(existing.clone(), usage)));
|
||||
let usage = load_usage_for_correspondent(&mut conn, tenant_id, correspondent_id)?;
|
||||
return build_summary(existing.clone(), usage).into_json();
|
||||
}
|
||||
|
||||
let mut changeset = CorrespondentChangeset::default();
|
||||
@@ -180,24 +184,33 @@ pub async fn update_correspondent(
|
||||
}
|
||||
|
||||
let now = Utc::now().naive_utc();
|
||||
diesel::update(correspondents::table.find(correspondent_id))
|
||||
diesel::update(
|
||||
correspondents::table
|
||||
.find(correspondent_id)
|
||||
.filter(correspondents::tenant_id.eq(tenant_id)),
|
||||
)
|
||||
.set((&changeset, correspondents::updated_at.eq(now)))
|
||||
.execute(&mut conn)?;
|
||||
|
||||
let updated: Correspondent = correspondents::table
|
||||
.find(correspondent_id)
|
||||
.first(&mut conn)?;
|
||||
let usage = load_usage_for_correspondent(&mut conn, correspondent_id)?;
|
||||
Ok(Json(build_summary(updated, usage)))
|
||||
.filter(correspondents::tenant_id.eq(tenant_id))
|
||||
.first(&mut conn)
|
||||
.one()?;
|
||||
let usage = load_usage_for_correspondent(&mut conn, tenant_id, correspondent_id)?;
|
||||
build_summary(updated, usage).into_json()
|
||||
}
|
||||
|
||||
pub async fn delete_correspondent(
|
||||
State(state): State<AppState>,
|
||||
Path(correspondent_id): Path<Uuid>,
|
||||
) -> AppResult<impl IntoResponse> {
|
||||
let mut conn = state.db()?;
|
||||
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
) -> AppResult<StatusCode> {
|
||||
let usage: i64 = document_correspondents::table
|
||||
.filter(document_correspondents::tenant_id.eq(tenant_id))
|
||||
.filter(document_correspondents::correspondent_id.eq(correspondent_id))
|
||||
.select(count_star())
|
||||
.first(&mut conn)?;
|
||||
@@ -208,29 +221,26 @@ pub async fn delete_correspondent(
|
||||
));
|
||||
}
|
||||
|
||||
let deleted =
|
||||
diesel::delete(correspondents::table.find(correspondent_id)).execute(&mut conn)?;
|
||||
let deleted = diesel::delete(
|
||||
correspondents::table
|
||||
.filter(correspondents::id.eq(correspondent_id))
|
||||
.filter(correspondents::tenant_id.eq(tenant_id)),
|
||||
)
|
||||
.execute(&mut conn)?;
|
||||
if deleted == 0 {
|
||||
return Err(AppError::not_found());
|
||||
}
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
no_content()
|
||||
}
|
||||
|
||||
fn build_summary(
|
||||
correspondent: Correspondent,
|
||||
role_counts: BTreeMap<String, i64>,
|
||||
) -> CorrespondentSummary {
|
||||
let total = role_counts.values().copied().sum();
|
||||
fn build_summary(correspondent: Correspondent, total: i64) -> CorrespondentSummary {
|
||||
CorrespondentSummary {
|
||||
id: correspondent.id,
|
||||
name: correspondent.name,
|
||||
metadata: correspondent.metadata,
|
||||
created_at: to_iso(correspondent.created_at),
|
||||
updated_at: to_iso(correspondent.updated_at),
|
||||
usage: CorrespondentUsage {
|
||||
total,
|
||||
by_role: role_counts,
|
||||
},
|
||||
usage: CorrespondentUsage { total },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,17 +253,14 @@ fn normalize_metadata(input: Option<Value>) -> Value {
|
||||
|
||||
fn load_usage_for_correspondent(
|
||||
conn: &mut PgConnection,
|
||||
tenant_id: Uuid,
|
||||
correspondent_id: Uuid,
|
||||
) -> AppResult<BTreeMap<String, i64>> {
|
||||
let rows: Vec<(String, i64)> = document_correspondents::table
|
||||
) -> AppResult<i64> {
|
||||
let total: i64 = document_correspondents::table
|
||||
.filter(document_correspondents::correspondent_id.eq(correspondent_id))
|
||||
.group_by(document_correspondents::role)
|
||||
.select((document_correspondents::role, count_star()))
|
||||
.load(conn)?;
|
||||
.filter(document_correspondents::tenant_id.eq(tenant_id))
|
||||
.select(count_star())
|
||||
.get_result(conn)?;
|
||||
|
||||
let mut map = BTreeMap::new();
|
||||
for (role, count) in rows {
|
||||
map.insert(role, count);
|
||||
}
|
||||
Ok(map)
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
+1047
-938
File diff suppressed because it is too large
Load Diff
+226
-60
@@ -4,19 +4,25 @@ use axum::{
|
||||
};
|
||||
use diesel::{dsl::exists, prelude::*, PgConnection};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::models::{Document, Folder, NewFolder};
|
||||
use crate::schema::{documents, folders};
|
||||
use crate::state::AppState;
|
||||
use crate::{
|
||||
auth::AuthenticatedUser,
|
||||
auth::TenantScopedConn,
|
||||
error::{AppError, AppResult},
|
||||
};
|
||||
|
||||
use super::documents::{
|
||||
load_correspondents_for_documents, load_primary_assets, load_tags_for_documents,
|
||||
to_document_response, to_iso, DocumentResponse,
|
||||
use super::documents::{to_document_response, DocumentResponse};
|
||||
use crate::documents::{
|
||||
asset::load_primary_assets, correspondents::load_correspondents_for_documents,
|
||||
tags::load_tags_for_documents,
|
||||
};
|
||||
use crate::utils::{
|
||||
json::{classify_nullable, NullableValue},
|
||||
time::to_iso,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -31,13 +37,6 @@ pub struct EnsureFolderPathRequest {
|
||||
pub segments: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateFolderRequest {
|
||||
#[serde(default)]
|
||||
pub parent_id: Option<Option<Uuid>>,
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct FolderResponse {
|
||||
pub folder: FolderInfo,
|
||||
@@ -69,16 +68,36 @@ pub struct FolderInfo {
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
pub async fn get_folder(
|
||||
Path(folder_id): Path<Uuid>,
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
) -> AppResult<Json<FolderResponse>> {
|
||||
let folder: Folder = folders::table
|
||||
.find(folder_id)
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.first(&mut conn)?;
|
||||
|
||||
Ok(Json(FolderResponse {
|
||||
folder: folder_to_info(folder),
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn ensure_folder_path(
|
||||
State(state): State<AppState>,
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
Json(payload): Json<EnsureFolderPathRequest>,
|
||||
) -> AppResult<Json<FolderResponse>> {
|
||||
if payload.segments.is_empty() {
|
||||
return Err(AppError::bad_request("segments must not be empty"));
|
||||
}
|
||||
|
||||
let mut conn = state.db()?;
|
||||
|
||||
let target_folder = conn.transaction::<Folder, AppError, _>(|conn| {
|
||||
let mut current_parent = payload.parent_id;
|
||||
let mut last_folder: Option<Folder> = None;
|
||||
@@ -91,12 +110,14 @@ pub async fn ensure_folder_path(
|
||||
|
||||
let existing: Option<Folder> = if let Some(parent_id) = current_parent {
|
||||
folders::table
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.filter(folders::parent_id.eq(Some(parent_id)))
|
||||
.filter(folders::name.eq(name))
|
||||
.first(conn)
|
||||
.optional()?
|
||||
} else {
|
||||
folders::table
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.filter(folders::parent_id.is_null())
|
||||
.filter(folders::name.eq(name))
|
||||
.first(conn)
|
||||
@@ -110,20 +131,40 @@ pub async fn ensure_folder_path(
|
||||
id: Uuid::new_v4(),
|
||||
name: name.to_string(),
|
||||
parent_id: current_parent,
|
||||
tenant_id,
|
||||
};
|
||||
|
||||
diesel::insert_into(folders::table)
|
||||
let inserted_id: Option<Uuid> = diesel::insert_into(folders::table)
|
||||
.values(&new_folder)
|
||||
.execute(conn)?;
|
||||
.on_conflict_do_nothing()
|
||||
.returning(folders::id)
|
||||
.get_result(conn)
|
||||
.optional()?;
|
||||
|
||||
folders::table.find(new_folder.id).first(conn)?
|
||||
if let Some(id) = inserted_id {
|
||||
folders::table
|
||||
.find(id)
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.first(conn)?
|
||||
} else if let Some(parent_id) = current_parent {
|
||||
folders::table
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.filter(folders::parent_id.eq(Some(parent_id)))
|
||||
.filter(folders::name.eq(name))
|
||||
.first(conn)?
|
||||
} else {
|
||||
folders::table
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.filter(folders::parent_id.is_null())
|
||||
.filter(folders::name.eq(name))
|
||||
.first(conn)?
|
||||
}
|
||||
};
|
||||
|
||||
current_parent = Some(folder.id);
|
||||
last_folder = Some(folder);
|
||||
}
|
||||
|
||||
last_folder.ok_or_else(|| AppError::internal("failed to resolve folder path".to_string()))
|
||||
last_folder.ok_or_else(|| AppError::internal("failed to resolve folder path"))
|
||||
})?;
|
||||
|
||||
Ok(Json(FolderResponse {
|
||||
@@ -132,39 +173,103 @@ pub async fn ensure_folder_path(
|
||||
}
|
||||
|
||||
pub async fn create_folder(
|
||||
State(state): State<AppState>,
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
Json(payload): Json<CreateFolderRequest>,
|
||||
) -> AppResult<Json<FolderResponse>> {
|
||||
) -> AppResult<(StatusCode, Json<FolderResponse>)> {
|
||||
if payload.name.trim().is_empty() {
|
||||
return Err(AppError::bad_request("name must not be empty"));
|
||||
}
|
||||
|
||||
let mut conn = state.db()?;
|
||||
let name = payload.name.trim();
|
||||
|
||||
let new_folder = NewFolder {
|
||||
id: Uuid::new_v4(),
|
||||
name: payload.name.trim().to_string(),
|
||||
parent_id: payload.parent_id,
|
||||
let existing: Option<Folder> = if let Some(parent_id) = payload.parent_id {
|
||||
folders::table
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.filter(folders::parent_id.eq(Some(parent_id)))
|
||||
.filter(folders::name.eq(name))
|
||||
.first(&mut conn)
|
||||
.optional()?
|
||||
} else {
|
||||
folders::table
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.filter(folders::parent_id.is_null())
|
||||
.filter(folders::name.eq(name))
|
||||
.first(&mut conn)
|
||||
.optional()?
|
||||
};
|
||||
|
||||
diesel::insert_into(folders::table)
|
||||
.values(&new_folder)
|
||||
.execute(&mut conn)?;
|
||||
let (folder, created): (Folder, bool) = if let Some(folder) = existing {
|
||||
(folder, false)
|
||||
} else {
|
||||
let new_folder = NewFolder {
|
||||
id: Uuid::new_v4(),
|
||||
name: name.to_string(),
|
||||
parent_id: payload.parent_id,
|
||||
tenant_id,
|
||||
};
|
||||
|
||||
let folder: Folder = folders::table.find(new_folder.id).first(&mut conn)?;
|
||||
Ok(Json(FolderResponse {
|
||||
let inserted_id: Option<Uuid> = diesel::insert_into(folders::table)
|
||||
.values(&new_folder)
|
||||
.on_conflict_do_nothing()
|
||||
.returning(folders::id)
|
||||
.get_result(&mut conn)
|
||||
.optional()?;
|
||||
|
||||
if let Some(id) = inserted_id {
|
||||
(
|
||||
folders::table
|
||||
.find(id)
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.first(&mut conn)?,
|
||||
true,
|
||||
)
|
||||
} else if let Some(parent_id) = payload.parent_id {
|
||||
(
|
||||
folders::table
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.filter(folders::parent_id.eq(Some(parent_id)))
|
||||
.filter(folders::name.eq(name))
|
||||
.first(&mut conn)?,
|
||||
false,
|
||||
)
|
||||
} else {
|
||||
(
|
||||
folders::table
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.filter(folders::parent_id.is_null())
|
||||
.filter(folders::name.eq(name))
|
||||
.first(&mut conn)?,
|
||||
false,
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let response = Json(FolderResponse {
|
||||
folder: folder_to_info(folder),
|
||||
}))
|
||||
});
|
||||
|
||||
if created {
|
||||
Ok((StatusCode::CREATED, response))
|
||||
} else {
|
||||
Ok((StatusCode::OK, response))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list_folder_contents(
|
||||
State(state): State<AppState>,
|
||||
Path(folder_identifier): Path<String>,
|
||||
Query(query): Query<FolderContentsQuery>,
|
||||
user: AuthenticatedUser,
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
user_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
) -> AppResult<Json<FolderContentsResponse>> {
|
||||
let mut conn = state.db()?;
|
||||
|
||||
let folder_id = if folder_identifier.eq_ignore_ascii_case("root") {
|
||||
None
|
||||
} else {
|
||||
@@ -176,7 +281,10 @@ pub async fn list_folder_contents(
|
||||
|
||||
let folder = match folder_id {
|
||||
Some(id) => Some(folder_to_info(
|
||||
folders::table.find(id).first::<Folder>(&mut conn)?,
|
||||
folders::table
|
||||
.find(id)
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.first::<Folder>(&mut conn)?,
|
||||
)),
|
||||
None => None,
|
||||
};
|
||||
@@ -184,11 +292,13 @@ pub async fn list_folder_contents(
|
||||
let child_folders: Vec<Folder> = if let Some(parent_id) = folder_id {
|
||||
folders::table
|
||||
.filter(folders::parent_id.eq(parent_id))
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.order(folders::name.asc())
|
||||
.load(&mut conn)?
|
||||
} else {
|
||||
folders::table
|
||||
.filter(folders::parent_id.is_null())
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.order(folders::name.asc())
|
||||
.load(&mut conn)?
|
||||
};
|
||||
@@ -197,6 +307,7 @@ pub async fn list_folder_contents(
|
||||
let documents = if query.include_documents {
|
||||
let docs_query = documents::table
|
||||
.filter(documents::deleted_at.is_null())
|
||||
.filter(documents::tenant_id.eq(tenant_id))
|
||||
.order(documents::uploaded_at.desc());
|
||||
|
||||
let docs: Vec<Document> = if let Some(current_folder) = folder_id {
|
||||
@@ -214,7 +325,7 @@ pub async fn list_folder_contents(
|
||||
let mut correspondents_map = load_correspondents_for_documents(&mut conn, &doc_ids)?;
|
||||
drop(conn);
|
||||
|
||||
let primary_versions = load_primary_assets(&state, &docs).await?;
|
||||
let primary_versions = load_primary_assets(&state, tenant_id, &docs)?;
|
||||
|
||||
let mut documents = Vec::with_capacity(doc_ids.len());
|
||||
for doc in docs {
|
||||
@@ -223,7 +334,7 @@ pub async fn list_folder_contents(
|
||||
let current_version = primary_versions.get(&doc.id).cloned();
|
||||
documents.push(to_document_response(
|
||||
&state,
|
||||
user.user_id,
|
||||
user_id,
|
||||
doc,
|
||||
tags,
|
||||
correspondents,
|
||||
@@ -244,16 +355,23 @@ pub async fn list_folder_contents(
|
||||
}
|
||||
|
||||
pub async fn delete_folder(
|
||||
State(state): State<AppState>,
|
||||
Path(folder_id): Path<Uuid>,
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
) -> AppResult<StatusCode> {
|
||||
let mut conn = state.db()?;
|
||||
|
||||
conn.transaction::<_, AppError, _>(|conn| {
|
||||
folders::table.find(folder_id).first::<Folder>(conn)?;
|
||||
folders::table
|
||||
.find(folder_id)
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.first::<Folder>(conn)?;
|
||||
|
||||
let has_child_folders: bool = diesel::select(exists(
|
||||
folders::table.filter(folders::parent_id.eq(Some(folder_id))),
|
||||
folders::table
|
||||
.filter(folders::parent_id.eq(Some(folder_id)))
|
||||
.filter(folders::tenant_id.eq(tenant_id)),
|
||||
))
|
||||
.get_result(conn)?;
|
||||
|
||||
@@ -266,6 +384,7 @@ pub async fn delete_folder(
|
||||
let has_documents: bool = diesel::select(exists(
|
||||
documents::table
|
||||
.filter(documents::folder_id.eq(Some(folder_id)))
|
||||
.filter(documents::tenant_id.eq(tenant_id))
|
||||
.filter(documents::deleted_at.is_null()),
|
||||
))
|
||||
.get_result(conn)?;
|
||||
@@ -276,7 +395,12 @@ pub async fn delete_folder(
|
||||
));
|
||||
}
|
||||
|
||||
diesel::delete(folders::table.find(folder_id)).execute(conn)?;
|
||||
diesel::delete(
|
||||
folders::table
|
||||
.filter(folders::id.eq(folder_id))
|
||||
.filter(folders::tenant_id.eq(tenant_id)),
|
||||
)
|
||||
.execute(conn)?;
|
||||
|
||||
Ok(())
|
||||
})?;
|
||||
@@ -285,43 +409,76 @@ pub async fn delete_folder(
|
||||
}
|
||||
|
||||
pub async fn update_folder(
|
||||
State(state): State<AppState>,
|
||||
Path(folder_id): Path<Uuid>,
|
||||
Json(payload): Json<UpdateFolderRequest>,
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<StatusCode> {
|
||||
let mut conn = state.db()?;
|
||||
if !body.is_object() {
|
||||
return Err(AppError::bad_request("request body must be a JSON object"));
|
||||
}
|
||||
|
||||
let parent_class = classify_nullable(body.get("parent_id")).map_err(AppError::bad_request)?;
|
||||
let name_class = classify_nullable(body.get("name")).map_err(AppError::bad_request)?;
|
||||
|
||||
conn.transaction::<(), AppError, _>(|conn| {
|
||||
let folder: Folder = folders::table.find(folder_id).first(conn)?;
|
||||
let folder: Folder = folders::table
|
||||
.find(folder_id)
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.first(conn)?;
|
||||
|
||||
let mut next_parent = folder.parent_id;
|
||||
let mut parent_changed = false;
|
||||
|
||||
if let Some(parent_request) = payload.parent_id {
|
||||
if parent_request == Some(folder_id) {
|
||||
match parent_class {
|
||||
NullableValue::Omitted => {}
|
||||
NullableValue::Null => {
|
||||
if folder.parent_id.is_some() {
|
||||
parent_changed = true;
|
||||
}
|
||||
next_parent = None;
|
||||
}
|
||||
NullableValue::String(value) => {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(AppError::bad_request("parent_id must not be empty"));
|
||||
}
|
||||
let parent_id = Uuid::parse_str(trimmed)
|
||||
.map_err(|_| AppError::bad_request("parent_id must be a valid UUID or null"))?;
|
||||
if parent_id == folder_id {
|
||||
return Err(AppError::bad_request("folder cannot be its own parent"));
|
||||
}
|
||||
|
||||
if let Some(parent_id) = parent_request {
|
||||
let _parent: Folder = folders::table.find(parent_id).first(conn)?;
|
||||
let _parent: Folder = folders::table
|
||||
.find(parent_id)
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.first(conn)?;
|
||||
|
||||
let descendant_ids = gather_descendant_folder_ids(conn, folder_id)?;
|
||||
if folder.parent_id != Some(parent_id) {
|
||||
let descendant_ids = gather_descendant_folder_ids(conn, tenant_id, folder_id)?;
|
||||
if descendant_ids.contains(&parent_id) {
|
||||
return Err(AppError::bad_request(
|
||||
"cannot move folder into itself or a descendant",
|
||||
));
|
||||
}
|
||||
parent_changed = true;
|
||||
}
|
||||
|
||||
parent_changed = parent_request != folder.parent_id;
|
||||
next_parent = parent_request;
|
||||
next_parent = Some(parent_id);
|
||||
}
|
||||
}
|
||||
|
||||
let mut new_name = folder.name.clone();
|
||||
let mut name_changed = false;
|
||||
|
||||
if let Some(name) = payload.name {
|
||||
let trimmed = name.trim();
|
||||
match name_class {
|
||||
NullableValue::Omitted => {}
|
||||
NullableValue::Null => {
|
||||
return Err(AppError::bad_request("name cannot be null"));
|
||||
}
|
||||
NullableValue::String(value) => {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(AppError::bad_request("name must not be empty"));
|
||||
}
|
||||
@@ -331,6 +488,7 @@ pub async fn update_folder(
|
||||
name_changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !parent_changed && !name_changed {
|
||||
return Ok(());
|
||||
@@ -341,6 +499,7 @@ pub async fn update_folder(
|
||||
.filter(folders::parent_id.eq(Some(parent_id)))
|
||||
.filter(folders::name.eq(&new_name))
|
||||
.filter(folders::id.ne(folder_id))
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.first::<Folder>(conn)
|
||||
.optional()?
|
||||
} else {
|
||||
@@ -348,6 +507,7 @@ pub async fn update_folder(
|
||||
.filter(folders::parent_id.is_null())
|
||||
.filter(folders::name.eq(&new_name))
|
||||
.filter(folders::id.ne(folder_id))
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.first::<Folder>(conn)
|
||||
.optional()?
|
||||
};
|
||||
@@ -358,7 +518,11 @@ pub async fn update_folder(
|
||||
));
|
||||
}
|
||||
|
||||
diesel::update(folders::table.find(folder_id))
|
||||
diesel::update(
|
||||
folders::table
|
||||
.find(folder_id)
|
||||
.filter(folders::tenant_id.eq(tenant_id)),
|
||||
)
|
||||
.set((
|
||||
folders::parent_id.eq(next_parent),
|
||||
folders::name.eq(&new_name),
|
||||
@@ -383,6 +547,7 @@ fn folder_to_info(folder: Folder) -> FolderInfo {
|
||||
|
||||
pub(super) fn gather_descendant_folder_ids(
|
||||
conn: &mut PgConnection,
|
||||
tenant_id: Uuid,
|
||||
folder_id: Uuid,
|
||||
) -> AppResult<Vec<Uuid>> {
|
||||
let mut ids = vec![folder_id];
|
||||
@@ -391,6 +556,7 @@ pub(super) fn gather_descendant_folder_ids(
|
||||
while let Some(current) = queue.pop() {
|
||||
let child_ids: Vec<Uuid> = folders::table
|
||||
.filter(folders::parent_id.eq(Some(current)))
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.select(folders::id)
|
||||
.load(conn)?;
|
||||
queue.extend(child_ids.iter().copied());
|
||||
|
||||
+70
-10
@@ -2,18 +2,25 @@ use axum::http::HeaderValue;
|
||||
use axum::{
|
||||
extract::DefaultBodyLimit,
|
||||
middleware,
|
||||
response::Json,
|
||||
routing::{delete, get, patch, post},
|
||||
Router,
|
||||
};
|
||||
use tower_http::cors::{AllowOrigin, CorsLayer};
|
||||
use std::sync::Arc;
|
||||
use tower_http::{
|
||||
cors::{AllowOrigin, CorsLayer},
|
||||
trace::{DefaultMakeSpan, DefaultOnFailure, DefaultOnResponse, TraceLayer},
|
||||
};
|
||||
use utoipa::OpenApi;
|
||||
|
||||
use crate::{auth::AuthenticatedUser, state::AppState};
|
||||
use crate::{auth::AuthenticatedUser, openapi::ApiDoc, state::AppState};
|
||||
|
||||
pub mod auth;
|
||||
pub mod correspondents;
|
||||
pub mod documents;
|
||||
pub mod folders;
|
||||
pub mod health;
|
||||
pub mod profile;
|
||||
pub mod tags;
|
||||
pub mod webdav;
|
||||
|
||||
@@ -47,17 +54,31 @@ pub fn create_router(state: AppState) -> Router<()> {
|
||||
};
|
||||
|
||||
let auth_routes = Router::new()
|
||||
.route("/signup/start", post(auth::signup_start))
|
||||
.route("/signup/finish", post(auth::signup_finish))
|
||||
.route("/login", post(auth::login))
|
||||
.route("/refresh", post(auth::refresh))
|
||||
.route("/logout", post(auth::logout))
|
||||
.route("/select-tenant", post(auth::select_tenant))
|
||||
.route("/tenants", get(auth::list_tenants))
|
||||
.route(
|
||||
"/passkeys/register/start",
|
||||
post(auth::passkey_register_start),
|
||||
)
|
||||
.route(
|
||||
"/passkeys/register/finish",
|
||||
post(auth::passkey_register_finish),
|
||||
)
|
||||
.route("/passkeys/login/start", post(auth::passkey_login_start))
|
||||
.route("/passkeys/login/finish", post(auth::passkey_login_finish))
|
||||
.route("/me", get(auth::me));
|
||||
|
||||
let documents_routes = Router::new()
|
||||
.route("/check", get(documents::check_document))
|
||||
.route(
|
||||
"/",
|
||||
get(documents::list_documents).post(documents::upload_document),
|
||||
)
|
||||
.route("/reanalyze", post(documents::reanalyze_all_documents))
|
||||
.route("/bulk/move", post(documents::bulk_move_documents))
|
||||
.route("/bulk/tags", post(documents::bulk_update_tags))
|
||||
.route(
|
||||
@@ -74,13 +95,17 @@ pub fn create_router(state: AppState) -> Router<()> {
|
||||
.delete(documents::delete_document)
|
||||
.patch(documents::update_document),
|
||||
)
|
||||
.route("/:id/download", get(documents::download_document))
|
||||
.route("/:id/assets/:asset_id", get(documents::get_document_asset))
|
||||
.route(
|
||||
"/:id/assets",
|
||||
get(documents::list_document_assets).post(documents::request_document_assets),
|
||||
)
|
||||
.route("/:id/folder", patch(documents::move_document))
|
||||
.route("/:id/versions", get(documents::list_document_versions))
|
||||
.route(
|
||||
"/:id/versions/:version_id",
|
||||
get(documents::get_document_version),
|
||||
)
|
||||
.route("/:id/restore", post(documents::restore_document))
|
||||
.route("/:id/tags", post(documents::assign_tags))
|
||||
.route("/:id/tags/:tag_id", delete(documents::remove_tag))
|
||||
.route(
|
||||
@@ -98,10 +123,9 @@ pub fn create_router(state: AppState) -> Router<()> {
|
||||
let folders_routes = Router::new()
|
||||
.route("/", post(folders::create_folder))
|
||||
.route("/path", post(folders::ensure_folder_path))
|
||||
.route(
|
||||
"/:id",
|
||||
delete(folders::delete_folder).patch(folders::update_folder),
|
||||
)
|
||||
.route("/:id", get(folders::get_folder))
|
||||
.route("/:id", delete(folders::delete_folder))
|
||||
.route("/:id", patch(folders::update_folder))
|
||||
.route("/:id/contents", get(folders::list_folder_contents));
|
||||
|
||||
let tags_routes = Router::new()
|
||||
@@ -119,20 +143,56 @@ pub fn create_router(state: AppState) -> Router<()> {
|
||||
.delete(correspondents::delete_correspondent),
|
||||
);
|
||||
|
||||
let profile_routes = Router::new()
|
||||
.route(
|
||||
"/webdav-tokens",
|
||||
get(profile::list_webdav_tokens).post(profile::create_webdav_token),
|
||||
)
|
||||
.route("/webdav-tokens/:id", delete(profile::delete_webdav_token))
|
||||
.route("/passkeys", get(profile::list_passkeys))
|
||||
.route("/passkeys/:id", delete(profile::delete_passkey));
|
||||
|
||||
let protected_state = state.clone();
|
||||
let assets_routes = Router::new().route("/:asset_id", get(documents::get_document_asset));
|
||||
|
||||
let protected_routes = Router::new()
|
||||
.nest("/api/documents", documents_routes)
|
||||
.nest("/api/folders", folders_routes)
|
||||
.nest("/api/tags", tags_routes)
|
||||
.nest("/api/correspondents", correspondents_routes)
|
||||
.nest("/api/profile", profile_routes)
|
||||
.nest("/api/assets", assets_routes)
|
||||
.layer(middleware::from_extractor_with_state::<AuthenticatedUser, _>(protected_state));
|
||||
|
||||
let openapi_arc = Arc::new(ApiDoc::openapi());
|
||||
let docs_route = Router::new().route(
|
||||
"/api/docs/openapi.json",
|
||||
get({
|
||||
let spec = openapi_arc.clone();
|
||||
move || {
|
||||
let spec = spec.clone();
|
||||
async move { Json((*spec).clone()) }
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
let upload_limit = state.config.upload_body_limit_bytes;
|
||||
|
||||
Router::new()
|
||||
.merge(download_routes)
|
||||
.merge(protected_routes)
|
||||
.merge(docs_route)
|
||||
.nest("/api/auth", auth_routes)
|
||||
.route("/api/health", get(health::health_check))
|
||||
.with_state(state)
|
||||
.layer(cors)
|
||||
.layer(DefaultBodyLimit::max(1024 * 1024 * 512))
|
||||
.layer(DefaultBodyLimit::max(
|
||||
usize::try_from(upload_limit).unwrap_or(usize::MAX),
|
||||
))
|
||||
.layer(
|
||||
TraceLayer::new_for_http()
|
||||
.make_span_with(DefaultMakeSpan::new().level(tracing::Level::INFO))
|
||||
.on_response(DefaultOnResponse::new().level(tracing::Level::INFO))
|
||||
.on_failure(DefaultOnFailure::new().level(tracing::Level::ERROR)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use chrono::{DateTime, NaiveDateTime};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::{
|
||||
passkeys::PasskeySummary,
|
||||
webdav_tokens::{
|
||||
create_webdav_token as issue_token, list_webdav_tokens as load_tokens,
|
||||
revoke_webdav_token as revoke_token,
|
||||
},
|
||||
TenantScopedConn,
|
||||
};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::models::WebdavToken;
|
||||
use crate::state::AppState;
|
||||
use crate::utils::{db::no_content, time::to_iso};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct WebdavTokenResponse {
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub label: Option<String>,
|
||||
pub created_at: String,
|
||||
pub last_used_at: Option<String>,
|
||||
pub expires_at: Option<String>,
|
||||
pub revoked_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct WebdavTokenCreatedResponse {
|
||||
pub token: String,
|
||||
pub token_info: WebdavTokenResponse,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateWebdavTokenRequest {
|
||||
pub label: Option<String>,
|
||||
pub expires_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RevokePasskeyQuery {
|
||||
#[serde(default)]
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn list_passkeys(
|
||||
State(state): State<AppState>,
|
||||
TenantScopedConn {
|
||||
mut conn, user_id, ..
|
||||
}: TenantScopedConn,
|
||||
) -> AppResult<Json<Vec<PasskeySummary>>> {
|
||||
let service = state
|
||||
.passkeys
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
|
||||
|
||||
let passkeys = service.list_for_user(&mut conn, user_id)?;
|
||||
Ok(Json(passkeys))
|
||||
}
|
||||
|
||||
pub async fn list_webdav_tokens(
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
user_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
) -> AppResult<Json<Vec<WebdavTokenResponse>>> {
|
||||
let tokens = load_tokens(&mut conn, user_id, Some(tenant_id))?;
|
||||
let responses = tokens.into_iter().map(webdav_token_to_response).collect();
|
||||
Ok(Json(responses))
|
||||
}
|
||||
|
||||
pub async fn create_webdav_token(
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
user_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
Json(payload): Json<CreateWebdavTokenRequest>,
|
||||
) -> AppResult<(StatusCode, Json<WebdavTokenCreatedResponse>)> {
|
||||
let expires_at = match payload.expires_at {
|
||||
Some(ref value) => Some(parse_timestamp(value)?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let issued = issue_token(
|
||||
&mut conn,
|
||||
user_id,
|
||||
tenant_id,
|
||||
payload.label.clone(),
|
||||
expires_at,
|
||||
)?;
|
||||
|
||||
let response = WebdavTokenCreatedResponse {
|
||||
token: issued.token,
|
||||
token_info: webdav_token_to_response(issued.record),
|
||||
};
|
||||
|
||||
Ok((StatusCode::CREATED, Json(response)))
|
||||
}
|
||||
|
||||
pub async fn delete_webdav_token(
|
||||
TenantScopedConn {
|
||||
mut conn, user_id, ..
|
||||
}: TenantScopedConn,
|
||||
Path(token_id): Path<Uuid>,
|
||||
) -> AppResult<StatusCode> {
|
||||
revoke_token(&mut conn, token_id, user_id)?;
|
||||
no_content()
|
||||
}
|
||||
|
||||
pub async fn delete_passkey(
|
||||
State(state): State<AppState>,
|
||||
TenantScopedConn {
|
||||
mut conn, user_id, ..
|
||||
}: TenantScopedConn,
|
||||
Path(passkey_id): Path<Uuid>,
|
||||
Query(query): Query<RevokePasskeyQuery>,
|
||||
) -> AppResult<StatusCode> {
|
||||
let service = state
|
||||
.passkeys
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
|
||||
|
||||
let active_count = service.active_passkey_count(&mut conn, user_id)?;
|
||||
if active_count <= 1 {
|
||||
return Err(AppError::bad_request(
|
||||
"cannot revoke the last remaining passkey",
|
||||
));
|
||||
}
|
||||
|
||||
service.revoke_passkey(&mut conn, user_id, passkey_id, query.reason)?;
|
||||
no_content()
|
||||
}
|
||||
|
||||
fn webdav_token_to_response(token: WebdavToken) -> WebdavTokenResponse {
|
||||
WebdavTokenResponse {
|
||||
id: token.id,
|
||||
tenant_id: token.tenant_id,
|
||||
label: token.label,
|
||||
created_at: to_iso(token.created_at),
|
||||
last_used_at: token.last_used_at.map(to_iso),
|
||||
expires_at: token.expires_at.map(to_iso),
|
||||
revoked_at: token.revoked_at.map(to_iso),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_timestamp(value: &str) -> AppResult<NaiveDateTime> {
|
||||
let dt = DateTime::parse_from_rfc3339(value)
|
||||
.map_err(|_| AppError::bad_request("invalid expires_at timestamp"))?;
|
||||
Ok(dt.naive_utc())
|
||||
}
|
||||
+75
-32
@@ -1,19 +1,16 @@
|
||||
use crate::utils::json::{classify_nullable, NullableValue};
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use axum::{extract::Path, http::StatusCode, Json};
|
||||
use diesel::{dsl::count_star, prelude::*};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::TenantScopedConn;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::models::{NewTag, Tag};
|
||||
use crate::schema::{document_tags, tags};
|
||||
use crate::state::AppState;
|
||||
use crate::utils::db::{no_content, EnsureEntity, IntoJsonResponse};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateTagRequest {
|
||||
@@ -36,19 +33,27 @@ pub struct TagCatalogEntry {
|
||||
pub usage_count: i64,
|
||||
}
|
||||
|
||||
pub async fn list_tags(State(state): State<AppState>) -> AppResult<Json<Vec<TagCatalogEntry>>> {
|
||||
let mut conn = state.db()?;
|
||||
|
||||
let tag_list: Vec<Tag> = tags::table.order(tags::label.asc()).load(&mut conn)?;
|
||||
pub async fn list_tags(
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
) -> AppResult<Json<Vec<TagCatalogEntry>>> {
|
||||
let tag_list: Vec<Tag> = tags::table
|
||||
.filter(tags::tenant_id.eq(tenant_id))
|
||||
.order(tags::label.asc())
|
||||
.load(&mut conn)?;
|
||||
|
||||
let usage_rows: Vec<(Uuid, i64)> = document_tags::table
|
||||
.filter(document_tags::tenant_id.eq(tenant_id))
|
||||
.group_by(document_tags::tag_id)
|
||||
.select((document_tags::tag_id, count_star()))
|
||||
.load(&mut conn)?;
|
||||
|
||||
let usage_map: HashMap<Uuid, i64> = usage_rows.into_iter().collect();
|
||||
|
||||
let response = tag_list
|
||||
let response: Vec<TagCatalogEntry> = tag_list
|
||||
.into_iter()
|
||||
.map(|tag| TagCatalogEntry {
|
||||
id: tag.id,
|
||||
@@ -58,22 +63,26 @@ pub async fn list_tags(State(state): State<AppState>) -> AppResult<Json<Vec<TagC
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(response))
|
||||
response.into_json()
|
||||
}
|
||||
|
||||
pub async fn create_tag(
|
||||
State(state): State<AppState>,
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
Json(payload): Json<CreateTagRequest>,
|
||||
) -> AppResult<Json<TagCatalogEntry>> {
|
||||
if payload.label.trim().is_empty() {
|
||||
return Err(AppError::bad_request("label must not be empty"));
|
||||
}
|
||||
|
||||
let mut conn = state.db()?;
|
||||
let new_tag = NewTag {
|
||||
id: Uuid::new_v4(),
|
||||
label: payload.label.trim().to_string(),
|
||||
color: payload.color,
|
||||
tenant_id,
|
||||
};
|
||||
|
||||
match diesel::insert_into(tags::table)
|
||||
@@ -90,22 +99,35 @@ pub async fn create_tag(
|
||||
Err(err) => return Err(AppError::from(err)),
|
||||
}
|
||||
|
||||
let tag: Tag = tags::table.find(new_tag.id).first(&mut conn)?;
|
||||
Ok(Json(TagCatalogEntry {
|
||||
let tag: Tag = tags::table
|
||||
.find(new_tag.id)
|
||||
.filter(tags::tenant_id.eq(tenant_id))
|
||||
.first(&mut conn)
|
||||
.one()?;
|
||||
|
||||
TagCatalogEntry {
|
||||
id: tag.id,
|
||||
label: tag.label,
|
||||
color: tag.color,
|
||||
usage_count: 0,
|
||||
}))
|
||||
}
|
||||
.into_json()
|
||||
}
|
||||
|
||||
pub async fn update_tag(
|
||||
State(state): State<AppState>,
|
||||
Path(tag_id): Path<Uuid>,
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<Json<TagCatalogEntry>> {
|
||||
let mut conn = state.db()?;
|
||||
let existing: Tag = tags::table.find(tag_id).first(&mut conn)?;
|
||||
let existing: Tag = tags::table
|
||||
.find(tag_id)
|
||||
.filter(tags::tenant_id.eq(tenant_id))
|
||||
.first(&mut conn)
|
||||
.one()?;
|
||||
let label_class = classify_nullable(body.get("label")).map_err(AppError::bad_request)?;
|
||||
let color_class = classify_nullable(body.get("color")).map_err(AppError::bad_request)?;
|
||||
|
||||
@@ -116,12 +138,13 @@ pub async fn update_tag(
|
||||
.filter(document_tags::tag_id.eq(tag_id))
|
||||
.select(count_star())
|
||||
.first(&mut conn)?;
|
||||
return Ok(Json(TagCatalogEntry {
|
||||
return TagCatalogEntry {
|
||||
id: existing.id,
|
||||
label: existing.label.clone(),
|
||||
color: existing.color.clone(),
|
||||
usage_count,
|
||||
}));
|
||||
}
|
||||
.into_json();
|
||||
}
|
||||
|
||||
let mut new_label: Option<String> = None;
|
||||
@@ -140,6 +163,7 @@ pub async fn update_tag(
|
||||
let duplicate = tags::table
|
||||
.filter(tags::label.eq(trimmed))
|
||||
.filter(tags::id.ne(tag_id))
|
||||
.filter(tags::tenant_id.eq(tenant_id))
|
||||
.first::<Tag>(&mut conn)
|
||||
.optional()?;
|
||||
if duplicate.is_some() {
|
||||
@@ -174,6 +198,7 @@ pub async fn update_tag(
|
||||
if !label_changed && !color_changed {
|
||||
let usage_count: i64 = document_tags::table
|
||||
.filter(document_tags::tag_id.eq(tag_id))
|
||||
.filter(document_tags::tenant_id.eq(tenant_id))
|
||||
.select(count_star())
|
||||
.first(&mut conn)?;
|
||||
return Ok(Json(TagCatalogEntry {
|
||||
@@ -191,32 +216,45 @@ pub async fn update_tag(
|
||||
.map(|opt| opt.as_ref().map(|value| value.as_str())),
|
||||
};
|
||||
|
||||
diesel::update(tags::table.find(tag_id))
|
||||
diesel::update(
|
||||
tags::table
|
||||
.find(tag_id)
|
||||
.filter(tags::tenant_id.eq(tenant_id)),
|
||||
)
|
||||
.set(&changeset)
|
||||
.execute(&mut conn)?;
|
||||
|
||||
let updated: Tag = tags::table.find(tag_id).first(&mut conn)?;
|
||||
let updated: Tag = tags::table
|
||||
.find(tag_id)
|
||||
.filter(tags::tenant_id.eq(tenant_id))
|
||||
.first(&mut conn)
|
||||
.one()?;
|
||||
let usage_count: i64 = document_tags::table
|
||||
.filter(document_tags::tag_id.eq(tag_id))
|
||||
.filter(document_tags::tenant_id.eq(tenant_id))
|
||||
.select(count_star())
|
||||
.first(&mut conn)?;
|
||||
|
||||
Ok(Json(TagCatalogEntry {
|
||||
TagCatalogEntry {
|
||||
id: updated.id,
|
||||
label: updated.label,
|
||||
color: updated.color,
|
||||
usage_count,
|
||||
}))
|
||||
}
|
||||
.into_json()
|
||||
}
|
||||
|
||||
pub async fn delete_tag(
|
||||
State(state): State<AppState>,
|
||||
Path(tag_id): Path<Uuid>,
|
||||
) -> AppResult<impl axum::response::IntoResponse> {
|
||||
let mut conn = state.db()?;
|
||||
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
) -> AppResult<StatusCode> {
|
||||
let usage: i64 = document_tags::table
|
||||
.filter(document_tags::tag_id.eq(tag_id))
|
||||
.filter(document_tags::tenant_id.eq(tenant_id))
|
||||
.select(count_star())
|
||||
.first(&mut conn)?;
|
||||
|
||||
@@ -226,10 +264,15 @@ pub async fn delete_tag(
|
||||
));
|
||||
}
|
||||
|
||||
let deleted = diesel::delete(tags::table.find(tag_id)).execute(&mut conn)?;
|
||||
let deleted = diesel::delete(
|
||||
tags::table
|
||||
.find(tag_id)
|
||||
.filter(tags::tenant_id.eq(tenant_id)),
|
||||
)
|
||||
.execute(&mut conn)?;
|
||||
if deleted == 0 {
|
||||
return Err(AppError::not_found());
|
||||
}
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
no_content()
|
||||
}
|
||||
|
||||
+146
-128
@@ -8,6 +8,7 @@ use axum::Router;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||
use base64::Engine;
|
||||
use diesel::prelude::*;
|
||||
use diesel::OptionalExtension;
|
||||
use diesel::PgConnection;
|
||||
use futures_util::StreamExt;
|
||||
use percent_encoding::{percent_decode_str, utf8_percent_encode, NON_ALPHANUMERIC};
|
||||
@@ -15,20 +16,23 @@ use quick_xml::events::{BytesDecl, BytesEnd, BytesStart, BytesText, Event};
|
||||
use quick_xml::Writer;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::password;
|
||||
use crate::auth::webdav_tokens::{find_active_token_by_secret, touch_webdav_token};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::models::{Document, DocumentVersion, Folder, User};
|
||||
use crate::schema::{
|
||||
document_versions::dsl as document_versions_dsl, documents::dsl as documents_dsl,
|
||||
folders::dsl as folders_dsl, users::dsl as users_dsl,
|
||||
folders::dsl as folders_dsl, tenants::dsl as tenant_dsl,
|
||||
user_memberships::dsl as memberships_dsl, users::dsl as users_dsl,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
use crate::utils::{error::StorageResultExt, http::inline_content_disposition, time::to_http_date};
|
||||
|
||||
const REALM: &str = "Papercrate WebDAV";
|
||||
const DOWNLOAD_URL_TTL_SECONDS: u64 = 300;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct WebDavUser {
|
||||
struct WebDavContext {
|
||||
tenant_id: Uuid,
|
||||
_user_id: Uuid,
|
||||
_username: String,
|
||||
}
|
||||
@@ -68,7 +72,7 @@ async fn handle_propfind(
|
||||
path: &str,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Response, AppError> {
|
||||
let _user = match authenticate(state, &headers)? {
|
||||
let context = match authenticate(state, &headers)? {
|
||||
Some(user) => user,
|
||||
None => return Ok(unauthorized_response()),
|
||||
};
|
||||
@@ -79,18 +83,21 @@ async fn handle_propfind(
|
||||
};
|
||||
|
||||
let segments = parse_segments(path)?;
|
||||
let resolution = match resolve_path(state, &segments)? {
|
||||
|
||||
let tenant_id = context.tenant_id;
|
||||
|
||||
let resources = if segments.is_empty() {
|
||||
let contents = fetch_folder_contents(state, tenant_id, None)?;
|
||||
build_resources_for_folder(None, &[], &contents, depth)
|
||||
} else {
|
||||
let resolution = match resolve_path(state, tenant_id, &segments)? {
|
||||
Some(resolved) => resolved,
|
||||
None => return Ok(not_found_response()),
|
||||
};
|
||||
|
||||
let resources = match resolution {
|
||||
ResolvedPath::Root => {
|
||||
let contents = fetch_folder_contents(state, None)?;
|
||||
build_resources_for_folder(None, &[], &contents, depth)
|
||||
}
|
||||
match resolution {
|
||||
ResolvedPath::Folder { folder, chain } => {
|
||||
let contents = fetch_folder_contents(state, Some(folder.id))?;
|
||||
let contents = fetch_folder_contents(state, tenant_id, Some(folder.id))?;
|
||||
build_resources_for_folder(Some(&folder), &chain, &contents, depth)
|
||||
}
|
||||
ResolvedPath::Document {
|
||||
@@ -98,10 +105,13 @@ async fn handle_propfind(
|
||||
version,
|
||||
chain,
|
||||
} => build_resources_for_document(&chain, &document, &version),
|
||||
}
|
||||
};
|
||||
|
||||
let body = render_multistatus(&resources)
|
||||
.map_err(|err| AppError::internal(format!("failed to render WebDAV response: {err}")))?;
|
||||
let body = render_multistatus(&resources).map_err(|err| {
|
||||
tracing::error!(error = ?err, "failed to render WebDAV response");
|
||||
AppError::internal("failed to render WebDAV response")
|
||||
})?;
|
||||
|
||||
let response = Response::builder()
|
||||
.status(multi_status())
|
||||
@@ -118,13 +128,18 @@ async fn handle_get_or_head(
|
||||
headers: HeaderMap,
|
||||
method: Method,
|
||||
) -> Result<Response, AppError> {
|
||||
let _user = match authenticate(state, &headers)? {
|
||||
let context = match authenticate(state, &headers)? {
|
||||
Some(user) => user,
|
||||
None => return Ok(unauthorized_response()),
|
||||
};
|
||||
|
||||
let tenant_id = context.tenant_id;
|
||||
let segments = parse_segments(path)?;
|
||||
let resolution = match resolve_path(state, &segments)? {
|
||||
if segments.is_empty() {
|
||||
return Ok(method_not_allowed());
|
||||
}
|
||||
|
||||
let resolution = match resolve_path(state, tenant_id, &segments)? {
|
||||
Some(resolved) => resolved,
|
||||
None => return Ok(not_found_response()),
|
||||
};
|
||||
@@ -219,21 +234,29 @@ fn parse_segments(path: &str) -> AppResult<Vec<String>> {
|
||||
|
||||
fn fetch_folder_contents(
|
||||
state: &AppState,
|
||||
tenant_id: Uuid,
|
||||
folder_id: Option<Uuid>,
|
||||
) -> AppResult<WebDavFolderContents> {
|
||||
let mut conn = state.db()?;
|
||||
let mut conn = state.db_for_tenant(tenant_id)?;
|
||||
|
||||
let folder = match folder_id {
|
||||
Some(id) => Some(folders_dsl::folders.find(id).first::<Folder>(&mut conn)?),
|
||||
Some(id) => Some(
|
||||
folders_dsl::folders
|
||||
.filter(folders_dsl::tenant_id.eq(tenant_id))
|
||||
.find(id)
|
||||
.first::<Folder>(&mut conn)?,
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let subfolders: Vec<Folder> = match folder_id {
|
||||
Some(id) => folders_dsl::folders
|
||||
.filter(folders_dsl::tenant_id.eq(tenant_id))
|
||||
.filter(folders_dsl::parent_id.eq(Some(id)))
|
||||
.order(folders_dsl::name.asc())
|
||||
.load(&mut conn)?,
|
||||
None => folders_dsl::folders
|
||||
.filter(folders_dsl::tenant_id.eq(tenant_id))
|
||||
.filter(folders_dsl::parent_id.is_null())
|
||||
.order(folders_dsl::name.asc())
|
||||
.load(&mut conn)?,
|
||||
@@ -241,6 +264,7 @@ fn fetch_folder_contents(
|
||||
|
||||
let mut docs_query = documents_dsl::documents
|
||||
.filter(documents_dsl::deleted_at.is_null())
|
||||
.filter(documents_dsl::tenant_id.eq(tenant_id))
|
||||
.into_boxed();
|
||||
|
||||
docs_query = match folder_id {
|
||||
@@ -290,14 +314,15 @@ async fn stream_document(
|
||||
) -> Result<Response, AppError> {
|
||||
let range_header = headers.get(header::RANGE).cloned();
|
||||
|
||||
let url = state
|
||||
.storage
|
||||
let storage = state.storage_for_tenant(document.tenant_id)?;
|
||||
|
||||
let url = storage
|
||||
.presign_get_object(
|
||||
&version.s3_key,
|
||||
Duration::from_secs(DOWNLOAD_URL_TTL_SECONDS),
|
||||
)
|
||||
.await
|
||||
.map_err(|err| AppError::internal(format!("failed to presign document download: {err}")))?;
|
||||
.storage_context("failed to presign document download")?;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let mut request = client.request(method.clone(), url.clone());
|
||||
@@ -306,18 +331,17 @@ async fn stream_document(
|
||||
request = request.header(header::RANGE, range.clone());
|
||||
}
|
||||
|
||||
let upstream = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| AppError::internal(format!("failed to fetch document stream: {err}")))?;
|
||||
let upstream = request.send().await.map_err(|err| {
|
||||
tracing::error!(error = ?err, "failed to fetch document stream");
|
||||
AppError::internal("failed to fetch document stream")
|
||||
})?;
|
||||
|
||||
let status =
|
||||
StatusCode::from_u16(upstream.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
|
||||
|
||||
if !(status.is_success() || status == StatusCode::PARTIAL_CONTENT) {
|
||||
return Err(AppError::internal(format!(
|
||||
"upstream download returned status {status}"
|
||||
)));
|
||||
tracing::error!(status = %status, "upstream download returned error status");
|
||||
return Err(AppError::internal("failed to fetch document stream"));
|
||||
}
|
||||
|
||||
let mut builder = Response::builder().status(status);
|
||||
@@ -338,16 +362,17 @@ async fn stream_document(
|
||||
|
||||
builder = builder.header("Accept-Ranges", "bytes");
|
||||
|
||||
if let Some(disposition) = content_disposition(&document.filename) {
|
||||
if let Some(disposition) = inline_content_disposition(&document.filename) {
|
||||
builder = builder.header(header::CONTENT_DISPOSITION, disposition);
|
||||
}
|
||||
|
||||
builder = builder.header(header::ETAG, format!("\"{}\"", version.id));
|
||||
|
||||
if method == Method::HEAD {
|
||||
return builder
|
||||
.body(Body::empty())
|
||||
.map_err(|err| AppError::internal(format!("failed to build response: {err}")));
|
||||
return builder.body(Body::empty()).map_err(|err| {
|
||||
tracing::error!(error = ?err, "failed to build WebDAV response");
|
||||
AppError::internal("failed to build WebDAV response")
|
||||
});
|
||||
}
|
||||
|
||||
let stream = upstream
|
||||
@@ -355,12 +380,13 @@ async fn stream_document(
|
||||
.map(|chunk| chunk.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err)));
|
||||
let body = Body::from_stream(stream);
|
||||
|
||||
builder
|
||||
.body(body)
|
||||
.map_err(|err| AppError::internal(format!("failed to build response: {err}")))
|
||||
builder.body(body).map_err(|err| {
|
||||
tracing::error!(error = ?err, "failed to build WebDAV response");
|
||||
AppError::internal("failed to build WebDAV response")
|
||||
})
|
||||
}
|
||||
|
||||
fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavUser>, AppError> {
|
||||
fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavContext>, AppError> {
|
||||
tracing::debug!("webdav authenticate invoked");
|
||||
let authorization = match headers.get(header::AUTHORIZATION) {
|
||||
Some(value) => match value.to_str() {
|
||||
@@ -399,13 +425,13 @@ fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavUs
|
||||
}
|
||||
};
|
||||
|
||||
let (username, password) = match credential_str.split_once(':') {
|
||||
Some((username, password)) if !username.is_empty() => (username, password),
|
||||
let (username, secret) = match credential_str.split_once(':') {
|
||||
Some((username, secret)) if !username.is_empty() => (username, secret),
|
||||
_ => return Ok(None),
|
||||
};
|
||||
|
||||
tracing::debug!(%username, "attempting webdav login");
|
||||
let mut conn = state.db()?;
|
||||
let mut conn = state.db_unscoped()?;
|
||||
|
||||
let user: User = match users_dsl::users
|
||||
.filter(users_dsl::username.eq(username))
|
||||
@@ -419,16 +445,44 @@ fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavUs
|
||||
Err(err) => return Err(AppError::from(err)),
|
||||
};
|
||||
|
||||
let valid = password::verify_password(password, &user.password_hash)
|
||||
.map_err(|_| AppError::internal("failed to verify password"))?;
|
||||
|
||||
if !valid {
|
||||
tracing::warn!(%username, "webdav password invalid");
|
||||
let token = match find_active_token_by_secret(&mut conn, user.id, None, secret)? {
|
||||
Some(token) => token,
|
||||
None => {
|
||||
tracing::warn!(%username, "webdav token invalid or expired");
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
|
||||
tracing::debug!(%username, "webdav login success");
|
||||
Ok(Some(WebDavUser {
|
||||
let tenant_row = memberships_dsl::user_memberships
|
||||
.inner_join(tenant_dsl::tenants)
|
||||
.filter(memberships_dsl::user_id.eq(user.id))
|
||||
.filter(memberships_dsl::tenant_id.eq(token.tenant_id))
|
||||
.select((tenant_dsl::id, tenant_dsl::name))
|
||||
.first::<(Uuid, String)>(&mut conn)
|
||||
.optional()?;
|
||||
|
||||
let (tenant_id, _name) = match tenant_row {
|
||||
Some(row) => row,
|
||||
None => {
|
||||
tracing::warn!(
|
||||
%username,
|
||||
tenant_id = %token.tenant_id,
|
||||
"webdav token tenant membership missing"
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
|
||||
touch_webdav_token(&mut conn, token.id)?;
|
||||
|
||||
tracing::debug!(
|
||||
%username,
|
||||
tenant_id = %tenant_id,
|
||||
token_id = %token.id,
|
||||
"webdav token login success"
|
||||
);
|
||||
Ok(Some(WebDavContext {
|
||||
tenant_id,
|
||||
_user_id: user.id,
|
||||
_username: user.username,
|
||||
}))
|
||||
@@ -444,10 +498,10 @@ fn build_resources_for_folder(
|
||||
|
||||
let display_name = folder
|
||||
.map(|folder| folder.name.clone())
|
||||
.unwrap_or_else(|| "/".to_string());
|
||||
.unwrap_or_else(|| chain.last().cloned().unwrap_or_else(|| "/".to_string()));
|
||||
|
||||
let href = build_href(chain, true);
|
||||
let last_modified = folder.map(|folder| format_http_date(folder.updated_at));
|
||||
let last_modified = folder.map(|folder| to_http_date(folder.updated_at));
|
||||
|
||||
resources.push(DavResource {
|
||||
href,
|
||||
@@ -471,7 +525,7 @@ fn build_resources_for_folder(
|
||||
is_collection: true,
|
||||
content_length: None,
|
||||
content_type: None,
|
||||
last_modified: Some(format_http_date(subfolder.updated_at)),
|
||||
last_modified: Some(to_http_date(subfolder.updated_at)),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -509,7 +563,7 @@ fn document_to_resource(
|
||||
is_collection: false,
|
||||
content_length: Some(version.size_bytes),
|
||||
content_type: document.content_type.clone(),
|
||||
last_modified: Some(format_http_date(document.updated_at)),
|
||||
last_modified: Some(to_http_date(document.updated_at)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -590,32 +644,6 @@ fn render_multistatus(resources: &[DavResource]) -> Result<Vec<u8>, quick_xml::E
|
||||
Ok(writer.into_inner())
|
||||
}
|
||||
|
||||
fn format_http_date(value: chrono::NaiveDateTime) -> String {
|
||||
let datetime = chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(value, chrono::Utc);
|
||||
datetime.format("%a, %d %b %Y %H:%M:%S GMT").to_string()
|
||||
}
|
||||
|
||||
fn content_disposition(filename: &str) -> Option<String> {
|
||||
if filename.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let sanitized: String = filename
|
||||
.chars()
|
||||
.map(|ch| match ch {
|
||||
'"' | '\\' => '_',
|
||||
_ => ch,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let encoded =
|
||||
percent_encoding::utf8_percent_encode(&sanitized, percent_encoding::NON_ALPHANUMERIC);
|
||||
Some(format!(
|
||||
"inline; filename=\"{}\"; filename*=UTF-8''{}",
|
||||
sanitized, encoded
|
||||
))
|
||||
}
|
||||
|
||||
struct WebDavFolderContents {
|
||||
_folder: Option<Folder>,
|
||||
subfolders: Vec<Folder>,
|
||||
@@ -635,9 +663,7 @@ struct DavResource {
|
||||
content_type: Option<String>,
|
||||
last_modified: Option<String>,
|
||||
}
|
||||
|
||||
enum ResolvedPath {
|
||||
Root,
|
||||
Folder {
|
||||
folder: Folder,
|
||||
chain: Vec<String>,
|
||||
@@ -649,12 +675,12 @@ enum ResolvedPath {
|
||||
},
|
||||
}
|
||||
|
||||
fn resolve_path(state: &AppState, segments: &[String]) -> AppResult<Option<ResolvedPath>> {
|
||||
if segments.is_empty() {
|
||||
return Ok(Some(ResolvedPath::Root));
|
||||
}
|
||||
|
||||
let mut conn = state.db()?;
|
||||
fn resolve_path(
|
||||
state: &AppState,
|
||||
tenant_id: Uuid,
|
||||
segments: &[String],
|
||||
) -> AppResult<Option<ResolvedPath>> {
|
||||
let mut conn = state.db_for_tenant(tenant_id)?;
|
||||
let mut parent_id: Option<Uuid> = None;
|
||||
let mut chain: Vec<String> = Vec::new();
|
||||
let mut current_folder: Option<Folder> = None;
|
||||
@@ -662,24 +688,19 @@ fn resolve_path(state: &AppState, segments: &[String]) -> AppResult<Option<Resol
|
||||
for (index, segment) in segments.iter().enumerate() {
|
||||
let is_last = index == segments.len() - 1;
|
||||
|
||||
match find_folder_by_name(&mut conn, parent_id, segment)? {
|
||||
Some(folder) => {
|
||||
if is_last {
|
||||
if let Some(folder) = find_folder_by_name(&mut conn, tenant_id, parent_id, segment)? {
|
||||
chain.push(folder.name.clone());
|
||||
if is_last {
|
||||
return Ok(Some(ResolvedPath::Folder { folder, chain }));
|
||||
}
|
||||
|
||||
parent_id = Some(folder.id);
|
||||
chain.push(folder.name.clone());
|
||||
current_folder = Some(folder);
|
||||
continue;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
|
||||
if is_last {
|
||||
if let Some((document, version)) =
|
||||
find_document_by_filename(&mut conn, parent_id, segment)?
|
||||
find_document_by_filename(&mut conn, tenant_id, parent_id, segment)?
|
||||
{
|
||||
chain.push(document.filename.clone());
|
||||
return Ok(Some(ResolvedPath::Document {
|
||||
@@ -691,26 +712,20 @@ fn resolve_path(state: &AppState, segments: &[String]) -> AppResult<Option<Resol
|
||||
}
|
||||
|
||||
if let Ok(uuid) = Uuid::parse_str(segment) {
|
||||
if let Some(folder) = folders_dsl::folders
|
||||
.find(uuid)
|
||||
.first::<Folder>(&mut conn)
|
||||
.optional()?
|
||||
{
|
||||
if let Some(folder) = find_folder_by_id(&mut conn, tenant_id, uuid)? {
|
||||
if folder.parent_id != parent_id {
|
||||
return Ok(None);
|
||||
}
|
||||
if !is_last {
|
||||
parent_id = Some(folder.id);
|
||||
chain.push(folder.name.clone());
|
||||
current_folder = Some(folder);
|
||||
continue;
|
||||
} else {
|
||||
chain.push(folder.name.clone());
|
||||
if is_last {
|
||||
return Ok(Some(ResolvedPath::Folder { folder, chain }));
|
||||
}
|
||||
parent_id = Some(folder.id);
|
||||
current_folder = Some(folder);
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some((document, version)) = find_document_by_id(&mut conn, uuid)? {
|
||||
if let Some((document, version)) = find_document_by_id(&mut conn, tenant_id, uuid)? {
|
||||
if document.folder_id != parent_id {
|
||||
return Ok(None);
|
||||
}
|
||||
@@ -723,19 +738,6 @@ fn resolve_path(state: &AppState, segments: &[String]) -> AppResult<Option<Resol
|
||||
}
|
||||
}
|
||||
|
||||
if is_last {
|
||||
if let Some((document, version)) =
|
||||
find_document_by_filename(&mut conn, parent_id, segment)?
|
||||
{
|
||||
chain.push(document.filename.clone());
|
||||
return Ok(Some(ResolvedPath::Document {
|
||||
document,
|
||||
version,
|
||||
chain,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
@@ -744,32 +746,46 @@ fn resolve_path(state: &AppState, segments: &[String]) -> AppResult<Option<Resol
|
||||
|
||||
fn find_folder_by_name(
|
||||
conn: &mut PgConnection,
|
||||
tenant_id: Uuid,
|
||||
parent_id: Option<Uuid>,
|
||||
name: &str,
|
||||
) -> AppResult<Option<Folder>> {
|
||||
let result = match parent_id {
|
||||
Some(parent) => folders_dsl::folders
|
||||
.filter(folders_dsl::parent_id.eq(Some(parent)))
|
||||
.filter(folders_dsl::name.eq(name))
|
||||
.first::<Folder>(conn)
|
||||
.optional()?,
|
||||
None => folders_dsl::folders
|
||||
.filter(folders_dsl::parent_id.is_null())
|
||||
.filter(folders_dsl::name.eq(name))
|
||||
.first::<Folder>(conn)
|
||||
.optional()?,
|
||||
let mut query = folders_dsl::folders
|
||||
.filter(folders_dsl::tenant_id.eq(tenant_id))
|
||||
.into_boxed();
|
||||
|
||||
query = match parent_id {
|
||||
Some(parent) => query.filter(folders_dsl::parent_id.eq(Some(parent))),
|
||||
None => query.filter(folders_dsl::parent_id.is_null()),
|
||||
};
|
||||
|
||||
Ok(result)
|
||||
Ok(query
|
||||
.filter(folders_dsl::name.eq(name))
|
||||
.first::<Folder>(conn)
|
||||
.optional()?)
|
||||
}
|
||||
|
||||
fn find_folder_by_id(
|
||||
conn: &mut PgConnection,
|
||||
tenant_id: Uuid,
|
||||
folder_id: Uuid,
|
||||
) -> AppResult<Option<Folder>> {
|
||||
Ok(folders_dsl::folders
|
||||
.filter(folders_dsl::tenant_id.eq(tenant_id))
|
||||
.find(folder_id)
|
||||
.first::<Folder>(conn)
|
||||
.optional()?)
|
||||
}
|
||||
|
||||
fn find_document_by_filename(
|
||||
conn: &mut PgConnection,
|
||||
tenant_id: Uuid,
|
||||
parent_id: Option<Uuid>,
|
||||
filename: &str,
|
||||
) -> AppResult<Option<(Document, DocumentVersion)>> {
|
||||
let mut query = documents_dsl::documents
|
||||
.filter(documents_dsl::deleted_at.is_null())
|
||||
.filter(documents_dsl::tenant_id.eq(tenant_id))
|
||||
.filter(documents_dsl::filename.eq(filename))
|
||||
.into_boxed();
|
||||
|
||||
@@ -790,10 +806,12 @@ fn find_document_by_filename(
|
||||
|
||||
fn find_document_by_id(
|
||||
conn: &mut PgConnection,
|
||||
tenant_id: Uuid,
|
||||
document_id: Uuid,
|
||||
) -> AppResult<Option<(Document, DocumentVersion)>> {
|
||||
if let Some(document) = documents_dsl::documents
|
||||
.filter(documents_dsl::deleted_at.is_null())
|
||||
.filter(documents_dsl::tenant_id.eq(tenant_id))
|
||||
.find(document_id)
|
||||
.first::<Document>(conn)
|
||||
.optional()?
|
||||
|
||||
+127
-9
@@ -1,5 +1,11 @@
|
||||
// @generated automatically by Diesel CLI.
|
||||
|
||||
pub mod sql_types {
|
||||
#[derive(diesel::query_builder::QueryId, Clone, diesel::sql_types::SqlType)]
|
||||
#[diesel(postgres_type(name = "tenant_status"))]
|
||||
pub struct TenantStatus;
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
correspondents (id) {
|
||||
id -> Uuid,
|
||||
@@ -8,6 +14,18 @@ diesel::table! {
|
||||
metadata -> Jsonb,
|
||||
created_at -> Timestamptz,
|
||||
updated_at -> Timestamptz,
|
||||
tenant_id -> Uuid,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
document_asset_objects (id) {
|
||||
id -> Uuid,
|
||||
asset_id -> Uuid,
|
||||
ordinal -> Int4,
|
||||
s3_key -> Text,
|
||||
metadata -> Jsonb,
|
||||
tenant_id -> Uuid,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,21 +34,21 @@ diesel::table! {
|
||||
id -> Uuid,
|
||||
document_version_id -> Uuid,
|
||||
asset_type -> Text,
|
||||
s3_key -> Text,
|
||||
mime_type -> Text,
|
||||
metadata -> Jsonb,
|
||||
created_at -> Timestamptz,
|
||||
cardinality -> Nullable<Int4>,
|
||||
tenant_id -> Uuid,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
document_correspondents (document_id, correspondent_id, role) {
|
||||
document_correspondents (document_id, correspondent_id) {
|
||||
document_id -> Uuid,
|
||||
correspondent_id -> Uuid,
|
||||
#[max_length = 32]
|
||||
role -> Varchar,
|
||||
assigned_at -> Timestamptz,
|
||||
assigned_by -> Nullable<Uuid>,
|
||||
tenant_id -> Uuid,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +58,7 @@ diesel::table! {
|
||||
tag_id -> Uuid,
|
||||
assigned_at -> Timestamptz,
|
||||
assigned_by -> Nullable<Uuid>,
|
||||
tenant_id -> Uuid,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,8 +73,8 @@ diesel::table! {
|
||||
#[max_length = 64]
|
||||
checksum -> Varchar,
|
||||
created_at -> Timestamptz,
|
||||
operations_summary -> Jsonb,
|
||||
metadata -> Jsonb,
|
||||
tenant_id -> Uuid,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +96,7 @@ diesel::table! {
|
||||
#[max_length = 255]
|
||||
title -> Varchar,
|
||||
current_version_id -> Uuid,
|
||||
tenant_id -> Uuid,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +108,7 @@ diesel::table! {
|
||||
parent_id -> Nullable<Uuid>,
|
||||
created_at -> Timestamptz,
|
||||
updated_at -> Timestamptz,
|
||||
tenant_id -> Uuid,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +123,7 @@ diesel::table! {
|
||||
last_error -> Nullable<Text>,
|
||||
created_at -> Timestamptz,
|
||||
updated_at -> Timestamptz,
|
||||
tenant_id -> Uuid,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +137,7 @@ diesel::table! {
|
||||
revoked_at -> Nullable<Timestamptz>,
|
||||
created_at -> Timestamptz,
|
||||
updated_at -> Timestamptz,
|
||||
tenant_id -> Uuid,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,6 +149,54 @@ diesel::table! {
|
||||
#[max_length = 7]
|
||||
color -> Nullable<Varchar>,
|
||||
created_at -> Timestamptz,
|
||||
tenant_id -> Uuid,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
use diesel::sql_types::*;
|
||||
use super::sql_types::TenantStatus;
|
||||
|
||||
tenants (id) {
|
||||
id -> Uuid,
|
||||
name -> Text,
|
||||
storage_root -> Nullable<Text>,
|
||||
quickwit_index -> Nullable<Text>,
|
||||
config -> Jsonb,
|
||||
created_at -> Timestamptz,
|
||||
updated_at -> Timestamptz,
|
||||
status -> TenantStatus,
|
||||
created_by -> Nullable<Uuid>,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
user_memberships (id) {
|
||||
id -> Uuid,
|
||||
user_id -> Uuid,
|
||||
tenant_id -> Uuid,
|
||||
created_at -> Timestamptz,
|
||||
updated_at -> Timestamptz,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
user_passkeys (id) {
|
||||
id -> Uuid,
|
||||
user_id -> Uuid,
|
||||
credential_id -> Bytea,
|
||||
public_key -> Bytea,
|
||||
credential -> Jsonb,
|
||||
sign_count -> Int8,
|
||||
transports -> Array<Nullable<Text>>,
|
||||
aaguid -> Nullable<Uuid>,
|
||||
nickname -> Nullable<Text>,
|
||||
created_at -> Timestamptz,
|
||||
updated_at -> Timestamptz,
|
||||
last_used_at -> Nullable<Timestamptz>,
|
||||
revoked_at -> Nullable<Timestamptz>,
|
||||
revoked_by -> Nullable<Uuid>,
|
||||
revoked_reason -> Nullable<Text>,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,27 +205,69 @@ diesel::table! {
|
||||
id -> Uuid,
|
||||
#[max_length = 100]
|
||||
username -> Varchar,
|
||||
#[max_length = 255]
|
||||
password_hash -> Varchar,
|
||||
#[max_length = 16]
|
||||
role -> Varchar,
|
||||
created_at -> Timestamptz,
|
||||
updated_at -> Timestamptz,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
webauthn_challenges (id) {
|
||||
id -> Uuid,
|
||||
user_id -> Nullable<Uuid>,
|
||||
purpose -> Text,
|
||||
challenge -> Bytea,
|
||||
state -> Bytea,
|
||||
created_at -> Timestamptz,
|
||||
expires_at -> Timestamptz,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
webdav_tokens (id) {
|
||||
id -> Uuid,
|
||||
user_id -> Uuid,
|
||||
tenant_id -> Uuid,
|
||||
token_prefix -> Text,
|
||||
token_hash -> Text,
|
||||
label -> Nullable<Text>,
|
||||
created_at -> Timestamptz,
|
||||
last_used_at -> Nullable<Timestamptz>,
|
||||
expires_at -> Nullable<Timestamptz>,
|
||||
revoked_at -> Nullable<Timestamptz>,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::joinable!(correspondents -> tenants (tenant_id));
|
||||
diesel::joinable!(document_asset_objects -> document_assets (asset_id));
|
||||
diesel::joinable!(document_asset_objects -> tenants (tenant_id));
|
||||
diesel::joinable!(document_assets -> document_versions (document_version_id));
|
||||
diesel::joinable!(document_assets -> tenants (tenant_id));
|
||||
diesel::joinable!(document_correspondents -> correspondents (correspondent_id));
|
||||
diesel::joinable!(document_correspondents -> documents (document_id));
|
||||
diesel::joinable!(document_correspondents -> tenants (tenant_id));
|
||||
diesel::joinable!(document_correspondents -> users (assigned_by));
|
||||
diesel::joinable!(document_tags -> documents (document_id));
|
||||
diesel::joinable!(document_tags -> tags (tag_id));
|
||||
diesel::joinable!(document_tags -> tenants (tenant_id));
|
||||
diesel::joinable!(document_tags -> users (assigned_by));
|
||||
diesel::joinable!(document_versions -> tenants (tenant_id));
|
||||
diesel::joinable!(documents -> folders (folder_id));
|
||||
diesel::joinable!(documents -> tenants (tenant_id));
|
||||
diesel::joinable!(folders -> tenants (tenant_id));
|
||||
diesel::joinable!(jobs -> tenants (tenant_id));
|
||||
diesel::joinable!(refresh_tokens -> tenants (tenant_id));
|
||||
diesel::joinable!(refresh_tokens -> users (user_id));
|
||||
diesel::joinable!(tags -> tenants (tenant_id));
|
||||
diesel::joinable!(user_memberships -> tenants (tenant_id));
|
||||
diesel::joinable!(user_memberships -> users (user_id));
|
||||
diesel::joinable!(user_passkeys -> users (user_id));
|
||||
diesel::joinable!(webauthn_challenges -> users (user_id));
|
||||
diesel::joinable!(webdav_tokens -> tenants (tenant_id));
|
||||
diesel::joinable!(webdav_tokens -> users (user_id));
|
||||
|
||||
diesel::allow_tables_to_appear_in_same_query!(
|
||||
correspondents,
|
||||
document_asset_objects,
|
||||
document_assets,
|
||||
document_correspondents,
|
||||
document_tags,
|
||||
@@ -164,5 +277,10 @@ diesel::allow_tables_to_appear_in_same_query!(
|
||||
jobs,
|
||||
refresh_tokens,
|
||||
tags,
|
||||
tenants,
|
||||
user_memberships,
|
||||
user_passkeys,
|
||||
users,
|
||||
webauthn_challenges,
|
||||
webdav_tokens,
|
||||
);
|
||||
|
||||
+58
-9
@@ -4,43 +4,92 @@ use diesel::{
|
||||
pg::PgConnection,
|
||||
r2d2::{ConnectionManager, PooledConnection},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
auth::jwt::JwtService,
|
||||
auth::{jwt::JwtService, passkeys::PasskeyService},
|
||||
config::AppConfig,
|
||||
db::PgPool,
|
||||
error::{AppError, AppResult},
|
||||
storage::ObjectStorage,
|
||||
storage::{ObjectStorage, TenantStorage},
|
||||
tenants::{apply_tenant_guc, TenantService},
|
||||
};
|
||||
|
||||
type PgPooledConnection = PooledConnection<ConnectionManager<PgConnection>>;
|
||||
pub type PgPooledConnection = PooledConnection<ConnectionManager<PgConnection>>;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub pool: PgPool,
|
||||
pub config: Arc<AppConfig>,
|
||||
pub storage: Arc<dyn ObjectStorage>,
|
||||
storage: Arc<dyn ObjectStorage>,
|
||||
pub jwt: JwtService,
|
||||
pub tenants: TenantService,
|
||||
pub passkeys: Option<PasskeyService>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub async fn initialize(
|
||||
config: AppConfig,
|
||||
pool_size_override: Option<u32>,
|
||||
) -> anyhow::Result<Self> {
|
||||
let pool_size = pool_size_override.unwrap_or(config.database_max_pool_size);
|
||||
let pool = crate::db::init_pool_with_size(&config.database_url, pool_size)?;
|
||||
let s3_client = crate::s3::build_client(&config).await?;
|
||||
let storage = Arc::new(crate::storage::S3Storage::new(
|
||||
s3_client,
|
||||
config.s3_bucket.clone(),
|
||||
));
|
||||
let jwt = crate::auth::jwt::JwtService::from_config(&config)?;
|
||||
|
||||
Ok(Self::new(pool, config, storage, jwt))
|
||||
}
|
||||
|
||||
pub fn new(
|
||||
pool: PgPool,
|
||||
config: AppConfig,
|
||||
storage: Arc<dyn ObjectStorage>,
|
||||
jwt: JwtService,
|
||||
) -> Self {
|
||||
let config = Arc::new(config);
|
||||
let tenants = TenantService::new(pool.clone());
|
||||
|
||||
let passkeys = match PasskeyService::try_new(&config) {
|
||||
Ok(service) => service,
|
||||
Err(err) => {
|
||||
tracing::warn!(error = ?err, "passkey service disabled due to configuration");
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
Self {
|
||||
pool,
|
||||
config: Arc::new(config),
|
||||
config,
|
||||
storage,
|
||||
jwt,
|
||||
tenants,
|
||||
passkeys,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn db(&self) -> AppResult<PgPooledConnection> {
|
||||
self.pool
|
||||
.get()
|
||||
.map_err(|err| AppError::internal(format!("database pool error: {err}")))
|
||||
pub fn db_for_tenant(&self, tenant_id: Uuid) -> AppResult<PgPooledConnection> {
|
||||
debug_assert!(!tenant_id.is_nil(), "nil tenant_id passed to db_for_tenant");
|
||||
let mut conn = self.db_unscoped()?;
|
||||
apply_tenant_guc(&mut conn, tenant_id)?;
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
pub(crate) fn db_unscoped(&self) -> AppResult<PgPooledConnection> {
|
||||
self.pool.get().map_err(|err| {
|
||||
tracing::error!(error = ?err, "database pool error");
|
||||
AppError::internal("database pool error")
|
||||
})
|
||||
}
|
||||
|
||||
pub fn storage_for_tenant(&self, tenant_id: Uuid) -> AppResult<TenantStorage> {
|
||||
let tenant = self.tenants.get_by_id(tenant_id)?;
|
||||
TenantStorage::new(self.storage.clone(), &tenant).map_err(|err| {
|
||||
tracing::error!(error = ?err, "tenant storage error");
|
||||
AppError::internal("tenant storage error")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+54
-1
@@ -1,11 +1,15 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use async_trait::async_trait;
|
||||
use aws_sdk_s3::presigning::PresigningConfig;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::Client as S3Client;
|
||||
|
||||
use crate::models::Tenant;
|
||||
|
||||
#[async_trait]
|
||||
pub trait ObjectStorage: Send + Sync + 'static {
|
||||
async fn put_object(
|
||||
@@ -119,3 +123,52 @@ impl ObjectStorage for S3Storage {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
#[derive(Clone)]
|
||||
pub struct TenantStorage {
|
||||
inner: Arc<dyn ObjectStorage>,
|
||||
root: String,
|
||||
}
|
||||
|
||||
impl TenantStorage {
|
||||
pub fn new(inner: Arc<dyn ObjectStorage>, tenant: &Tenant) -> Result<Self> {
|
||||
let root = tenant
|
||||
.storage_root
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow!("tenant {} missing storage_root", tenant.id))?
|
||||
.to_owned();
|
||||
|
||||
Ok(Self { inner, root })
|
||||
}
|
||||
|
||||
fn qualify(&self, key: &str) -> String {
|
||||
format!("{}{}", self.root, key)
|
||||
}
|
||||
|
||||
pub async fn put_object(
|
||||
&self,
|
||||
key: &str,
|
||||
bytes: Vec<u8>,
|
||||
content_type: Option<String>,
|
||||
content_disposition: Option<String>,
|
||||
) -> Result<()> {
|
||||
let qualified = self.qualify(key);
|
||||
self.inner
|
||||
.put_object(&qualified, bytes, content_type, content_disposition)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn presign_get_object(&self, key: &str, expires_in: Duration) -> Result<String> {
|
||||
let qualified = self.qualify(key);
|
||||
self.inner.presign_get_object(&qualified, expires_in).await
|
||||
}
|
||||
|
||||
pub async fn get_object(&self, key: &str) -> Result<Vec<u8>> {
|
||||
let qualified = self.qualify(key);
|
||||
self.inner.get_object(&qualified).await
|
||||
}
|
||||
|
||||
pub async fn delete_object(&self, key: &str) -> Result<()> {
|
||||
let qualified = self.qualify(key);
|
||||
self.inner.delete_object(&qualified).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
use diesel::{pg::PgConnection, prelude::*, sql_types::Text};
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
db::PgPool,
|
||||
error::{AppError, AppResult},
|
||||
jobs::{enqueue_job, JOB_PROVISION_TENANT},
|
||||
models::{Tenant, TenantStatus},
|
||||
schema::tenants::dsl,
|
||||
};
|
||||
|
||||
pub struct TenantRepository;
|
||||
|
||||
impl TenantRepository {
|
||||
pub fn get_by_id(conn: &mut PgConnection, tenant_id: Uuid) -> AppResult<Tenant> {
|
||||
dsl::tenants.find(tenant_id).first(conn).map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn get_by_name(conn: &mut PgConnection, name: &str) -> AppResult<Tenant> {
|
||||
dsl::tenants
|
||||
.filter(dsl::name.eq(name))
|
||||
.first(conn)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TenantService {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl TenantService {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
pub fn get_by_id(&self, tenant_id: Uuid) -> AppResult<Tenant> {
|
||||
let tenant = self.load(|conn| TenantRepository::get_by_id(conn, tenant_id))?;
|
||||
Ok(tenant)
|
||||
}
|
||||
|
||||
pub fn get_by_name(&self, name: &str) -> AppResult<Tenant> {
|
||||
let name_owned = name.to_owned();
|
||||
let tenant = self.load(|conn| TenantRepository::get_by_name(conn, &name_owned))?;
|
||||
Ok(tenant)
|
||||
}
|
||||
|
||||
pub fn create_tenant(
|
||||
&self,
|
||||
name: &str,
|
||||
storage_root: Option<&str>,
|
||||
quickwit_index: Option<&str>,
|
||||
status: TenantStatus,
|
||||
initial_members: &[Uuid],
|
||||
created_by: Option<Uuid>,
|
||||
) -> AppResult<Tenant> {
|
||||
let mut conn = self.pool.get().map_err(|err| {
|
||||
tracing::error!(error = ?err, "database pool error");
|
||||
AppError::internal("database pool error")
|
||||
})?;
|
||||
self.create_tenant_with_conn(
|
||||
&mut conn,
|
||||
name,
|
||||
storage_root,
|
||||
quickwit_index,
|
||||
status,
|
||||
initial_members,
|
||||
created_by,
|
||||
)
|
||||
}
|
||||
|
||||
fn load<F>(&self, loader: F) -> AppResult<Tenant>
|
||||
where
|
||||
F: FnOnce(&mut PgConnection) -> AppResult<Tenant>,
|
||||
{
|
||||
let mut conn = self.pool.get().map_err(|err| {
|
||||
tracing::error!(error = ?err, "database pool error");
|
||||
AppError::internal("database pool error")
|
||||
})?;
|
||||
let tenant = loader(&mut conn)?;
|
||||
Ok(tenant)
|
||||
}
|
||||
pub fn create_tenant_with_conn(
|
||||
&self,
|
||||
conn: &mut PgConnection,
|
||||
name: &str,
|
||||
storage_root: Option<&str>,
|
||||
quickwit_index: Option<&str>,
|
||||
status: TenantStatus,
|
||||
initial_members: &[Uuid],
|
||||
created_by: Option<Uuid>,
|
||||
) -> AppResult<Tenant> {
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
return Err(AppError::bad_request("tenant name must not be empty"));
|
||||
}
|
||||
|
||||
let id = Uuid::new_v4();
|
||||
let storage_root = normalize_storage_root(storage_root, id);
|
||||
let quickwit_index = normalize_quickwit_index(quickwit_index, id);
|
||||
|
||||
diesel::insert_into(dsl::tenants)
|
||||
.values((
|
||||
dsl::id.eq(id),
|
||||
dsl::name.eq(name),
|
||||
dsl::storage_root.eq(Some(storage_root.clone())),
|
||||
dsl::quickwit_index.eq(Some(quickwit_index.clone())),
|
||||
dsl::config.eq(json!({})),
|
||||
dsl::status.eq(status),
|
||||
dsl::created_by.eq(created_by),
|
||||
))
|
||||
.execute(conn)?;
|
||||
|
||||
if status == TenantStatus::Creating {
|
||||
let payload = json!({
|
||||
"members": initial_members,
|
||||
});
|
||||
|
||||
enqueue_job(conn, id, JOB_PROVISION_TENANT, payload, None).map_err(|err| {
|
||||
tracing::error!(error = ?err, tenant_id = %id, "failed to enqueue tenant provisioning job");
|
||||
AppError::internal("failed to enqueue tenant provisioning job")
|
||||
})?;
|
||||
}
|
||||
|
||||
TenantRepository::get_by_id(conn, id)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply_tenant_guc(conn: &mut PgConnection, tenant_id: Uuid) -> AppResult<()> {
|
||||
diesel::sql_query("SELECT set_config('papercrate.tenant_id', $1, true)")
|
||||
.bind::<Text, _>(tenant_id.to_string())
|
||||
.execute(conn)
|
||||
.map(|_| ())
|
||||
.map_err(AppError::from)
|
||||
}
|
||||
|
||||
fn normalize_storage_root(raw: Option<&str>, tenant_id: Uuid) -> String {
|
||||
match raw.map(str::trim) {
|
||||
Some(root) if !root.is_empty() => {
|
||||
let mut owned = root.to_owned();
|
||||
if !owned.ends_with('/') {
|
||||
owned.push('/');
|
||||
}
|
||||
owned
|
||||
}
|
||||
_ => format!("tenants/{tenant_id}/"),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_quickwit_index(raw: Option<&str>, tenant_id: Uuid) -> String {
|
||||
match raw.map(str::trim) {
|
||||
Some(value) if !value.is_empty() => value.to_owned(),
|
||||
_ => format!("documents-{tenant_id}"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::{config::AppConfig, state::AppState, utils::tracing::init_tracing};
|
||||
|
||||
/// Initialize tracing, load configuration, and build the shared `AppState`.
|
||||
/// Optionally override the connection pool size for lightweight components.
|
||||
pub async fn init_component(name: &str, pool_override: Option<u32>) -> Result<Arc<AppState>> {
|
||||
init_tracing("info");
|
||||
let config = AppConfig::load_and_log(name)?;
|
||||
let state = AppState::initialize(config, pool_override).await?;
|
||||
Ok(Arc::new(state))
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user