Compare commits
86
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
42a3a53314 | ||
|
|
4a06fa82eb | ||
|
|
80238bb7a1 | ||
|
|
dcbd46531e | ||
|
|
0864b39336 | ||
|
|
2a0c96bb4c | ||
|
|
c53213a357 | ||
|
|
dc633783e0 | ||
|
|
e33ab71fac |
Generated
+315
@@ -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"
|
||||
@@ -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",
|
||||
@@ -637,6 +680,8 @@ dependencies = [
|
||||
"url",
|
||||
"utoipa",
|
||||
"uuid",
|
||||
"webauthn-rs",
|
||||
"webauthn-rs-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -688,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"
|
||||
@@ -937,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"
|
||||
@@ -1004,6 +1066,12 @@ dependencies = [
|
||||
"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"
|
||||
@@ -1014,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"
|
||||
@@ -1175,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"
|
||||
@@ -1244,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"
|
||||
@@ -1418,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"
|
||||
@@ -2148,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"
|
||||
@@ -2211,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"
|
||||
@@ -2687,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"
|
||||
@@ -2904,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"
|
||||
@@ -3759,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"
|
||||
@@ -4001,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"
|
||||
|
||||
@@ -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"
|
||||
@@ -54,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"
|
||||
@@ -62,3 +67,4 @@ rand = "0.8"
|
||||
once_cell = "1.19"
|
||||
hyper = "1.2"
|
||||
http-body-util = "0.1"
|
||||
webauthn-rs-core = "0.5"
|
||||
|
||||
@@ -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;
|
||||
@@ -17,6 +17,8 @@ pub struct JwtService {
|
||||
download_expiry: Duration,
|
||||
selector_audience: String,
|
||||
selector_expiry: Duration,
|
||||
signup_audience: String,
|
||||
signup_expiry: Duration,
|
||||
}
|
||||
|
||||
impl JwtService {
|
||||
@@ -31,6 +33,8 @@ impl JwtService {
|
||||
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),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -108,6 +112,35 @@ impl JwtService {
|
||||
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)]
|
||||
@@ -140,3 +173,14 @@ pub struct TenantSelectionClaims {
|
||||
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,
|
||||
pub exp: usize,
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
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};
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
+140
-290
@@ -2,115 +2,81 @@ use std::env;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use argon2::{
|
||||
password_hash::{PasswordHasher, SaltString},
|
||||
Argon2,
|
||||
};
|
||||
use diesel::{dsl::exists, prelude::*, select};
|
||||
use once_cell::sync::Lazy;
|
||||
use reqwest::{Client, Method, StatusCode};
|
||||
use serde_json::json;
|
||||
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, User},
|
||||
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,
|
||||
};
|
||||
|
||||
use rand::rngs::OsRng;
|
||||
|
||||
static QUICKWIT_INDEX_TEMPLATE: Lazy<serde_json::Value> = Lazy::new(|| {
|
||||
json!({
|
||||
"version": "0.8",
|
||||
"index_id": "documents",
|
||||
"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"]
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
#[derive(Debug)]
|
||||
enum Command {
|
||||
CreateUser {
|
||||
username: String,
|
||||
password: String,
|
||||
},
|
||||
SetPassword {
|
||||
username: String,
|
||||
password: String,
|
||||
},
|
||||
ListUsers,
|
||||
DeleteUser {
|
||||
username: String,
|
||||
},
|
||||
CreateTenant {
|
||||
slug: String,
|
||||
name: String,
|
||||
storage_root: Option<String>,
|
||||
quickwit_index: Option<String>,
|
||||
},
|
||||
DeleteTenant {
|
||||
slug: String,
|
||||
tenant_id: Uuid,
|
||||
},
|
||||
AddUserToTenant {
|
||||
username: String,
|
||||
slug: String,
|
||||
role: Option<String>,
|
||||
tenant_id: Uuid,
|
||||
},
|
||||
RemoveUserFromTenant {
|
||||
username: String,
|
||||
slug: String,
|
||||
tenant_id: Uuid,
|
||||
},
|
||||
ReanalyzeDocuments {
|
||||
slug: String,
|
||||
tenant_id: Uuid,
|
||||
},
|
||||
ListTenants,
|
||||
DeleteAssets(String),
|
||||
QuickwitCreate(String),
|
||||
QuickwitDelete(String),
|
||||
DeleteAssets(Uuid),
|
||||
QuickwitCreate(Uuid),
|
||||
QuickwitDelete(Uuid),
|
||||
}
|
||||
|
||||
impl Command {
|
||||
fn usage() -> &'static str {
|
||||
"Usage: admin\n\
|
||||
create-user <username> <password>\n\
|
||||
set-password <username> <password>\n\
|
||||
create-user <username>\n\
|
||||
list-users\n\
|
||||
delete-user <username>\n\
|
||||
create-tenant <slug> [storage_root] [quickwit_index]\n\
|
||||
delete-tenant <slug>\n\
|
||||
add-user-to-tenant <username> <slug> [role]\n\
|
||||
remove-user-from-tenant <username> <slug>\n\
|
||||
reanalyze-documents <slug>\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 <slug>\n\
|
||||
quickwit-create-index <slug>\n\
|
||||
quickwit-delete-index <slug>"
|
||||
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> {
|
||||
@@ -118,46 +84,38 @@ impl Command {
|
||||
match args.next().as_deref() {
|
||||
Some("create-user") => Ok(Self::CreateUser {
|
||||
username: args.next().ok_or_else(|| anyhow!("username required"))?,
|
||||
password: args.next().ok_or_else(|| anyhow!("password required"))?,
|
||||
}),
|
||||
Some("set-password") => Ok(Self::SetPassword {
|
||||
username: args.next().ok_or_else(|| anyhow!("username required"))?,
|
||||
password: args.next().ok_or_else(|| anyhow!("password 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 {
|
||||
slug: args.next().ok_or_else(|| anyhow!("tenant slug required"))?,
|
||||
name: args.next().ok_or_else(|| anyhow!("tenant name required"))?,
|
||||
storage_root: args.next(),
|
||||
quickwit_index: args.next(),
|
||||
}),
|
||||
Some("delete-tenant") => Ok(Self::DeleteTenant {
|
||||
slug: args.next().ok_or_else(|| anyhow!("tenant slug required"))?,
|
||||
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"))?,
|
||||
slug: args.next().ok_or_else(|| anyhow!("tenant slug required"))?,
|
||||
role: args.next(),
|
||||
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"))?,
|
||||
slug: args.next().ok_or_else(|| anyhow!("tenant slug required"))?,
|
||||
tenant_id: Self::parse_tenant_id(args.next())?,
|
||||
}),
|
||||
Some("reanalyze-documents") => Ok(Self::ReanalyzeDocuments {
|
||||
slug: args.next().ok_or_else(|| anyhow!("tenant slug required"))?,
|
||||
tenant_id: Self::parse_tenant_id(args.next())?,
|
||||
}),
|
||||
Some("list-tenants") => Ok(Self::ListTenants),
|
||||
Some("delete-assets") => Ok(Self::DeleteAssets(
|
||||
args.next().ok_or_else(|| anyhow!("tenant slug required"))?,
|
||||
)),
|
||||
Some("quickwit-create-index") => Ok(Self::QuickwitCreate(
|
||||
args.next().ok_or_else(|| anyhow!("tenant slug required"))?,
|
||||
)),
|
||||
Some("quickwit-delete-index") => Ok(Self::QuickwitDelete(
|
||||
args.next().ok_or_else(|| anyhow!("tenant slug required"))?,
|
||||
)),
|
||||
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())),
|
||||
}
|
||||
}
|
||||
@@ -171,45 +129,43 @@ async fn main() -> Result<()> {
|
||||
let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?;
|
||||
|
||||
match command {
|
||||
Command::CreateUser { username, password } => create_user(&pool, &username, &password)?,
|
||||
Command::SetPassword { username, password } => set_password(&pool, &username, &password)?,
|
||||
Command::CreateUser { username } => create_user(&pool, &username)?,
|
||||
Command::ListUsers => list_users(&pool)?,
|
||||
Command::DeleteUser { username } => delete_user(&pool, &username)?,
|
||||
Command::CreateTenant {
|
||||
slug,
|
||||
name,
|
||||
storage_root,
|
||||
quickwit_index,
|
||||
} => create_tenant(&pool, &slug, storage_root, quickwit_index)?,
|
||||
Command::DeleteTenant { slug } => delete_tenant(&pool, &slug)?,
|
||||
} => create_tenant(&pool, &name, storage_root, quickwit_index)?,
|
||||
Command::DeleteTenant { tenant_id } => delete_tenant(&pool, tenant_id)?,
|
||||
Command::AddUserToTenant {
|
||||
username,
|
||||
slug,
|
||||
role,
|
||||
} => add_user_to_tenant(&pool, &username, &slug, role.as_deref())?,
|
||||
Command::RemoveUserFromTenant { username, slug } => {
|
||||
remove_user_from_tenant(&pool, &username, &slug)?
|
||||
}
|
||||
Command::ReanalyzeDocuments { slug } => reanalyze_documents(&pool, &slug)?,
|
||||
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(slug) => delete_assets_for_tenant(&config, &pool, &slug).await?,
|
||||
Command::QuickwitCreate(slug) => {
|
||||
quickwit_index(&config, &pool, &slug, Method::POST).await?
|
||||
Command::DeleteAssets(tenant_id) => {
|
||||
delete_assets_for_tenant(&config, &pool, tenant_id).await?
|
||||
}
|
||||
Command::QuickwitDelete(slug) => {
|
||||
quickwit_index(&config, &pool, &slug, Method::DELETE).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, password: &str) -> Result<()> {
|
||||
fn create_user(pool: &PgPool, username: &str) -> Result<()> {
|
||||
if username.trim().is_empty() {
|
||||
bail!("username must not be empty");
|
||||
}
|
||||
if password.is_empty() {
|
||||
bail!("password must not be empty");
|
||||
}
|
||||
|
||||
let mut conn = pool.get().context("failed to get database connection")?;
|
||||
let exists: bool =
|
||||
@@ -218,11 +174,9 @@ fn create_user(pool: &PgPool, username: &str, password: &str) -> Result<()> {
|
||||
bail!("user '{}' already exists", username);
|
||||
}
|
||||
|
||||
let password_hash = hash_password(password)?;
|
||||
let new_user = NewUser {
|
||||
id: Uuid::new_v4(),
|
||||
username: username.to_string(),
|
||||
password_hash,
|
||||
};
|
||||
|
||||
diesel::insert_into(users::table)
|
||||
@@ -233,34 +187,6 @@ fn create_user(pool: &PgPool, username: &str, password: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_password(pool: &PgPool, username: &str, password: &str) -> Result<()> {
|
||||
if password.is_empty() {
|
||||
bail!("password must not be empty");
|
||||
}
|
||||
|
||||
let mut conn = pool.get().context("failed to get database connection")?;
|
||||
let password_hash = hash_password(password)?;
|
||||
|
||||
let updated = diesel::update(users::table.filter(users::username.eq(username)))
|
||||
.set(users::password_hash.eq(password_hash))
|
||||
.execute(&mut conn)?;
|
||||
|
||||
if updated == 0 {
|
||||
bail!("user '{}' not found", username);
|
||||
}
|
||||
|
||||
println!("updated password for '{}'", username);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
fn list_users(pool: &PgPool) -> Result<()> {
|
||||
let mut conn = pool.get().context("failed to get database connection")?;
|
||||
|
||||
@@ -271,21 +197,22 @@ fn list_users(pool: &PgPool) -> Result<()> {
|
||||
}
|
||||
|
||||
for user in users_list {
|
||||
let memberships: Vec<(Uuid, String, String)> = user_memberships::table
|
||||
let memberships: Vec<String> = user_memberships::table
|
||||
.inner_join(tenants::table)
|
||||
.filter(user_memberships::user_id.eq(user.id))
|
||||
.select((tenants::id, tenants::slug, user_memberships::role))
|
||||
.order((tenants::slug.asc(), user_memberships::role.asc()))
|
||||
.select(tenants::name)
|
||||
.order(tenants::name.asc())
|
||||
.load(&mut conn)?;
|
||||
|
||||
if memberships.is_empty() {
|
||||
println!("{} ({})", user.username, user.id);
|
||||
} else {
|
||||
let details: Vec<String> = memberships
|
||||
.into_iter()
|
||||
.map(|(_, slug, role)| format!("{}: {}", slug, role))
|
||||
.collect();
|
||||
println!("{} ({}) -> {}", user.username, user.id, details.join(", "));
|
||||
println!(
|
||||
"{} ({}) -> {}",
|
||||
user.username,
|
||||
user.id,
|
||||
memberships.join(", ")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,77 +238,59 @@ fn delete_user(pool: &PgPool, username: &str) -> Result<()> {
|
||||
|
||||
fn create_tenant(
|
||||
pool: &PgPool,
|
||||
slug: &str,
|
||||
name: &str,
|
||||
storage_root_arg: Option<String>,
|
||||
quickwit_index_arg: Option<String>,
|
||||
) -> Result<()> {
|
||||
if slug.trim().is_empty() {
|
||||
bail!("tenant slug must not be empty");
|
||||
}
|
||||
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 mut conn = pool.get().context("failed to get database connection")?;
|
||||
let exists: bool =
|
||||
select(exists(tenants::table.filter(tenants::slug.eq(slug)))).get_result(&mut conn)?;
|
||||
if exists {
|
||||
bail!("tenant '{}' already exists", slug);
|
||||
}
|
||||
|
||||
let id = Uuid::new_v4();
|
||||
let storage_root = storage_root_arg
|
||||
.map(|mut s| {
|
||||
if s.is_empty() {
|
||||
format!("tenants/{}/", id)
|
||||
} else {
|
||||
if !s.ends_with('/') {
|
||||
s.push('/');
|
||||
}
|
||||
s
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| format!("tenants/{}/", id));
|
||||
let quickwit_index = quickwit_index_arg.unwrap_or_else(|| format!("documents-{}", id));
|
||||
|
||||
diesel::insert_into(tenants::table)
|
||||
.values((
|
||||
tenants::id.eq(id),
|
||||
tenants::slug.eq(slug),
|
||||
tenants::storage_root.eq(Some(storage_root.clone())),
|
||||
tenants::quickwit_index.eq(Some(quickwit_index.clone())),
|
||||
tenants::status.eq("active"),
|
||||
tenants::config.eq(serde_json::json!({})),
|
||||
))
|
||||
.execute(&mut conn)?;
|
||||
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 '{}'",
|
||||
slug, id, storage_root, quickwit_index
|
||||
"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, slug: &str) -> Result<()> {
|
||||
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
|
||||
.filter(tenants::slug.eq(slug))
|
||||
.find(tenant_id)
|
||||
.first(&mut conn)
|
||||
.optional()?
|
||||
.ok_or_else(|| anyhow!("tenant '{}' not found", slug))?;
|
||||
.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", slug);
|
||||
bail!("tenant '{}' still has user memberships", tenant.name);
|
||||
}
|
||||
|
||||
diesel::delete(tenants::table.filter(tenants::id.eq(tenant.id))).execute(&mut conn)?;
|
||||
println!("deleted tenant '{}'", slug);
|
||||
println!("deleted tenant '{}'", tenant.name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn add_user_to_tenant(pool: &PgPool, username: &str, slug: &str, role: Option<&str>) -> Result<()> {
|
||||
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
|
||||
@@ -391,33 +300,28 @@ fn add_user_to_tenant(pool: &PgPool, username: &str, slug: &str, role: Option<&s
|
||||
.ok_or_else(|| anyhow!("user '{}' not found", username))?;
|
||||
|
||||
let tenant: Tenant = tenants::table
|
||||
.filter(tenants::slug.eq(slug))
|
||||
.find(tenant_id)
|
||||
.first(&mut conn)
|
||||
.optional()?
|
||||
.ok_or_else(|| anyhow!("tenant '{}' not found", slug))?;
|
||||
.ok_or_else(|| anyhow!("tenant '{}' not found", tenant_id))?;
|
||||
|
||||
let membership = NewUserMembership {
|
||||
id: Uuid::new_v4(),
|
||||
user_id: user.id,
|
||||
tenant_id: tenant.id,
|
||||
role: role.unwrap_or("user").to_string(),
|
||||
};
|
||||
|
||||
diesel::insert_into(user_memberships::table)
|
||||
.values(&membership)
|
||||
.on_conflict((user_memberships::user_id, user_memberships::tenant_id))
|
||||
.do_update()
|
||||
.set(user_memberships::role.eq(&membership.role))
|
||||
.do_nothing()
|
||||
.execute(&mut conn)?;
|
||||
|
||||
println!(
|
||||
"added user '{}' to tenant '{}' with role '{}'",
|
||||
username, slug, membership.role
|
||||
);
|
||||
println!("added user '{}' to tenant '{}'", username, tenant.name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_user_from_tenant(pool: &PgPool, username: &str, slug: &str) -> Result<()> {
|
||||
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
|
||||
@@ -427,10 +331,10 @@ fn remove_user_from_tenant(pool: &PgPool, username: &str, slug: &str) -> Result<
|
||||
.ok_or_else(|| anyhow!("user '{}' not found", username))?;
|
||||
|
||||
let tenant: Tenant = tenants::table
|
||||
.filter(tenants::slug.eq(slug))
|
||||
.find(tenant_id)
|
||||
.first(&mut conn)
|
||||
.optional()?
|
||||
.ok_or_else(|| anyhow!("tenant '{}' not found", slug))?;
|
||||
.ok_or_else(|| anyhow!("tenant '{}' not found", tenant_id))?;
|
||||
|
||||
let removed = diesel::delete(
|
||||
user_memberships::table
|
||||
@@ -440,21 +344,24 @@ fn remove_user_from_tenant(pool: &PgPool, username: &str, slug: &str) -> Result<
|
||||
.execute(&mut conn)?;
|
||||
|
||||
if removed == 0 {
|
||||
println!("user '{}' was not a member of tenant '{}'", username, slug);
|
||||
println!(
|
||||
"user '{}' was not a member of tenant '{}'",
|
||||
username, tenant.name
|
||||
);
|
||||
} else {
|
||||
println!("removed user '{}' from tenant '{}'", username, slug);
|
||||
println!("removed user '{}' from tenant '{}'", username, tenant.name);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reanalyze_documents(pool: &PgPool, slug: &str) -> Result<()> {
|
||||
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
|
||||
.filter(tenants::slug.eq(slug))
|
||||
.find(tenant_id)
|
||||
.first(&mut conn)
|
||||
.optional()?
|
||||
.ok_or_else(|| anyhow!("tenant '{}' not found", slug))?;
|
||||
.ok_or_else(|| anyhow!("tenant '{}' not found", tenant_id))?;
|
||||
|
||||
let targets: Vec<(Uuid, Uuid)> = documents::table
|
||||
.filter(documents::tenant_id.eq(tenant.id))
|
||||
@@ -463,7 +370,7 @@ fn reanalyze_documents(pool: &PgPool, slug: &str) -> Result<()> {
|
||||
.load(&mut conn)?;
|
||||
|
||||
if targets.is_empty() {
|
||||
println!("tenant '{}' has no active documents", slug);
|
||||
println!("tenant '{}' has no active documents", tenant.name);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -486,7 +393,7 @@ fn reanalyze_documents(pool: &PgPool, slug: &str) -> Result<()> {
|
||||
|
||||
println!(
|
||||
"queued {} documents for re-analysis in tenant '{}'",
|
||||
queued, slug
|
||||
queued, tenant.name
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -494,7 +401,7 @@ fn reanalyze_documents(pool: &PgPool, slug: &str) -> Result<()> {
|
||||
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::slug.asc())
|
||||
.order(tenants::name.asc())
|
||||
.load(&mut conn)
|
||||
.context("failed to load tenants")?;
|
||||
|
||||
@@ -504,7 +411,7 @@ fn list_tenants(pool: &PgPool) -> Result<()> {
|
||||
}
|
||||
|
||||
for tenant in tenants {
|
||||
println!("{} ({})", tenant.slug, tenant.id);
|
||||
println!("{} {}", tenant.id, tenant.name);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -513,7 +420,7 @@ fn list_tenants(pool: &PgPool) -> Result<()> {
|
||||
async fn delete_assets_for_tenant(
|
||||
config: &AppConfig,
|
||||
pool: &PgPool,
|
||||
tenant_slug: &str,
|
||||
tenant_id: Uuid,
|
||||
) -> Result<()> {
|
||||
let s3_client = s3::build_client(config).await?;
|
||||
let storage: Arc<dyn ObjectStorage> =
|
||||
@@ -521,28 +428,27 @@ async fn delete_assets_for_tenant(
|
||||
|
||||
let mut conn = pool.get().context("failed to get database connection")?;
|
||||
let tenant: Tenant = tenants::table
|
||||
.filter(tenants::slug.eq(tenant_slug))
|
||||
.find(tenant_id)
|
||||
.first(&mut conn)
|
||||
.optional()
|
||||
.context("failed to load tenant")?
|
||||
.ok_or_else(|| anyhow!("tenant '{}' not found", tenant_slug))?;
|
||||
.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.slug))?;
|
||||
.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.slug))?;
|
||||
.with_context(|| format!("failed to load assets for tenant {}", tenant.name))?;
|
||||
|
||||
if assets.is_empty() {
|
||||
println!("Tenant {}: no assets", tenant.slug);
|
||||
println!("Tenant {}: no assets", tenant.name);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!(
|
||||
"Tenant {} ({}): deleting {} assets…",
|
||||
tenant.slug,
|
||||
tenant.name,
|
||||
tenant.id,
|
||||
assets.len()
|
||||
);
|
||||
@@ -553,13 +459,13 @@ async fn delete_assets_for_tenant(
|
||||
.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.slug))?;
|
||||
.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.slug
|
||||
object.s3_key, tenant.name
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -570,20 +476,20 @@ async fn delete_assets_for_tenant(
|
||||
.filter(document_asset_objects::asset_id.eq_any(&asset_ids)),
|
||||
)
|
||||
.execute(&mut conn)
|
||||
.with_context(|| format!("failed to remove asset objects for tenant {}", tenant.slug))?;
|
||||
.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.slug))?;
|
||||
.with_context(|| format!("failed to remove asset records for tenant {}", tenant.name))?;
|
||||
|
||||
println!("Tenant {}: asset records deleted.", tenant.slug);
|
||||
println!("Tenant {}: asset records deleted.", tenant.name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn quickwit_index(
|
||||
config: &AppConfig,
|
||||
pool: &PgPool,
|
||||
slug: &str,
|
||||
tenant_id: Uuid,
|
||||
method: Method,
|
||||
) -> Result<()> {
|
||||
let endpoint = config
|
||||
@@ -593,11 +499,10 @@ async fn quickwit_index(
|
||||
|
||||
let mut conn = pool.get().context("failed to get database connection")?;
|
||||
let tenant: Tenant = tenants::table
|
||||
.filter(tenants::slug.eq(slug))
|
||||
.find(tenant_id)
|
||||
.first(&mut conn)
|
||||
.optional()
|
||||
.context("failed to query tenants")?
|
||||
.ok_or_else(|| anyhow!("tenant '{}' not found", slug))?;
|
||||
.optional()?
|
||||
.ok_or_else(|| anyhow!("tenant '{}' not found", tenant_id))?;
|
||||
|
||||
let client = Client::new();
|
||||
let index_id = format!("documents-{}", tenant.id);
|
||||
@@ -605,63 +510,19 @@ async fn quickwit_index(
|
||||
|
||||
match method {
|
||||
Method::POST => {
|
||||
let payload = render_index_template(&index_id);
|
||||
let response = client
|
||||
.post(format!("{}/api/v1/indexes", base_endpoint))
|
||||
.header("content-type", "application/json")
|
||||
.body(payload)
|
||||
.send()
|
||||
ensure_quickwit_index(&client, base_endpoint, &index_id)
|
||||
.await
|
||||
.context("failed to send create index request")?;
|
||||
.context("failed to ensure quickwit index")?;
|
||||
|
||||
match response.status() {
|
||||
status if status.is_success() => {
|
||||
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")?;
|
||||
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.slug, index_id
|
||||
);
|
||||
}
|
||||
StatusCode::CONFLICT => {
|
||||
let lookup = client
|
||||
.get(format!("{}/api/v1/indexes/{}", base_endpoint, index_id))
|
||||
.send()
|
||||
.await
|
||||
.context("failed to verify existing quickwit index")?;
|
||||
|
||||
let lookup_status = lookup.status();
|
||||
if !lookup_status.is_success() {
|
||||
let body = lookup.text().await.unwrap_or_default();
|
||||
bail!(
|
||||
"quickwit reported conflict but index lookup failed with status {}: {}",
|
||||
lookup_status,
|
||||
body
|
||||
);
|
||||
}
|
||||
|
||||
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.slug, index_id
|
||||
);
|
||||
}
|
||||
status => {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
bail!(
|
||||
"quickwit create index failed with status {}: {}",
|
||||
status,
|
||||
body
|
||||
);
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"Tenant '{}' quickwit index set to '{}'.",
|
||||
tenant.name, index_id
|
||||
);
|
||||
}
|
||||
Method::DELETE => {
|
||||
let response = client
|
||||
@@ -677,7 +538,7 @@ async fn quickwit_index(
|
||||
.execute(&mut conn)
|
||||
.context("failed to clear tenant quickwit_index")?;
|
||||
|
||||
println!("Tenant '{}' quickwit index cleared.", tenant.slug);
|
||||
println!("Tenant '{}' quickwit index cleared.", tenant.name);
|
||||
}
|
||||
status => {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
@@ -694,14 +555,3 @@ async fn quickwit_index(
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_index_template(index_id: &str) -> String {
|
||||
let mut template = QUICKWIT_INDEX_TEMPLATE.clone();
|
||||
if let Some(obj) = template.as_object_mut() {
|
||||
obj.insert(
|
||||
"index_id".to_string(),
|
||||
serde_json::Value::String(index_id.to_string()),
|
||||
);
|
||||
}
|
||||
template.to_string()
|
||||
}
|
||||
|
||||
+132
-78
@@ -1,36 +1,69 @@
|
||||
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>,
|
||||
pub default_tenant_slug: 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 {
|
||||
@@ -42,87 +75,19 @@ impl AppConfig {
|
||||
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)
|
||||
}
|
||||
|
||||
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();
|
||||
let default_tenant_slug =
|
||||
env::var("DEFAULT_TENANT_SLUG").unwrap_or_else(|_| "admin".to_string());
|
||||
|
||||
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,
|
||||
default_tenant_slug,
|
||||
})
|
||||
let config: AppConfig = envy::from_env()
|
||||
.context("failed to parse application configuration from environment")?;
|
||||
Ok(config.normalize())
|
||||
}
|
||||
|
||||
pub fn redacted_database_url(&self) -> String {
|
||||
@@ -130,6 +95,95 @@ 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) => {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -72,7 +72,10 @@ 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 {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod auth;
|
||||
pub mod config;
|
||||
pub mod db;
|
||||
pub mod documents;
|
||||
pub mod error;
|
||||
pub mod jobs;
|
||||
pub mod models;
|
||||
|
||||
+161
-12
@@ -1,7 +1,16 @@
|
||||
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)]
|
||||
@@ -12,7 +21,6 @@ pub struct UserMembership {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub role: String,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
}
|
||||
@@ -23,7 +31,65 @@ pub struct NewUserMembership {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
#[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)]
|
||||
@@ -31,13 +97,14 @@ pub struct NewUserMembership {
|
||||
#[diesel(primary_key(id))]
|
||||
pub struct Tenant {
|
||||
pub id: Uuid,
|
||||
pub slug: String,
|
||||
pub name: String,
|
||||
pub storage_root: Option<String>,
|
||||
pub quickwit_index: Option<String>,
|
||||
pub status: String,
|
||||
pub config: serde_json::Value,
|
||||
pub config: Value,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
pub status: TenantStatus,
|
||||
pub created_by: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Queryable, Identifiable)]
|
||||
@@ -45,7 +112,6 @@ pub struct Tenant {
|
||||
pub struct User {
|
||||
pub id: Uuid,
|
||||
pub username: String,
|
||||
pub password_hash: String,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
}
|
||||
@@ -55,7 +121,94 @@ pub struct User {
|
||||
pub struct NewUser {
|
||||
pub id: Uuid,
|
||||
pub username: String,
|
||||
pub password_hash: 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)]
|
||||
@@ -123,7 +276,6 @@ 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,
|
||||
}
|
||||
@@ -137,7 +289,6 @@ 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,
|
||||
}
|
||||
@@ -283,11 +434,10 @@ pub struct NewCorrespondent {
|
||||
#[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,
|
||||
@@ -298,7 +448,6 @@ 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,
|
||||
}
|
||||
|
||||
+296
-43
@@ -8,17 +8,23 @@ use uuid::Uuid;
|
||||
paths(
|
||||
doc::health_check,
|
||||
doc::login,
|
||||
doc::signup_start,
|
||||
doc::signup_finish,
|
||||
doc::refresh,
|
||||
doc::logout,
|
||||
doc::me,
|
||||
doc::list_tenants,
|
||||
doc::select_tenant,
|
||||
doc::passkey_register_start,
|
||||
doc::passkey_register_finish,
|
||||
doc::passkey_login_start,
|
||||
doc::passkey_login_finish,
|
||||
doc::list_documents,
|
||||
doc::check_document,
|
||||
doc::upload_document,
|
||||
doc::get_document,
|
||||
doc::update_document,
|
||||
doc::delete_document,
|
||||
doc::download_document,
|
||||
doc::download_with_token,
|
||||
doc::move_document,
|
||||
doc::assign_tags,
|
||||
@@ -46,24 +52,39 @@ use uuid::Uuid;
|
||||
doc::create_correspondent,
|
||||
doc::update_correspondent,
|
||||
doc::delete_correspondent,
|
||||
doc::list_webdav_tokens,
|
||||
doc::create_webdav_token,
|
||||
doc::delete_webdav_token,
|
||||
doc::list_passkeys,
|
||||
doc::delete_passkey,
|
||||
),
|
||||
components(
|
||||
schemas(
|
||||
schemas::LoginRequest,
|
||||
schemas::SignupStartRequest,
|
||||
schemas::SignupStartResponse,
|
||||
schemas::SignupFinishRequest,
|
||||
schemas::AccessTokenResponse,
|
||||
schemas::TenantSummary,
|
||||
schemas::TenantSnippet,
|
||||
schemas::TenantSelectionResponse,
|
||||
schemas::TenantSelectionRequest,
|
||||
schemas::TenantListResponse,
|
||||
schemas::LoginResponseVariants,
|
||||
schemas::RegistrationChallengeResponse,
|
||||
schemas::AuthenticationChallengeResponse,
|
||||
schemas::PasskeySummary,
|
||||
schemas::PasskeyRegistrationFinishPayload,
|
||||
schemas::PasskeyLoginStartPayload,
|
||||
schemas::PasskeyLoginFinishPayload,
|
||||
schemas::DocumentResponse,
|
||||
schemas::DocumentDetailResponse,
|
||||
schemas::DocumentVersion,
|
||||
schemas::DocumentVersionResponse,
|
||||
schemas::DocumentVersionDetailResponse,
|
||||
schemas::DocumentAssetSummary,
|
||||
schemas::DocumentAssetDetail,
|
||||
schemas::DocumentAssetObject,
|
||||
schemas::DocumentCorrespondent,
|
||||
schemas::DocumentTag,
|
||||
schemas::DocumentDownloadResponse,
|
||||
schemas::UpdateDocumentRequest,
|
||||
schemas::BulkMoveDocumentsRequest,
|
||||
schemas::BulkMoveDocumentsResponse,
|
||||
@@ -77,7 +98,6 @@ use uuid::Uuid;
|
||||
schemas::BulkCorrespondentsResponse,
|
||||
schemas::BulkCorrespondentAction,
|
||||
schemas::AssignCorrespondentsRequest,
|
||||
schemas::RemoveCorrespondentParams,
|
||||
schemas::ReanalyzeRequest,
|
||||
schemas::ReanalyzeResponse,
|
||||
schemas::DocumentAssetRequestParams,
|
||||
@@ -96,9 +116,13 @@ use uuid::Uuid;
|
||||
schemas::TagCatalogEntry,
|
||||
schemas::CreateTagRequest,
|
||||
schemas::UpdateTagRequest,
|
||||
schemas::CorrespondentUsage,
|
||||
schemas::CorrespondentCatalogEntry,
|
||||
schemas::CreateCorrespondentRequest,
|
||||
schemas::UpdateCorrespondentRequest,
|
||||
schemas::WebdavTokenResponse,
|
||||
schemas::WebdavTokenCreatedResponse,
|
||||
schemas::CreateWebdavTokenRequest,
|
||||
)
|
||||
),
|
||||
tags(
|
||||
@@ -108,7 +132,8 @@ use uuid::Uuid;
|
||||
(name = "Assets", description = "Document assets"),
|
||||
(name = "Folders", description = "Folder management"),
|
||||
(name = "Tags", description = "Tag catalog"),
|
||||
(name = "Correspondents", description = "Correspondent catalog")
|
||||
(name = "Correspondents", description = "Correspondent catalog"),
|
||||
(name = "Profile", description = "User profile and WebDAV tokens")
|
||||
)
|
||||
)]
|
||||
pub struct ApiDoc;
|
||||
@@ -143,6 +168,32 @@ mod doc {
|
||||
)]
|
||||
pub(super) fn login() {}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/auth/signup/start",
|
||||
request_body = SignupStartRequest,
|
||||
responses(
|
||||
(status = 200, description = "Signup challenge created", body = SignupStartResponse),
|
||||
(status = 400, description = "Invalid signup request"),
|
||||
(status = 409, description = "Username already exists")
|
||||
),
|
||||
tag = "Auth"
|
||||
)]
|
||||
pub(super) fn signup_start() {}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/auth/signup/finish",
|
||||
request_body = SignupFinishRequest,
|
||||
responses(
|
||||
(status = 200, description = "Signup completed", body = LoginResponseVariants),
|
||||
(status = 400, description = "Invalid signup completion"),
|
||||
(status = 409, description = "Username already exists")
|
||||
),
|
||||
tag = "Auth"
|
||||
)]
|
||||
pub(super) fn signup_finish() {}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/auth/refresh",
|
||||
@@ -170,6 +221,14 @@ mod doc {
|
||||
)]
|
||||
pub(super) fn me() {}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/auth/tenants",
|
||||
responses((status = 200, description = "Available tenants", body = TenantListResponse)),
|
||||
tag = "Auth"
|
||||
)]
|
||||
pub(super) fn list_tenants() {}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/auth/select-tenant",
|
||||
@@ -179,6 +238,44 @@ mod doc {
|
||||
)]
|
||||
pub(super) fn select_tenant() {}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/auth/passkeys/register/start",
|
||||
responses((status = 200, description = "Passkey registration challenge", body = RegistrationChallengeResponse)),
|
||||
tag = "Auth"
|
||||
)]
|
||||
pub(super) fn passkey_register_start() {}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/auth/passkeys/register/finish",
|
||||
request_body = PasskeyRegistrationFinishPayload,
|
||||
responses((status = 200, description = "Passkey registered", body = PasskeySummary)),
|
||||
tag = "Auth"
|
||||
)]
|
||||
pub(super) fn passkey_register_finish() {}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/auth/passkeys/login/start",
|
||||
request_body = PasskeyLoginStartPayload,
|
||||
responses((status = 200, description = "Passkey authentication challenge", body = AuthenticationChallengeResponse)),
|
||||
tag = "Auth"
|
||||
)]
|
||||
pub(super) fn passkey_login_start() {}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/auth/passkeys/login/finish",
|
||||
request_body = PasskeyLoginFinishPayload,
|
||||
responses(
|
||||
(status = 200, description = "Passkey login successful", body = LoginResponseVariants),
|
||||
(status = 401, description = "Authentication failed")
|
||||
),
|
||||
tag = "Auth"
|
||||
)]
|
||||
pub(super) fn passkey_login_finish() {}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/documents",
|
||||
@@ -240,12 +337,34 @@ mod doc {
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/documents/{id}/download",
|
||||
path = "/api/documents/{id}/versions",
|
||||
params(("id" = Uuid, Path, description = "Document ID")),
|
||||
responses((status = 200, description = "Download metadata", body = DocumentDownloadResponse)),
|
||||
responses((status = 200, description = "Document versions", body = [DocumentVersionResponse])),
|
||||
tag = "Documents"
|
||||
)]
|
||||
pub(super) fn download_document() {}
|
||||
pub(super) fn list_document_versions() {}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/documents/{id}/versions/{version_id}",
|
||||
params(
|
||||
("id" = Uuid, Path, description = "Document ID"),
|
||||
("version_id" = Uuid, Path, description = "Version ID"),
|
||||
),
|
||||
responses((status = 200, description = "Document version detail", body = DocumentVersionDetailResponse)),
|
||||
tag = "Documents"
|
||||
)]
|
||||
pub(super) fn get_document_version() {}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/documents/{id}/restore",
|
||||
params(("id" = Uuid, Path, description = "Document ID")),
|
||||
request_body = RestoreDocumentRequest,
|
||||
responses((status = 204, description = "Document restored")),
|
||||
tag = "Documents"
|
||||
)]
|
||||
pub(super) fn restore_document() {}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
@@ -330,8 +449,7 @@ mod doc {
|
||||
path = "/api/documents/{id}/correspondents/{correspondent_id}",
|
||||
params(
|
||||
("id" = Uuid, Path, description = "Document ID"),
|
||||
("correspondent_id" = Uuid, Path, description = "Correspondent ID"),
|
||||
RemoveCorrespondentParams
|
||||
("correspondent_id" = Uuid, Path, description = "Correspondent ID")
|
||||
),
|
||||
responses((status = 204, description = "Correspondent removed")),
|
||||
tag = "Documents"
|
||||
@@ -384,7 +502,10 @@ mod doc {
|
||||
post,
|
||||
path = "/api/folders",
|
||||
request_body = CreateFolderRequest,
|
||||
responses((status = 200, description = "Folder created", body = FolderResponse)),
|
||||
responses(
|
||||
(status = 201, description = "Folder created", body = FolderResponse),
|
||||
(status = 200, description = "Folder already existed", body = FolderResponse)
|
||||
),
|
||||
tag = "Folders"
|
||||
)]
|
||||
pub(super) fn create_folder() {}
|
||||
@@ -509,6 +630,52 @@ mod doc {
|
||||
tag = "Correspondents"
|
||||
)]
|
||||
pub(super) fn delete_correspondent() {}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/profile/webdav-tokens",
|
||||
responses((status = 200, description = "List WebDAV tokens", body = [WebdavTokenResponse])),
|
||||
tag = "Profile"
|
||||
)]
|
||||
pub(super) fn list_webdav_tokens() {}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/profile/webdav-tokens",
|
||||
request_body = CreateWebdavTokenRequest,
|
||||
responses((status = 201, description = "WebDAV token created", body = WebdavTokenCreatedResponse)),
|
||||
tag = "Profile"
|
||||
)]
|
||||
pub(super) fn create_webdav_token() {}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/profile/webdav-tokens/{id}",
|
||||
params(("id" = Uuid, Path, description = "WebDAV token ID")),
|
||||
responses((status = 204, description = "WebDAV token revoked")),
|
||||
tag = "Profile"
|
||||
)]
|
||||
pub(super) fn delete_webdav_token() {}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/profile/passkeys",
|
||||
responses((status = 200, description = "List registered passkeys", body = [PasskeySummary])),
|
||||
tag = "Profile"
|
||||
)]
|
||||
pub(super) fn list_passkeys() {}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/profile/passkeys/{id}",
|
||||
params(
|
||||
("id" = Uuid, Path, description = "Passkey ID"),
|
||||
("reason" = Option<String>, Query, description = "Optional reason for revoking the passkey")
|
||||
),
|
||||
responses((status = 204, description = "Passkey revoked")),
|
||||
tag = "Profile"
|
||||
)]
|
||||
pub(super) fn delete_passkey() {}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -526,12 +693,37 @@ mod tests {
|
||||
pub mod schemas {
|
||||
use super::*;
|
||||
|
||||
pub use crate::auth::passkeys::{
|
||||
AuthenticationChallengeResponse, PasskeyLoginFinishPayload, PasskeyLoginStartPayload,
|
||||
PasskeyRegistrationFinishPayload, PasskeySummary, RegistrationChallengeResponse,
|
||||
};
|
||||
use webauthn_rs::prelude::RegisterPublicKeyCredential;
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct LoginRequest {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
#[schema(nullable)]
|
||||
pub preferred_tenant_slug: Option<String>,
|
||||
pub preferred_tenant_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct SignupStartRequest {
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct SignupStartResponse {
|
||||
pub signup_token: String,
|
||||
pub challenge: RegistrationChallengeResponse,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct SignupFinishRequest {
|
||||
pub signup_token: String,
|
||||
pub credential: RegisterPublicKeyCredential,
|
||||
#[schema(nullable)]
|
||||
pub nickname: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
@@ -539,18 +731,19 @@ pub mod schemas {
|
||||
pub access_token: String,
|
||||
pub token_type: String,
|
||||
pub expires_in: i64,
|
||||
pub tenant: TenantSnippet,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct TenantSummary {
|
||||
pub tenant_id: Uuid,
|
||||
pub slug: String,
|
||||
pub struct TenantSnippet {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct TenantSelectionResponse {
|
||||
pub selection_token: String,
|
||||
pub tenants: Vec<TenantSummary>,
|
||||
pub access_token: String,
|
||||
pub tenants: Vec<TenantSnippet>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
@@ -558,6 +751,11 @@ pub mod schemas {
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct TenantListResponse {
|
||||
pub tenants: Vec<TenantSnippet>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
#[serde(untagged)]
|
||||
pub enum LoginResponseVariants {
|
||||
@@ -569,11 +767,26 @@ pub mod schemas {
|
||||
#[into_params(parameter_in = Query)]
|
||||
pub struct DocumentListQuery {
|
||||
pub folder_id: Option<Uuid>,
|
||||
pub include_deleted: Option<bool>,
|
||||
#[schema(nullable)]
|
||||
pub include_descendants: Option<bool>,
|
||||
pub query: Option<String>,
|
||||
pub tags: Option<String>,
|
||||
pub correspondents: Option<String>,
|
||||
#[serde(default = "default_document_status_filter")]
|
||||
#[schema(default = "active")]
|
||||
pub status: DocumentStatusFilter,
|
||||
}
|
||||
|
||||
fn default_document_status_filter() -> DocumentStatusFilter {
|
||||
DocumentStatusFilter::Active
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum DocumentStatusFilter {
|
||||
Active,
|
||||
Deleted,
|
||||
All,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
@@ -618,17 +831,21 @@ pub mod schemas {
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct DocumentVersion {
|
||||
pub struct DocumentVersionResponse {
|
||||
pub id: Uuid,
|
||||
pub version_number: i32,
|
||||
pub checksum: String,
|
||||
pub size_bytes: i64,
|
||||
pub checksum: String,
|
||||
pub created_at: String,
|
||||
pub metadata: Value,
|
||||
#[schema(nullable)]
|
||||
pub operations_summary: Option<Value>,
|
||||
#[schema(nullable)]
|
||||
pub assets: Option<Vec<DocumentAssetSummary>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct DocumentVersionDetailResponse {
|
||||
#[serde(flatten)]
|
||||
pub version: DocumentVersionResponse,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub assets: Vec<DocumentAssetSummary>,
|
||||
pub download_path: String,
|
||||
}
|
||||
|
||||
@@ -636,7 +853,6 @@ pub mod schemas {
|
||||
pub struct DocumentCorrespondent {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub role: String,
|
||||
pub metadata: Value,
|
||||
pub assigned_at: String,
|
||||
}
|
||||
@@ -659,10 +875,10 @@ pub mod schemas {
|
||||
pub issued_at: Option<String>,
|
||||
pub metadata: Value,
|
||||
pub tags: Vec<DocumentTag>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub correspondents: Vec<DocumentCorrespondent>,
|
||||
#[schema(nullable)]
|
||||
pub correspondents: Option<Vec<DocumentCorrespondent>>,
|
||||
#[schema(nullable)]
|
||||
pub current_version: Option<DocumentVersion>,
|
||||
pub current_version: Option<DocumentVersionDetailResponse>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
@@ -671,19 +887,27 @@ pub mod schemas {
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct DocumentDownloadResponse {
|
||||
pub url: String,
|
||||
pub expires_in: u64,
|
||||
pub filename: String,
|
||||
pub struct DocumentMetadataUpdate {
|
||||
pub value: Value,
|
||||
#[serde(default)]
|
||||
#[schema(default = false)]
|
||||
pub replace: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct RestoreDocumentRequest {
|
||||
#[schema(nullable)]
|
||||
pub content_type: Option<String>,
|
||||
pub size_bytes: i64,
|
||||
pub folder_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct UpdateDocumentRequest {
|
||||
#[schema(nullable)]
|
||||
pub title: Option<String>,
|
||||
#[schema(nullable, value_type = Option<String>)]
|
||||
pub issued_at: Option<Value>,
|
||||
#[schema(nullable)]
|
||||
pub metadata: Option<DocumentMetadataUpdate>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
@@ -732,7 +956,6 @@ pub mod schemas {
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct CorrespondentAssignment {
|
||||
pub correspondent_id: Uuid,
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
@@ -767,12 +990,6 @@ pub mod schemas {
|
||||
pub removed: usize,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, IntoParams, ToSchema)]
|
||||
#[into_params(parameter_in = Query)]
|
||||
pub struct RemoveCorrespondentParams {
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct ReanalyzeRequest {
|
||||
pub document_ids: Vec<Uuid>,
|
||||
@@ -926,12 +1143,19 @@ pub mod schemas {
|
||||
pub color: Option<Option<String>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct CorrespondentUsage {
|
||||
pub total: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct CorrespondentCatalogEntry {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub metadata: Value,
|
||||
pub role_counts: Value,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub usage: CorrespondentUsage,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
@@ -948,4 +1172,33 @@ pub mod schemas {
|
||||
#[schema(nullable)]
|
||||
pub metadata: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct WebdavTokenResponse {
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
#[schema(nullable)]
|
||||
pub label: Option<String>,
|
||||
pub created_at: String,
|
||||
#[schema(nullable)]
|
||||
pub last_used_at: Option<String>,
|
||||
#[schema(nullable)]
|
||||
pub expires_at: Option<String>,
|
||||
#[schema(nullable)]
|
||||
pub revoked_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct WebdavTokenCreatedResponse {
|
||||
pub token: String,
|
||||
pub token_info: WebdavTokenResponse,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct CreateWebdavTokenRequest {
|
||||
#[schema(nullable)]
|
||||
pub label: Option<String>,
|
||||
#[schema(nullable, example = "2025-01-01T00:00:00Z")]
|
||||
pub expires_at: Option<String>,
|
||||
}
|
||||
}
|
||||
|
||||
+329
-70
@@ -16,17 +16,24 @@ 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, Tenant, User, UserMembership},
|
||||
models::{NewRefreshToken, NewUser, RefreshToken, Tenant, TenantStatus, User, UserMembership},
|
||||
schema::{
|
||||
refresh_tokens, tenants::dsl as tenant_dsl, user_memberships::dsl as memberships_dsl,
|
||||
users::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";
|
||||
|
||||
@@ -35,26 +42,32 @@ pub struct LoginRequest {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
#[serde(default)]
|
||||
pub preferred_tenant_slug: Option<String>,
|
||||
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,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct TenantSummary {
|
||||
pub tenant_id: Uuid,
|
||||
pub slug: String,
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct TenantSnippet {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct TenantSelectionResponse {
|
||||
pub selection_token: String,
|
||||
pub tenants: Vec<TenantSummary>,
|
||||
pub access_token: String,
|
||||
pub tenants: Vec<TenantSnippet>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct TenantListResponse {
|
||||
pub tenants: Vec<TenantSnippet>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -62,69 +75,120 @@ pub struct TenantSelectionRequest {
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
pub async fn login(
|
||||
#[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<Response> {
|
||||
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 mut conn = state.db_unscoped()?;
|
||||
|
||||
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());
|
||||
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 memberships: Vec<(UserMembership, Tenant)> = memberships_dsl::user_memberships
|
||||
.inner_join(tenant_dsl::tenants)
|
||||
.filter(memberships_dsl::user_id.eq(user.id))
|
||||
.load(&mut conn)?;
|
||||
|
||||
if memberships.is_empty() {
|
||||
return Err(AppError::unauthorized());
|
||||
}
|
||||
|
||||
let preferred_slug = payload
|
||||
.preferred_tenant_slug
|
||||
let user_id = Uuid::new_v4();
|
||||
let challenge = state
|
||||
.passkeys
|
||||
.as_ref()
|
||||
.map(|slug| slug.trim().to_string())
|
||||
.filter(|slug| !slug.is_empty());
|
||||
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?
|
||||
.start_signup_registration(&mut conn, user_id, username)?;
|
||||
|
||||
if let Some(tenant) = preferred_slug.as_ref().and_then(|slug| {
|
||||
memberships
|
||||
.iter()
|
||||
.find(|(_, tenant)| tenant.slug.eq_ignore_ascii_case(slug))
|
||||
}) {
|
||||
return issue_session(&state, &mut conn, &user, tenant.1.id);
|
||||
}
|
||||
|
||||
if memberships.len() == 1 {
|
||||
let tenant_id = memberships[0].1.id;
|
||||
return issue_session(&state, &mut conn, &user, tenant_id);
|
||||
}
|
||||
|
||||
let selection_token = state
|
||||
let signup_token = state
|
||||
.jwt
|
||||
.generate_tenant_selector_token(user.id)
|
||||
.generate_signup_token(user_id, challenge.challenge_id, username.to_owned())
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
let tenants = memberships
|
||||
.into_iter()
|
||||
.map(|(_, tenant)| TenantSummary {
|
||||
tenant_id: tenant.id,
|
||||
slug: tenant.slug,
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(SignupStartResponse {
|
||||
signup_token,
|
||||
challenge,
|
||||
}))
|
||||
}
|
||||
|
||||
let response = Json(TenantSelectionResponse {
|
||||
selection_token,
|
||||
tenants,
|
||||
})
|
||||
.into_response();
|
||||
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())?;
|
||||
|
||||
let mut conn = state.db_unscoped()?;
|
||||
|
||||
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"));
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -169,20 +233,37 @@ pub async fn refresh(
|
||||
issue_session(&state, &mut conn, &user, token.tenant_id)
|
||||
}
|
||||
|
||||
fn insert_user(conn: &mut PgConnection, id: Uuid, username: &str) -> AppResult<()> {
|
||||
let new_user = NewUser {
|
||||
id,
|
||||
username: username.to_string(),
|
||||
};
|
||||
|
||||
diesel::insert_into(dsl::users)
|
||||
.values(&new_user)
|
||||
.execute(conn)
|
||||
.map(|_| ())
|
||||
.map_err(AppError::from)
|
||||
}
|
||||
|
||||
pub async fn select_tenant(
|
||||
State(state): State<AppState>,
|
||||
TypedHeader(Authorization(bearer)): TypedHeader<Authorization<Bearer>>,
|
||||
Json(payload): Json<TenantSelectionRequest>,
|
||||
) -> AppResult<Response> {
|
||||
let claims = state
|
||||
.jwt
|
||||
.verify_tenant_selector_token(bearer.token())
|
||||
.map_err(|_| AppError::unauthorized())?;
|
||||
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())?,
|
||||
};
|
||||
|
||||
let mut conn = state.db_unscoped()?;
|
||||
|
||||
let membership_exists = memberships_dsl::user_memberships
|
||||
.filter(memberships_dsl::user_id.eq(claims.sub))
|
||||
.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)
|
||||
@@ -194,7 +275,7 @@ pub async fn select_tenant(
|
||||
}
|
||||
|
||||
let user: User = dsl::users
|
||||
.find(claims.sub)
|
||||
.find(user_id)
|
||||
.first(&mut conn)
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
@@ -250,6 +331,174 @@ 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,
|
||||
@@ -262,6 +511,12 @@ fn issue_session(
|
||||
.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);
|
||||
@@ -283,6 +538,10 @@ fn issue_session(
|
||||
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();
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use axum::{extract::Path, http::StatusCode, Json};
|
||||
use chrono::Utc;
|
||||
@@ -21,8 +21,6 @@ use crate::{
|
||||
#[derive(Serialize)]
|
||||
pub struct CorrespondentUsage {
|
||||
pub total: i64,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub by_role: BTreeMap<String, i64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -67,31 +65,21 @@ pub async fn list_correspondents(
|
||||
.order(correspondents::name.asc())
|
||||
.load(&mut conn)?;
|
||||
|
||||
let usage_rows: Vec<(Uuid, String, i64)> = document_correspondents::table
|
||||
let usage_rows: Vec<(Uuid, i64)> = document_correspondents::table
|
||||
.filter(document_correspondents::tenant_id.eq(tenant_id))
|
||||
.group_by((
|
||||
document_correspondents::correspondent_id,
|
||||
document_correspondents::role,
|
||||
))
|
||||
.select((
|
||||
document_correspondents::correspondent_id,
|
||||
document_correspondents::role,
|
||||
count_star(),
|
||||
))
|
||||
.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));
|
||||
}
|
||||
|
||||
response.into_json()
|
||||
@@ -136,7 +124,7 @@ pub async fn create_correspondent(
|
||||
.first(&mut conn)
|
||||
.one()?;
|
||||
|
||||
build_summary(correspondent, BTreeMap::new()).into_json()
|
||||
build_summary(correspondent, 0).into_json()
|
||||
}
|
||||
|
||||
pub async fn update_correspondent(
|
||||
@@ -245,21 +233,14 @@ pub async fn delete_correspondent(
|
||||
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 },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,17 +255,12 @@ 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))
|
||||
.filter(document_correspondents::tenant_id.eq(tenant_id))
|
||||
.group_by(document_correspondents::role)
|
||||
.select((document_correspondents::role, count_star()))
|
||||
.load(conn)?;
|
||||
.select(count_star())
|
||||
.get_result(conn)?;
|
||||
|
||||
let mut map = BTreeMap::new();
|
||||
for (role, count) in rows {
|
||||
map.insert(role, count);
|
||||
}
|
||||
Ok(map)
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
+448
-687
File diff suppressed because it is too large
Load Diff
@@ -1,120 +0,0 @@
|
||||
use std::path::Path as FsPath;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::models::{Document, DocumentAsset, DocumentAssetObject, DocumentVersion};
|
||||
use crate::state::AppState;
|
||||
use crate::utils::time::to_iso;
|
||||
|
||||
use super::{
|
||||
DocumentAssetDetailResponse, DocumentAssetObjectResponse, DocumentAssetResponse,
|
||||
DocumentVersionResponse,
|
||||
};
|
||||
|
||||
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| AppError::internal(format!("failed to generate download token: {err}")))
|
||||
}
|
||||
|
||||
pub fn to_version_response(
|
||||
version: DocumentVersion,
|
||||
include_operations_summary: bool,
|
||||
) -> DocumentVersionResponse {
|
||||
DocumentVersionResponse {
|
||||
id: version.id,
|
||||
version_number: version.version_number,
|
||||
s3_key: version.s3_key,
|
||||
size_bytes: version.size_bytes,
|
||||
checksum: version.checksum,
|
||||
created_at: to_iso(version.created_at),
|
||||
metadata: version.metadata,
|
||||
operations_summary: if include_operations_summary {
|
||||
Some(version.operations_summary)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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 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()
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{AppError, AppResult};
|
||||
|
||||
use super::CorrespondentAssignmentInput;
|
||||
|
||||
pub const CORRESPONDENT_ROLES: &[&str] = &["sender", "receiver", "other"];
|
||||
|
||||
pub fn normalize_role(value: &str) -> String {
|
||||
value.trim().to_lowercase()
|
||||
}
|
||||
|
||||
pub fn is_valid_correspondent_role(role: &str) -> bool {
|
||||
CORRESPONDENT_ROLES.iter().any(|allowed| *allowed == role)
|
||||
}
|
||||
|
||||
pub fn normalize_correspondent_assignments(
|
||||
assignments: &[CorrespondentAssignmentInput],
|
||||
) -> AppResult<(Vec<(Uuid, String)>, Vec<Uuid>, Vec<String>)> {
|
||||
let mut unique_pairs: HashSet<(Uuid, String)> = HashSet::new();
|
||||
let mut normalized_pairs: Vec<(Uuid, String)> = Vec::new();
|
||||
let mut role_set: HashSet<String> = HashSet::new();
|
||||
let mut correspondent_ids: HashSet<Uuid> = HashSet::new();
|
||||
|
||||
for assignment in assignments {
|
||||
let role = normalize_role(&assignment.role);
|
||||
if role.is_empty() {
|
||||
return Err(AppError::bad_request("role must not be empty"));
|
||||
}
|
||||
if !is_valid_correspondent_role(&role) {
|
||||
return Err(AppError::bad_request(format!(
|
||||
"invalid correspondent role '{role}'. Allowed roles: {}",
|
||||
CORRESPONDENT_ROLES.join(", ")
|
||||
)));
|
||||
}
|
||||
|
||||
if !unique_pairs.insert((assignment.correspondent_id, role.clone())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
normalized_pairs.push((assignment.correspondent_id, role.clone()));
|
||||
role_set.insert(role);
|
||||
correspondent_ids.insert(assignment.correspondent_id);
|
||||
}
|
||||
|
||||
if normalized_pairs.is_empty() {
|
||||
return Err(AppError::bad_request(
|
||||
"assignments must contain at least one unique correspondent/role pair",
|
||||
));
|
||||
}
|
||||
|
||||
let mut correspondents_vec: Vec<Uuid> = correspondent_ids.into_iter().collect();
|
||||
correspondents_vec.sort();
|
||||
|
||||
let mut roles_vec: Vec<String> = role_set.into_iter().collect();
|
||||
roles_vec.sort();
|
||||
|
||||
Ok((normalized_pairs, correspondents_vec, roles_vec))
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
use serde_json::Value;
|
||||
use uuid::Uuid;
|
||||
|
||||
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 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
|
||||
}
|
||||
+101
-23
@@ -15,9 +15,10 @@ use crate::{
|
||||
error::{AppError, AppResult},
|
||||
};
|
||||
|
||||
use super::documents::{
|
||||
load_correspondents_for_documents, load_primary_assets, load_tags_for_documents,
|
||||
to_document_response, 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},
|
||||
@@ -109,16 +110,16 @@ 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))
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.first(conn)
|
||||
.optional()?
|
||||
} else {
|
||||
folders::table
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.filter(folders::parent_id.is_null())
|
||||
.filter(folders::name.eq(name))
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.first(conn)
|
||||
.optional()?
|
||||
};
|
||||
@@ -133,18 +134,37 @@ pub async fn ensure_folder_path(
|
||||
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 {
|
||||
@@ -159,26 +179,84 @@ pub async fn create_folder(
|
||||
..
|
||||
}: 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 new_folder = NewFolder {
|
||||
id: Uuid::new_v4(),
|
||||
name: payload.name.trim().to_string(),
|
||||
parent_id: payload.parent_id,
|
||||
tenant_id,
|
||||
let name = payload.name.trim();
|
||||
|
||||
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(
|
||||
@@ -247,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, tenant_id, &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 {
|
||||
|
||||
@@ -7,7 +7,10 @@ use axum::{
|
||||
Router,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tower_http::cors::{AllowOrigin, CorsLayer};
|
||||
use tower_http::{
|
||||
cors::{AllowOrigin, CorsLayer},
|
||||
trace::{DefaultMakeSpan, DefaultOnFailure, DefaultOnResponse, TraceLayer},
|
||||
};
|
||||
use utoipa::OpenApi;
|
||||
|
||||
use crate::{auth::AuthenticatedUser, openapi::ApiDoc, state::AppState};
|
||||
@@ -17,6 +20,7 @@ pub mod correspondents;
|
||||
pub mod documents;
|
||||
pub mod folders;
|
||||
pub mod health;
|
||||
pub mod profile;
|
||||
pub mod tags;
|
||||
pub mod webdav;
|
||||
|
||||
@@ -50,10 +54,23 @@ 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()
|
||||
@@ -78,12 +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",
|
||||
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(
|
||||
@@ -121,6 +143,15 @@ 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));
|
||||
|
||||
@@ -129,6 +160,7 @@ pub fn create_router(state: AppState) -> Router<()> {
|
||||
.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));
|
||||
|
||||
@@ -144,6 +176,8 @@ pub fn create_router(state: AppState) -> Router<()> {
|
||||
}),
|
||||
);
|
||||
|
||||
let upload_limit = state.config.upload_body_limit_bytes;
|
||||
|
||||
Router::new()
|
||||
.merge(download_routes)
|
||||
.merge(protected_routes)
|
||||
@@ -152,5 +186,13 @@ pub fn create_router(state: AppState) -> Router<()> {
|
||||
.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())
|
||||
}
|
||||
@@ -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,7 +16,7 @@ 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::{
|
||||
@@ -24,22 +25,16 @@ use crate::schema::{
|
||||
user_memberships::dsl as memberships_dsl, users::dsl as users_dsl,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
use crate::utils::{http::inline_content_disposition, time::to_http_date};
|
||||
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 TenantEntry {
|
||||
tenant_id: Uuid,
|
||||
slug: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct WebDavContext {
|
||||
tenant_id: Uuid,
|
||||
_user_id: Uuid,
|
||||
_username: String,
|
||||
tenants: Vec<TenantEntry>,
|
||||
}
|
||||
|
||||
pub fn create_router() -> Router<AppState> {
|
||||
@@ -89,35 +84,20 @@ async fn handle_propfind(
|
||||
|
||||
let segments = parse_segments(path)?;
|
||||
|
||||
let resources = if segments.is_empty() {
|
||||
build_account_root_resources(&context.tenants, depth)
|
||||
} else {
|
||||
let (requested_slug, remainder) = segments.split_first().unwrap();
|
||||
let tenant_entry = match context
|
||||
.tenants
|
||||
.iter()
|
||||
.find(|entry| entry.slug.eq_ignore_ascii_case(requested_slug))
|
||||
{
|
||||
Some(entry) => TenantEntry {
|
||||
tenant_id: entry.tenant_id,
|
||||
slug: entry.slug.clone(),
|
||||
},
|
||||
None => return Ok(not_found_response()),
|
||||
};
|
||||
let tenant_id = context.tenant_id;
|
||||
|
||||
let resolution = match resolve_path(state, &tenant_entry, remainder)? {
|
||||
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()),
|
||||
};
|
||||
|
||||
match resolution {
|
||||
ResolvedPath::TenantRoot { chain } => {
|
||||
let contents = fetch_folder_contents(state, tenant_entry.tenant_id, None)?;
|
||||
build_resources_for_folder(None, &chain, &contents, depth)
|
||||
}
|
||||
ResolvedPath::Folder { folder, chain } => {
|
||||
let contents =
|
||||
fetch_folder_contents(state, tenant_entry.tenant_id, 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 {
|
||||
@@ -128,8 +108,10 @@ async fn handle_propfind(
|
||||
}
|
||||
};
|
||||
|
||||
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())
|
||||
@@ -151,29 +133,13 @@ async fn handle_get_or_head(
|
||||
None => return Ok(unauthorized_response()),
|
||||
};
|
||||
|
||||
let tenant_id = context.tenant_id;
|
||||
let segments = parse_segments(path)?;
|
||||
let (requested_slug, remainder) = match segments.split_first() {
|
||||
Some(values) => values,
|
||||
None => return Ok(method_not_allowed()),
|
||||
};
|
||||
|
||||
let tenant_entry = match context
|
||||
.tenants
|
||||
.iter()
|
||||
.find(|entry| entry.slug.eq_ignore_ascii_case(requested_slug))
|
||||
{
|
||||
Some(entry) => TenantEntry {
|
||||
tenant_id: entry.tenant_id,
|
||||
slug: entry.slug.clone(),
|
||||
},
|
||||
None => return Ok(not_found_response()),
|
||||
};
|
||||
|
||||
if remainder.is_empty() {
|
||||
if segments.is_empty() {
|
||||
return Ok(method_not_allowed());
|
||||
}
|
||||
|
||||
let resolution = match resolve_path(state, &tenant_entry, remainder)? {
|
||||
let resolution = match resolve_path(state, tenant_id, &segments)? {
|
||||
Some(resolved) => resolved,
|
||||
None => return Ok(not_found_response()),
|
||||
};
|
||||
@@ -356,7 +322,7 @@ async fn stream_document(
|
||||
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());
|
||||
@@ -365,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);
|
||||
@@ -404,9 +369,10 @@ async fn stream_document(
|
||||
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
|
||||
@@ -414,9 +380,10 @@ 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<WebDavContext>, AppError> {
|
||||
@@ -458,8 +425,8 @@ fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavCo
|
||||
}
|
||||
};
|
||||
|
||||
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),
|
||||
};
|
||||
|
||||
@@ -478,35 +445,46 @@ fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavCo
|
||||
Err(err) => return Err(AppError::from(err)),
|
||||
};
|
||||
|
||||
let valid = password::verify_password(password, &user.password_hash)
|
||||
.map_err(|_| AppError::internal("failed to verify password"))?;
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
if !valid {
|
||||
tracing::warn!(%username, "webdav password invalid");
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let tenant_rows: Vec<(Uuid, String)> = memberships_dsl::user_memberships
|
||||
let tenant_row = memberships_dsl::user_memberships
|
||||
.inner_join(tenant_dsl::tenants)
|
||||
.filter(memberships_dsl::user_id.eq(user.id))
|
||||
.select((tenant_dsl::id, tenant_dsl::slug))
|
||||
.load(&mut conn)?;
|
||||
.filter(memberships_dsl::tenant_id.eq(token.tenant_id))
|
||||
.select((tenant_dsl::id, tenant_dsl::name))
|
||||
.first::<(Uuid, String)>(&mut conn)
|
||||
.optional()?;
|
||||
|
||||
if tenant_rows.is_empty() {
|
||||
tracing::warn!(%username, "webdav user has no tenant memberships");
|
||||
return Ok(None);
|
||||
}
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
let tenants: Vec<TenantEntry> = tenant_rows
|
||||
.into_iter()
|
||||
.map(|(tenant_id, slug)| TenantEntry { tenant_id, slug })
|
||||
.collect();
|
||||
touch_webdav_token(&mut conn, token.id)?;
|
||||
|
||||
tracing::debug!(%username, tenant_count = tenants.len(), "webdav login success");
|
||||
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,
|
||||
tenants,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -564,37 +542,6 @@ fn build_resources_for_folder(
|
||||
resources
|
||||
}
|
||||
|
||||
fn build_account_root_resources(tenants: &[TenantEntry], depth: u8) -> Vec<DavResource> {
|
||||
let mut resources = Vec::new();
|
||||
|
||||
resources.push(DavResource {
|
||||
href: "/".to_string(),
|
||||
display_name: "/".to_string(),
|
||||
is_collection: true,
|
||||
content_length: None,
|
||||
content_type: None,
|
||||
last_modified: None,
|
||||
});
|
||||
|
||||
if depth == 0 {
|
||||
return resources;
|
||||
}
|
||||
|
||||
for tenant in tenants {
|
||||
let href = build_href(&[tenant.slug.clone()], true);
|
||||
resources.push(DavResource {
|
||||
href,
|
||||
display_name: tenant.slug.clone(),
|
||||
is_collection: true,
|
||||
content_length: None,
|
||||
content_type: None,
|
||||
last_modified: None,
|
||||
});
|
||||
}
|
||||
|
||||
resources
|
||||
}
|
||||
|
||||
fn build_resources_for_document(
|
||||
chain: &[String],
|
||||
document: &Document,
|
||||
@@ -717,9 +664,6 @@ struct DavResource {
|
||||
last_modified: Option<String>,
|
||||
}
|
||||
enum ResolvedPath {
|
||||
TenantRoot {
|
||||
chain: Vec<String>,
|
||||
},
|
||||
Folder {
|
||||
folder: Folder,
|
||||
chain: Vec<String>,
|
||||
@@ -733,23 +677,18 @@ enum ResolvedPath {
|
||||
|
||||
fn resolve_path(
|
||||
state: &AppState,
|
||||
tenant: &TenantEntry,
|
||||
tenant_id: Uuid,
|
||||
segments: &[String],
|
||||
) -> AppResult<Option<ResolvedPath>> {
|
||||
let mut conn = state.db_for_tenant(tenant.tenant_id)?;
|
||||
let mut conn = state.db_for_tenant(tenant_id)?;
|
||||
let mut parent_id: Option<Uuid> = None;
|
||||
let mut chain: Vec<String> = vec![tenant.slug.clone()];
|
||||
let mut chain: Vec<String> = Vec::new();
|
||||
let mut current_folder: Option<Folder> = None;
|
||||
|
||||
if segments.is_empty() {
|
||||
return Ok(Some(ResolvedPath::TenantRoot { chain }));
|
||||
}
|
||||
|
||||
for (index, segment) in segments.iter().enumerate() {
|
||||
let is_last = index == segments.len() - 1;
|
||||
|
||||
if let Some(folder) = find_folder_by_name(&mut conn, tenant.tenant_id, parent_id, segment)?
|
||||
{
|
||||
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 }));
|
||||
@@ -761,7 +700,7 @@ fn resolve_path(
|
||||
|
||||
if is_last {
|
||||
if let Some((document, version)) =
|
||||
find_document_by_filename(&mut conn, tenant.tenant_id, parent_id, segment)?
|
||||
find_document_by_filename(&mut conn, tenant_id, parent_id, segment)?
|
||||
{
|
||||
chain.push(document.filename.clone());
|
||||
return Ok(Some(ResolvedPath::Document {
|
||||
@@ -773,7 +712,7 @@ fn resolve_path(
|
||||
}
|
||||
|
||||
if let Ok(uuid) = Uuid::parse_str(segment) {
|
||||
if let Some(folder) = find_folder_by_id(&mut conn, tenant.tenant_id, uuid)? {
|
||||
if let Some(folder) = find_folder_by_id(&mut conn, tenant_id, uuid)? {
|
||||
if folder.parent_id != parent_id {
|
||||
return Ok(None);
|
||||
}
|
||||
@@ -786,9 +725,7 @@ fn resolve_path(
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some((document, version)) =
|
||||
find_document_by_id(&mut conn, tenant.tenant_id, uuid)?
|
||||
{
|
||||
if let Some((document, version)) = find_document_by_id(&mut conn, tenant_id, uuid)? {
|
||||
if document.folder_id != parent_id {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
+67
-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,
|
||||
@@ -37,11 +43,9 @@ diesel::table! {
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -69,7 +73,6 @@ diesel::table! {
|
||||
#[max_length = 64]
|
||||
checksum -> Varchar,
|
||||
created_at -> Timestamptz,
|
||||
operations_summary -> Jsonb,
|
||||
metadata -> Jsonb,
|
||||
tenant_id -> Uuid,
|
||||
}
|
||||
@@ -151,15 +154,19 @@ diesel::table! {
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
use diesel::sql_types::*;
|
||||
use super::sql_types::TenantStatus;
|
||||
|
||||
tenants (id) {
|
||||
id -> Uuid,
|
||||
slug -> Text,
|
||||
name -> Text,
|
||||
storage_root -> Nullable<Text>,
|
||||
quickwit_index -> Nullable<Text>,
|
||||
status -> Text,
|
||||
config -> Jsonb,
|
||||
created_at -> Timestamptz,
|
||||
updated_at -> Timestamptz,
|
||||
status -> TenantStatus,
|
||||
created_by -> Nullable<Uuid>,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,24 +175,68 @@ diesel::table! {
|
||||
id -> Uuid,
|
||||
user_id -> Uuid,
|
||||
tenant_id -> Uuid,
|
||||
role -> Text,
|
||||
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>,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
users (id) {
|
||||
id -> Uuid,
|
||||
#[max_length = 100]
|
||||
username -> Varchar,
|
||||
#[max_length = 255]
|
||||
password_hash -> 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));
|
||||
@@ -209,6 +260,10 @@ 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,
|
||||
@@ -224,5 +279,8 @@ diesel::allow_tables_to_appear_in_same_query!(
|
||||
tags,
|
||||
tenants,
|
||||
user_memberships,
|
||||
user_passkeys,
|
||||
users,
|
||||
webauthn_challenges,
|
||||
webdav_tokens,
|
||||
);
|
||||
|
||||
+19
-6
@@ -7,7 +7,7 @@ use diesel::{
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
auth::jwt::JwtService,
|
||||
auth::{jwt::JwtService, passkeys::PasskeyService},
|
||||
config::AppConfig,
|
||||
db::PgPool,
|
||||
error::{AppError, AppResult},
|
||||
@@ -24,6 +24,7 @@ pub struct AppState {
|
||||
storage: Arc<dyn ObjectStorage>,
|
||||
pub jwt: JwtService,
|
||||
pub tenants: TenantService,
|
||||
pub passkeys: Option<PasskeyService>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -52,12 +53,21 @@ impl AppState {
|
||||
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,
|
||||
storage,
|
||||
jwt,
|
||||
tenants,
|
||||
passkeys,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,14 +79,17 @@ impl AppState {
|
||||
}
|
||||
|
||||
pub(crate) fn db_unscoped(&self) -> AppResult<PgPooledConnection> {
|
||||
self.pool
|
||||
.get()
|
||||
.map_err(|err| AppError::internal(format!("database pool error: {err}")))
|
||||
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| AppError::internal(format!("tenant storage error: {err}")))
|
||||
TenantStorage::new(self.storage.clone(), &tenant).map_err(|err| {
|
||||
tracing::error!(error = ?err, "tenant storage error");
|
||||
AppError::internal("tenant storage error")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+93
-28
@@ -1,13 +1,13 @@
|
||||
use axum::{async_trait, extract::FromRequestParts, http::request::Parts};
|
||||
use diesel::{pg::PgConnection, prelude::*, sql_types::Text};
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
db::PgPool,
|
||||
error::{AppError, AppResult},
|
||||
models::Tenant,
|
||||
jobs::{enqueue_job, JOB_PROVISION_TENANT},
|
||||
models::{Tenant, TenantStatus},
|
||||
schema::tenants::dsl,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
pub struct TenantRepository;
|
||||
@@ -17,9 +17,9 @@ impl TenantRepository {
|
||||
dsl::tenants.find(tenant_id).first(conn).map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn get_by_slug(conn: &mut PgConnection, slug: &str) -> AppResult<Tenant> {
|
||||
pub fn get_by_name(conn: &mut PgConnection, name: &str) -> AppResult<Tenant> {
|
||||
dsl::tenants
|
||||
.filter(dsl::slug.eq(slug))
|
||||
.filter(dsl::name.eq(name))
|
||||
.first(conn)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
@@ -40,27 +40,91 @@ impl TenantService {
|
||||
Ok(tenant)
|
||||
}
|
||||
|
||||
pub fn get_by_slug(&self, slug: &str) -> AppResult<Tenant> {
|
||||
let slug_owned = slug.to_owned();
|
||||
let tenant = self.load(|conn| TenantRepository::get_by_slug(conn, &slug_owned))?;
|
||||
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 tenant_id_for_slug(&self, slug: &str) -> AppResult<Uuid> {
|
||||
Ok(self.get_by_slug(slug)?.id)
|
||||
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| AppError::internal(format!("database pool error: {err}")))?;
|
||||
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<()> {
|
||||
@@ -71,21 +135,22 @@ pub fn apply_tenant_guc(conn: &mut PgConnection, tenant_id: Uuid) -> AppResult<(
|
||||
.map_err(AppError::from)
|
||||
}
|
||||
|
||||
pub struct TenantContext {
|
||||
pub tenant: Tenant,
|
||||
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}/"),
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FromRequestParts<AppState> for TenantContext {
|
||||
type Rejection = AppError;
|
||||
|
||||
async fn from_request_parts(
|
||||
_parts: &mut Parts,
|
||||
state: &AppState,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
let tenant = state
|
||||
.tenants
|
||||
.get_by_slug(&state.config.default_tenant_slug)?;
|
||||
Ok(Self { tenant })
|
||||
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,32 @@
|
||||
use diesel::result::Error as DieselError;
|
||||
|
||||
use crate::error::{AppError, AppResult};
|
||||
|
||||
pub trait DbResultExt<T> {
|
||||
fn db_context(self, context: &'static str) -> AppResult<T>;
|
||||
}
|
||||
|
||||
impl<T> DbResultExt<T> for Result<T, DieselError> {
|
||||
fn db_context(self, context: &'static str) -> AppResult<T> {
|
||||
self.map_err(|err| match err {
|
||||
DieselError::NotFound => AppError::not_found(),
|
||||
other => {
|
||||
tracing::error!(error = ?other, "{context}");
|
||||
AppError::internal(context)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub trait StorageResultExt<T> {
|
||||
fn storage_context(self, context: &'static str) -> AppResult<T>;
|
||||
}
|
||||
|
||||
impl<T> StorageResultExt<T> for Result<T, anyhow::Error> {
|
||||
fn storage_context(self, context: &'static str) -> AppResult<T> {
|
||||
self.map_err(|err| {
|
||||
tracing::error!(error = ?err, "{context}");
|
||||
AppError::internal(context)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod bootstrap;
|
||||
pub mod db;
|
||||
pub mod error;
|
||||
pub mod http;
|
||||
pub mod json;
|
||||
pub mod storage_paths;
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::{collections::HashSet, sync::Arc, time::Duration};
|
||||
use async_trait::async_trait;
|
||||
use diesel::prelude::*;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Map, Value};
|
||||
use serde_json::json;
|
||||
use tokio::task;
|
||||
use tracing::{error, warn};
|
||||
use uuid::Uuid;
|
||||
@@ -104,7 +104,7 @@ fn analyze_document(
|
||||
|
||||
let tenant_id = document.tenant_id;
|
||||
|
||||
let (supported, reason) = determine_thumbnail_support(&document);
|
||||
let (supported, _reason) = determine_thumbnail_support(&document);
|
||||
let ocr_supported = document_is_pdf(&document);
|
||||
|
||||
let existing_ocr: Option<DocumentAsset> = document_assets::table
|
||||
@@ -117,32 +117,6 @@ fn analyze_document(
|
||||
|
||||
let skip_ocr = existing_ocr.is_some() && !payload.force;
|
||||
|
||||
let mut summary_map = match version.operations_summary {
|
||||
Value::Object(map) => map,
|
||||
_ => Map::new(),
|
||||
};
|
||||
summary_map.insert("thumbnail_supported".to_string(), Value::Bool(supported));
|
||||
if let Some(reason) = reason {
|
||||
summary_map.insert("thumbnail_reason".to_string(), Value::String(reason));
|
||||
} else {
|
||||
summary_map.remove("thumbnail_reason");
|
||||
}
|
||||
|
||||
summary_map.insert("ocr_supported".to_string(), Value::Bool(ocr_supported));
|
||||
if ocr_supported {
|
||||
summary_map.remove("ocr_reason");
|
||||
} else {
|
||||
summary_map.insert(
|
||||
"ocr_reason".to_string(),
|
||||
Value::String("document is not a PDF".into()),
|
||||
);
|
||||
}
|
||||
|
||||
diesel::update(document_versions::table.find(version.id))
|
||||
.set(document_versions::operations_summary.eq(Value::Object(summary_map)))
|
||||
.execute(&mut conn)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
|
||||
if supported {
|
||||
let enqueue_result = enqueue_job(
|
||||
&mut conn,
|
||||
|
||||
@@ -5,12 +5,12 @@ use async_trait::async_trait;
|
||||
use diesel::prelude::*;
|
||||
use reqwest::Client;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use tokio::task;
|
||||
use tracing::{error, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
documents::search::{build_quickwit_ingest_record, quickwit_ingest},
|
||||
jobs::JOB_INDEX_DOCUMENT_TEXT,
|
||||
models::{Document, DocumentVersion},
|
||||
schema::{document_asset_objects, document_assets, document_versions, documents},
|
||||
@@ -18,7 +18,9 @@ use crate::{
|
||||
storage::TenantStorage,
|
||||
};
|
||||
|
||||
use super::{ocr::OCR_TEXT_ASSET_TYPE, JobExecution, JobHandler};
|
||||
use super::{
|
||||
fetch_version_object, handle_fetch_error, ocr::OCR_TEXT_ASSET_TYPE, JobExecution, JobHandler,
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct IndexPayload {
|
||||
@@ -114,21 +116,23 @@ impl JobHandler for IndexDocumentTextJob {
|
||||
}
|
||||
|
||||
let s3_key = context.text_s3_key.unwrap();
|
||||
let text = match storage.get_object(&s3_key).await {
|
||||
Ok(bytes) => match String::from_utf8(bytes) {
|
||||
Ok(text) => text,
|
||||
Err(err) => {
|
||||
warn!(job_id = %job.id, error = %err, "ocr text not valid UTF-8");
|
||||
return JobExecution::Failed {
|
||||
error: "ocr text not valid UTF-8".into(),
|
||||
};
|
||||
}
|
||||
},
|
||||
let bytes = match fetch_version_object(
|
||||
&context.version,
|
||||
&storage,
|
||||
&s3_key,
|
||||
state.config.worker_max_document_bytes,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(bytes) => bytes,
|
||||
Err(err) => return handle_fetch_error(&job, err, "failed to download ocr text"),
|
||||
};
|
||||
let text = match String::from_utf8(bytes) {
|
||||
Ok(text) => text,
|
||||
Err(err) => {
|
||||
warn!(job_id = %job.id, error = %err, "failed to download ocr text");
|
||||
return JobExecution::Retry {
|
||||
delay: Duration::from_secs(30),
|
||||
error: err.to_string(),
|
||||
warn!(job_id = %job.id, error = %err, "ocr text not valid UTF-8");
|
||||
return JobExecution::Failed {
|
||||
error: "ocr text not valid UTF-8".into(),
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -140,43 +144,13 @@ impl JobHandler for IndexDocumentTextJob {
|
||||
};
|
||||
}
|
||||
|
||||
let client = client;
|
||||
let url = format!(
|
||||
"{}/api/v1/{}/ingest?commit=auto",
|
||||
quickwit_endpoint, quickwit_index
|
||||
);
|
||||
let payload = json!({
|
||||
"document_id": context.document.id,
|
||||
"version_id": context.version.id,
|
||||
"tenant_id": job.tenant_id,
|
||||
"title": context.document.title.to_lowercase(),
|
||||
"text": text.to_lowercase()
|
||||
});
|
||||
let record =
|
||||
build_quickwit_ingest_record(&context.document, &context.version, job.tenant_id, &text);
|
||||
|
||||
let body = serde_json::to_string(&payload).unwrap();
|
||||
|
||||
match client
|
||||
.post(&url)
|
||||
.header("content-type", "application/x-ndjson")
|
||||
.body(format!("{}\n", body))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
if response.status().is_success() {
|
||||
JobExecution::Success
|
||||
} else {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
warn!(job_id = %job.id, %status, %body, "quickwit ingest failed");
|
||||
JobExecution::Retry {
|
||||
delay: Duration::from_secs(30),
|
||||
error: format!("quickwit ingest failed with status {status}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
match quickwit_ingest(&client, &quickwit_endpoint, &quickwit_index, &[record]).await {
|
||||
Ok(()) => JobExecution::Success,
|
||||
Err(err) => {
|
||||
warn!(job_id = %job.id, error = %err, "quickwit request failed");
|
||||
warn!(job_id = %job.id, error = %err, "quickwit ingest failed");
|
||||
JobExecution::Retry {
|
||||
delay: Duration::from_secs(30),
|
||||
error: err.to_string(),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::{collections::HashMap, sync::Arc, time::Duration};
|
||||
|
||||
use anyhow::Error as AnyhowError;
|
||||
use async_trait::async_trait;
|
||||
use tokio::time::sleep;
|
||||
use tracing::{error, info, warn};
|
||||
@@ -14,8 +15,11 @@ use crate::{
|
||||
pub mod analyze;
|
||||
pub mod index;
|
||||
pub mod ocr;
|
||||
pub mod tenants;
|
||||
pub mod thumbnails;
|
||||
|
||||
use tenants::ProvisionTenantJob;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum JobExecution {
|
||||
Success,
|
||||
@@ -145,5 +149,70 @@ pub fn default_handlers() -> Vec<Arc<dyn JobHandler>> {
|
||||
Arc::new(thumbnails::GenerateThumbnailsJob::new()),
|
||||
Arc::new(ocr::GenerateOcrTextJob::new()),
|
||||
Arc::new(index::IndexDocumentTextJob::new()),
|
||||
Arc::new(ProvisionTenantJob::new()),
|
||||
]
|
||||
}
|
||||
|
||||
pub(crate) fn check_worker_document_limit(
|
||||
size_bytes: i64,
|
||||
limit_bytes: u64,
|
||||
) -> Result<(), (u64, u64)> {
|
||||
let size = size_bytes.max(0) as u64;
|
||||
if size > limit_bytes {
|
||||
Err((size, limit_bytes))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) enum FetchVersionError {
|
||||
TooLarge { size: u64, limit: u64 },
|
||||
Storage(AnyhowError),
|
||||
}
|
||||
|
||||
pub(crate) async fn fetch_version_object(
|
||||
version: &crate::models::DocumentVersion,
|
||||
storage: &TenantStorage,
|
||||
s3_key: &str,
|
||||
limit_bytes: u64,
|
||||
) -> Result<Vec<u8>, FetchVersionError> {
|
||||
check_worker_document_limit(version.size_bytes, limit_bytes)
|
||||
.map_err(|(size, limit)| FetchVersionError::TooLarge { size, limit })?;
|
||||
|
||||
storage
|
||||
.get_object(s3_key)
|
||||
.await
|
||||
.map_err(FetchVersionError::Storage)
|
||||
}
|
||||
|
||||
pub(crate) fn handle_fetch_error(
|
||||
job: &crate::models::Job,
|
||||
err: FetchVersionError,
|
||||
message: &str,
|
||||
) -> JobExecution {
|
||||
match err {
|
||||
FetchVersionError::TooLarge { size, limit } => {
|
||||
warn!(
|
||||
job_id = %job.id,
|
||||
size_bytes = size,
|
||||
limit_bytes = limit,
|
||||
"document exceeds worker size limit"
|
||||
);
|
||||
JobExecution::Failed {
|
||||
error: format!("document size {size} bytes exceeds worker limit of {limit} bytes"),
|
||||
}
|
||||
}
|
||||
FetchVersionError::Storage(err) => {
|
||||
warn!(
|
||||
job_id = %job.id,
|
||||
error = %err,
|
||||
context = message,
|
||||
"failed to fetch object for worker"
|
||||
);
|
||||
JobExecution::Retry {
|
||||
delay: Duration::from_secs(30),
|
||||
error: err.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+30
-11
@@ -18,6 +18,7 @@ use tracing::{error, info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
documents::asset::delete_asset,
|
||||
jobs::{enqueue_job, JOB_GENERATE_OCR_TEXT, JOB_INDEX_DOCUMENT_TEXT},
|
||||
models::{
|
||||
Document, DocumentAsset, DocumentAssetObject, DocumentVersion, NewDocumentAsset,
|
||||
@@ -29,7 +30,7 @@ use crate::{
|
||||
utils::storage_paths::document_asset_object_prefix,
|
||||
};
|
||||
|
||||
use super::{JobExecution, JobHandler};
|
||||
use super::{fetch_version_object, handle_fetch_error, JobExecution, JobHandler};
|
||||
|
||||
pub const OCR_TEXT_ASSET_TYPE: &str = "ocr-text";
|
||||
const MIN_TEXT_LENGTH: usize = 50;
|
||||
@@ -98,15 +99,16 @@ impl JobHandler for GenerateOcrTextJob {
|
||||
return JobExecution::Success;
|
||||
}
|
||||
|
||||
let bytes = match storage.get_object(&context.version.s3_key).await {
|
||||
let bytes = match fetch_version_object(
|
||||
&context.version,
|
||||
&storage,
|
||||
&context.version.s3_key,
|
||||
state.config.worker_max_document_bytes,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(bytes) => bytes,
|
||||
Err(err) => {
|
||||
warn!(job_id = %job.id, error = %err, "failed to fetch document for ocr");
|
||||
return JobExecution::Retry {
|
||||
delay: Duration::from_secs(30),
|
||||
error: err.to_string(),
|
||||
};
|
||||
}
|
||||
Err(err) => return handle_fetch_error(&job, err, "failed to fetch document for ocr"),
|
||||
};
|
||||
|
||||
let doc_meta = PdfDocumentMeta {
|
||||
@@ -133,12 +135,29 @@ impl JobHandler for GenerateOcrTextJob {
|
||||
};
|
||||
};
|
||||
|
||||
if context.existing_asset.is_some() {
|
||||
if let Some(existing_asset) = &context.existing_asset {
|
||||
for object in &context.existing_objects {
|
||||
if let Err(err) = storage.delete_object(&object.s3_key).await {
|
||||
warn!(job_id = %job.id, error = %err, s3_key = %object.s3_key, "failed to delete existing ocr asset object");
|
||||
}
|
||||
}
|
||||
|
||||
let tenant_id = context.document.tenant_id;
|
||||
let asset_id = existing_asset.id;
|
||||
let state_clone = state.clone();
|
||||
match task::spawn_blocking(move || {
|
||||
delete_asset(state_clone.as_ref(), tenant_id, asset_id)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(err)) => {
|
||||
warn!(job_id = %job.id, error = ?err, asset_id = %asset_id, "failed to remove ocr asset metadata after deletion");
|
||||
}
|
||||
Err(join_err) => {
|
||||
warn!(job_id = %job.id, error = %join_err, asset_id = %asset_id, "failed to remove ocr asset metadata: task panicked");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let asset_id = Uuid::new_v4();
|
||||
@@ -154,7 +173,7 @@ impl JobHandler for GenerateOcrTextJob {
|
||||
.put_object(
|
||||
&s3_key,
|
||||
generation.text.into_bytes(),
|
||||
Some("text/plain".into()),
|
||||
Some("text/plain; charset=utf-8".into()),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use diesel::prelude::*;
|
||||
use reqwest::Client;
|
||||
use serde::Deserialize;
|
||||
use tracing::warn;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::documents::search::ensure_quickwit_index;
|
||||
use crate::jobs::JOB_PROVISION_TENANT;
|
||||
use crate::models::{NewUserMembership, TenantStatus};
|
||||
use crate::schema::{tenants, user_memberships};
|
||||
use crate::state::AppState;
|
||||
use crate::tenants::TenantRepository;
|
||||
use crate::workers::{JobExecution, JobHandler};
|
||||
|
||||
pub struct ProvisionTenantJob;
|
||||
|
||||
impl ProvisionTenantJob {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl JobHandler for ProvisionTenantJob {
|
||||
fn job_type(&self) -> &'static str {
|
||||
JOB_PROVISION_TENANT
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
&self,
|
||||
state: Arc<AppState>,
|
||||
job: crate::models::Job,
|
||||
_storage: crate::storage::TenantStorage,
|
||||
) -> JobExecution {
|
||||
let mut conn = match state.db_unscoped() {
|
||||
Ok(conn) => conn,
|
||||
Err(err) => {
|
||||
warn!(job_id = %job.id, error = ?err, "failed to get connection for tenant provisioning");
|
||||
return JobExecution::Retry {
|
||||
delay: std::time::Duration::from_secs(30),
|
||||
error: "database connection unavailable".into(),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let tenant = match TenantRepository::get_by_id(&mut conn, job.tenant_id) {
|
||||
Ok(tenant) => tenant,
|
||||
Err(err) => {
|
||||
warn!(job_id = %job.id, error = ?err, "tenant not found for provisioning");
|
||||
return JobExecution::Failed {
|
||||
error: "tenant not found".into(),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
if tenant.status == TenantStatus::Active {
|
||||
warn!(
|
||||
job_id = %job.id,
|
||||
tenant_id = %tenant.id,
|
||||
"tenant already active; skipping provisioning"
|
||||
);
|
||||
return JobExecution::Success;
|
||||
}
|
||||
|
||||
if tenant.status != TenantStatus::Creating {
|
||||
warn!(
|
||||
job_id = %job.id,
|
||||
tenant_id = %tenant.id,
|
||||
status = %tenant.status.as_str(),
|
||||
"tenant not in creating state; skipping provisioning"
|
||||
);
|
||||
return JobExecution::Failed {
|
||||
error: format!(
|
||||
"tenant status '{}' not eligible for provisioning",
|
||||
tenant.status.as_str()
|
||||
),
|
||||
};
|
||||
}
|
||||
let endpoint = match &state.config.quickwit_endpoint {
|
||||
Some(endpoint) => endpoint.trim_end_matches('/').to_owned(),
|
||||
None => {
|
||||
warn!(
|
||||
job_id = %job.id,
|
||||
tenant_id = %tenant.id,
|
||||
"quickwit endpoint not configured; retrying"
|
||||
);
|
||||
return JobExecution::Retry {
|
||||
delay: std::time::Duration::from_secs(30),
|
||||
error: "quickwit endpoint not configured".into(),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let index_id = tenant
|
||||
.quickwit_index
|
||||
.as_deref()
|
||||
.map(str::to_owned)
|
||||
.unwrap_or_else(|| format!("documents-{}", tenant.id));
|
||||
|
||||
let client = Client::new();
|
||||
if let Err(err) = ensure_quickwit_index(&client, &endpoint, &index_id).await {
|
||||
warn!(
|
||||
job_id = %job.id,
|
||||
tenant_id = %tenant.id,
|
||||
error = %err,
|
||||
"failed to ensure quickwit index"
|
||||
);
|
||||
return JobExecution::Retry {
|
||||
delay: std::time::Duration::from_secs(30),
|
||||
error: err.to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
if let Some(members) = ProvisionPayload::from_job(&job) {
|
||||
for member in members {
|
||||
let new_membership = NewUserMembership {
|
||||
id: Uuid::new_v4(),
|
||||
user_id: member,
|
||||
tenant_id: tenant.id,
|
||||
};
|
||||
|
||||
if let Err(err) = diesel::insert_into(user_memberships::table)
|
||||
.values(&new_membership)
|
||||
.on_conflict((user_memberships::user_id, user_memberships::tenant_id))
|
||||
.do_nothing()
|
||||
.execute(&mut conn)
|
||||
{
|
||||
warn!(
|
||||
job_id = %job.id,
|
||||
tenant_id = %tenant.id,
|
||||
user_id = %member,
|
||||
error = %err,
|
||||
"failed to assign initial membership"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(err) = diesel::update(tenants::table.find(tenant.id))
|
||||
.set((
|
||||
tenants::status.eq(TenantStatus::Active),
|
||||
tenants::quickwit_index.eq(Some(index_id)),
|
||||
tenants::updated_at.eq(Utc::now().naive_utc()),
|
||||
))
|
||||
.execute(&mut conn)
|
||||
{
|
||||
warn!(job_id = %job.id, error = %err, "failed to activate tenant");
|
||||
return JobExecution::Retry {
|
||||
delay: std::time::Duration::from_secs(30),
|
||||
error: format!("failed to update tenant status: {err}"),
|
||||
};
|
||||
}
|
||||
|
||||
JobExecution::Success
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct ProvisionPayload {
|
||||
#[serde(default)]
|
||||
members: Vec<Uuid>,
|
||||
}
|
||||
|
||||
impl ProvisionPayload {
|
||||
fn from_job(job: &crate::models::Job) -> Option<Vec<Uuid>> {
|
||||
serde_json::from_value(job.payload.clone())
|
||||
.map(|payload: ProvisionPayload| payload.members)
|
||||
.ok()
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ use tracing::{error, info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
documents::asset::delete_asset,
|
||||
jobs::JOB_GENERATE_THUMBNAILS,
|
||||
models::{
|
||||
Document, DocumentAsset, DocumentAssetObject, DocumentVersion, NewDocumentAsset,
|
||||
@@ -23,7 +24,10 @@ use crate::{
|
||||
utils::storage_paths::document_asset_object_key,
|
||||
};
|
||||
|
||||
use super::{analyze::determine_thumbnail_support, JobExecution, JobHandler};
|
||||
use super::{
|
||||
analyze::determine_thumbnail_support, fetch_version_object, handle_fetch_error, JobExecution,
|
||||
JobHandler,
|
||||
};
|
||||
|
||||
const THUMBNAIL_WIDTH: u32 = 512;
|
||||
const THUMBNAIL_HEIGHT: u32 = 512;
|
||||
@@ -95,15 +99,16 @@ impl JobHandler for GenerateThumbnailsJob {
|
||||
return JobExecution::Success;
|
||||
}
|
||||
|
||||
let bytes = match storage.get_object(&initial.version.s3_key).await {
|
||||
let bytes = match fetch_version_object(
|
||||
&initial.version,
|
||||
&storage,
|
||||
&initial.version.s3_key,
|
||||
state.config.worker_max_document_bytes,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(bytes) => bytes,
|
||||
Err(err) => {
|
||||
warn!(job_id = %job.id, error = %err, "thumbnail fetch failed; will retry");
|
||||
return JobExecution::Retry {
|
||||
delay: Duration::from_secs(30),
|
||||
error: err.to_string(),
|
||||
};
|
||||
}
|
||||
Err(err) => return handle_fetch_error(&job, err, "thumbnail fetch failed; will retry"),
|
||||
};
|
||||
|
||||
let generation = match generate_preview_and_thumbnail(&initial.document, &bytes) {
|
||||
@@ -152,7 +157,7 @@ impl JobHandler for GenerateThumbnailsJob {
|
||||
}
|
||||
}
|
||||
|
||||
if initial.existing_preview.is_some() {
|
||||
if let Some(existing_preview) = &initial.existing_preview {
|
||||
for object in &initial.existing_preview_objects {
|
||||
if let Err(err) = storage.delete_object(&object.s3_key).await {
|
||||
warn!(
|
||||
@@ -163,9 +168,36 @@ impl JobHandler for GenerateThumbnailsJob {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let tenant_id = initial.document.tenant_id;
|
||||
let asset_id = existing_preview.id;
|
||||
let state_clone = state.clone();
|
||||
match task::spawn_blocking(move || {
|
||||
delete_asset(state_clone.as_ref(), tenant_id, asset_id)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(err)) => {
|
||||
warn!(
|
||||
job_id = %job.id,
|
||||
error = ?err,
|
||||
asset_id = %asset_id,
|
||||
"failed to remove preview metadata after deletion"
|
||||
);
|
||||
}
|
||||
Err(join_err) => {
|
||||
warn!(
|
||||
job_id = %job.id,
|
||||
error = %join_err,
|
||||
asset_id = %asset_id,
|
||||
"failed to remove preview metadata: task panicked"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if initial.existing_thumbnail.is_some() {
|
||||
if let Some(existing_thumbnail) = &initial.existing_thumbnail {
|
||||
for object in &initial.existing_thumbnail_objects {
|
||||
if let Err(err) = storage.delete_object(&object.s3_key).await {
|
||||
warn!(
|
||||
@@ -176,6 +208,33 @@ impl JobHandler for GenerateThumbnailsJob {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let tenant_id = initial.document.tenant_id;
|
||||
let asset_id = existing_thumbnail.id;
|
||||
let state_clone = state.clone();
|
||||
match task::spawn_blocking(move || {
|
||||
delete_asset(state_clone.as_ref(), tenant_id, asset_id)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(err)) => {
|
||||
warn!(
|
||||
job_id = %job.id,
|
||||
error = ?err,
|
||||
asset_id = %asset_id,
|
||||
"failed to remove thumbnail metadata after deletion"
|
||||
);
|
||||
}
|
||||
Err(join_err) => {
|
||||
warn!(
|
||||
job_id = %job.id,
|
||||
error = %join_err,
|
||||
asset_id = %asset_id,
|
||||
"failed to remove thumbnail metadata: task panicked"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let preview_asset_id = Uuid::new_v4();
|
||||
|
||||
+624
-4
@@ -1,15 +1,73 @@
|
||||
mod common;
|
||||
|
||||
use anyhow::Result;
|
||||
use axum::http::StatusCode;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use axum::http::{header::SET_COOKIE, StatusCode};
|
||||
use backend::auth::passkeys::{
|
||||
PasskeyLoginFinishPayload, PasskeyLoginStartPayload, PasskeyRegistrationFinishPayload,
|
||||
RegistrationChallengeResponse,
|
||||
};
|
||||
use backend::models::{NewRefreshToken, NewUserMembership, TenantStatus, UserPasskey};
|
||||
use backend::openapi::schemas::PasskeySummary;
|
||||
use backend::schema::{refresh_tokens, tenants, user_memberships, users};
|
||||
use chrono::{Duration as ChronoDuration, Utc};
|
||||
use common::{acquire_db_lock, body_to_vec, TestApp};
|
||||
use diesel::prelude::*;
|
||||
use rand::rngs::OsRng;
|
||||
use rand::RngCore;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use sha2::{Digest, Sha256};
|
||||
use uuid::Uuid;
|
||||
use webauthn_rs_core::proto::{
|
||||
AuthenticatorAssertionResponseRaw, AuthenticatorAttestationResponseRaw, PublicKeyCredential,
|
||||
RegisterPublicKeyCredential,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AuthenticatedUser {
|
||||
username: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ErrorResponse {
|
||||
error: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LoginTenant {
|
||||
id: Uuid,
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LoginResponse {
|
||||
access_token: String,
|
||||
tenant: LoginTenant,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SignupStartResponse {
|
||||
signup_token: String,
|
||||
challenge: RegistrationChallengeResponse,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TenantSelectionResponse {
|
||||
access_token: String,
|
||||
tenants: Vec<TenantSummary>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TenantListResponse {
|
||||
tenants: Vec<TenantSummary>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TenantSummary {
|
||||
id: Uuid,
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_and_me_roundtrip() -> Result<()> {
|
||||
let _lock = acquire_db_lock().await;
|
||||
@@ -18,9 +76,9 @@ async fn login_and_me_roundtrip() -> Result<()> {
|
||||
let password = "s3cret";
|
||||
app.insert_user("alice", password, "admin").await?;
|
||||
|
||||
let token = app.login_token("alice", password).await?;
|
||||
let (login, _) = login_with_session(&app, "alice", password).await?;
|
||||
|
||||
let response = app.get("/api/auth/me", Some(&token)).await?;
|
||||
let response = app.get("/api/auth/me", Some(&login.access_token)).await?;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = body_to_vec(response.into_body()).await?;
|
||||
let user: AuthenticatedUser = serde_json::from_slice(&body)?;
|
||||
@@ -30,3 +88,565 @@ async fn login_and_me_roundtrip() -> Result<()> {
|
||||
app.cleanup().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_rejects_unknown_user() -> Result<()> {
|
||||
let _lock = acquire_db_lock().await;
|
||||
let app = TestApp::new().await?;
|
||||
|
||||
let payload = json!({ "username": "ghost", "password": "nope" });
|
||||
let response = app.post_json("/api/auth/login", &payload, None).await?;
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
let body = body_to_vec(response.into_body()).await?;
|
||||
let err: ErrorResponse = serde_json::from_slice(&body)?;
|
||||
assert_eq!(err.error, "password authentication is no longer supported");
|
||||
|
||||
app.cleanup().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn signup_start_and_finish_require_valid_passkey() -> Result<()> {
|
||||
let _lock = acquire_db_lock().await;
|
||||
let app = TestApp::new().await?;
|
||||
|
||||
let payload = json!({ "username": "signup-user" });
|
||||
|
||||
let response = app
|
||||
.post_json("/api/auth/signup/start", &payload, None)
|
||||
.await?;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = body_to_vec(response.into_body()).await?;
|
||||
let start: SignupStartResponse = serde_json::from_slice(&body)?;
|
||||
assert!(!start.signup_token.is_empty());
|
||||
assert_ne!(start.challenge.challenge_id, Uuid::nil());
|
||||
|
||||
app.with_conn(|conn| {
|
||||
let exists: bool = diesel::select(diesel::dsl::exists(
|
||||
users::table.filter(users::username.eq("signup-user")),
|
||||
))
|
||||
.get_result(conn)?;
|
||||
assert!(!exists);
|
||||
Ok(())
|
||||
})
|
||||
.await?;
|
||||
|
||||
let finish_payload = json!({
|
||||
"signup_token": start.signup_token,
|
||||
"credential": fake_register_credential(),
|
||||
});
|
||||
let finish_response = app
|
||||
.post_json("/api/auth/signup/finish", &finish_payload, None)
|
||||
.await?;
|
||||
assert_eq!(finish_response.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
app.cleanup().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn passkey_register_start_creates_challenge() -> Result<()> {
|
||||
let _lock = acquire_db_lock().await;
|
||||
let app = TestApp::new().await?;
|
||||
|
||||
let password = "secret";
|
||||
app.insert_user("passkey-user", password, "admin").await?;
|
||||
|
||||
let (login, _) = login_with_session(&app, "passkey-user", password).await?;
|
||||
|
||||
let response = app
|
||||
.post_json(
|
||||
"/api/auth/passkeys/register/start",
|
||||
&json!({}),
|
||||
Some(&login.access_token),
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = body_to_vec(response.into_body()).await?;
|
||||
let challenge: RegistrationChallengeResponse = serde_json::from_slice(&body)?;
|
||||
assert_ne!(challenge.challenge_id, Uuid::nil());
|
||||
let challenge_id = challenge.challenge_id;
|
||||
|
||||
app.with_conn(move |conn| {
|
||||
use backend::schema::webauthn_challenges::dsl;
|
||||
use diesel::dsl::{exists, select};
|
||||
|
||||
let exists: bool = select(exists(
|
||||
dsl::webauthn_challenges.filter(dsl::id.eq(challenge_id)),
|
||||
))
|
||||
.get_result(conn)?;
|
||||
assert!(exists, "challenge not persisted");
|
||||
Ok(())
|
||||
})
|
||||
.await?;
|
||||
|
||||
app.cleanup().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn passkey_register_finish_rejects_unknown_challenge() -> Result<()> {
|
||||
let _lock = acquire_db_lock().await;
|
||||
let app = TestApp::new().await?;
|
||||
|
||||
let password = "secret";
|
||||
app.insert_user("passkey-register", password, "admin")
|
||||
.await?;
|
||||
let (login, _) = login_with_session(&app, "passkey-register", password).await?;
|
||||
|
||||
let payload = PasskeyRegistrationFinishPayload {
|
||||
challenge_id: Uuid::new_v4(),
|
||||
credential: fake_register_credential(),
|
||||
nickname: None,
|
||||
};
|
||||
|
||||
let response = app
|
||||
.post_json(
|
||||
"/api/auth/passkeys/register/finish",
|
||||
&payload,
|
||||
Some(&login.access_token),
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
app.cleanup().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn passkey_login_start_requires_passkey() -> Result<()> {
|
||||
let _lock = acquire_db_lock().await;
|
||||
let app = TestApp::new().await?;
|
||||
|
||||
let password = "secret";
|
||||
app.insert_user("passkey-login", password, "admin").await?;
|
||||
|
||||
let payload = PasskeyLoginStartPayload {
|
||||
username: "passkey-login".to_string(),
|
||||
};
|
||||
|
||||
let response = app
|
||||
.post_json("/api/auth/passkeys/login/start", &payload, None)
|
||||
.await?;
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
app.cleanup().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn passkey_login_start_unknown_user_returns_not_found() -> Result<()> {
|
||||
let _lock = acquire_db_lock().await;
|
||||
let app = TestApp::new().await?;
|
||||
|
||||
let payload = PasskeyLoginStartPayload {
|
||||
username: "nobody".to_string(),
|
||||
};
|
||||
|
||||
let response = app
|
||||
.post_json("/api/auth/passkeys/login/start", &payload, None)
|
||||
.await?;
|
||||
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
|
||||
app.cleanup().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn passkey_login_finish_rejects_invalid_challenge() -> Result<()> {
|
||||
let _lock = acquire_db_lock().await;
|
||||
let app = TestApp::new().await?;
|
||||
|
||||
let payload = PasskeyLoginFinishPayload {
|
||||
challenge_id: Uuid::new_v4(),
|
||||
credential: fake_authentication_credential(),
|
||||
};
|
||||
|
||||
let response = app
|
||||
.post_json("/api/auth/passkeys/login/finish", &payload, None)
|
||||
.await?;
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
app.cleanup().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_passkeys_returns_entries() -> Result<()> {
|
||||
let _lock = acquire_db_lock().await;
|
||||
let app = TestApp::new().await?;
|
||||
|
||||
let password = "secret";
|
||||
let user_id = app.insert_user("passkey-owner", password, "admin").await?;
|
||||
app.insert_passkey(user_id, Some("Laptop")).await?;
|
||||
|
||||
let (session, _) = login_with_session(&app, "passkey-owner", password).await?;
|
||||
|
||||
let response = app
|
||||
.get("/api/profile/passkeys", Some(&session.access_token))
|
||||
.await?;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let body = body_to_vec(response.into_body()).await?;
|
||||
let summaries: Vec<PasskeySummary> = serde_json::from_slice(&body)?;
|
||||
assert_eq!(summaries.len(), 1);
|
||||
assert_eq!(summaries[0].nickname.as_deref(), Some("Laptop"));
|
||||
assert!(summaries[0].revoked_at.is_none());
|
||||
|
||||
app.cleanup().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_passkey_soft_revokes() -> Result<()> {
|
||||
let _lock = acquire_db_lock().await;
|
||||
let app = TestApp::new().await?;
|
||||
|
||||
let password = "secret";
|
||||
let user_id = app.insert_user("passkey-delete", password, "admin").await?;
|
||||
let passkey_id = app.insert_passkey(user_id, Some("Phone")).await?;
|
||||
app.insert_passkey(user_id, Some("Backup")).await?;
|
||||
let (session, _) = login_with_session(&app, "passkey-delete", password).await?;
|
||||
|
||||
let response = app
|
||||
.delete(
|
||||
&format!("/api/profile/passkeys/{}?reason=lost", passkey_id),
|
||||
Some(&session.access_token),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
||||
|
||||
app.with_conn(move |conn| {
|
||||
use backend::schema::user_passkeys::dsl as passkey_dsl;
|
||||
|
||||
let record = passkey_dsl::user_passkeys
|
||||
.find(passkey_id)
|
||||
.first::<UserPasskey>(conn)?;
|
||||
assert!(record.revoked_at.is_some());
|
||||
assert_eq!(record.revoked_reason.as_deref(), Some("lost"));
|
||||
Ok(())
|
||||
})
|
||||
.await?;
|
||||
|
||||
app.cleanup().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_passkey_prevents_last() -> Result<()> {
|
||||
let _lock = acquire_db_lock().await;
|
||||
let app = TestApp::new().await?;
|
||||
|
||||
let password = "secret";
|
||||
let user_id = app.insert_user("passkey-guard", password, "admin").await?;
|
||||
let first_id = app.insert_passkey(user_id, Some("Key A")).await?;
|
||||
let last_id = app.insert_passkey(user_id, Some("Key B")).await?;
|
||||
let (session, _) = login_with_session(&app, "passkey-guard", password).await?;
|
||||
|
||||
let response = app
|
||||
.delete(
|
||||
&format!("/api/profile/passkeys/{}", first_id),
|
||||
Some(&session.access_token),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
||||
|
||||
let block_response = app
|
||||
.delete(
|
||||
&format!("/api/profile/passkeys/{}", last_id),
|
||||
Some(&session.access_token),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(block_response.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
app.cleanup().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn fake_register_credential() -> RegisterPublicKeyCredential {
|
||||
RegisterPublicKeyCredential {
|
||||
id: "fake-passkey".to_string(),
|
||||
raw_id: vec![1, 2, 3, 4].into(),
|
||||
response: AuthenticatorAttestationResponseRaw {
|
||||
attestation_object: vec![5, 6, 7, 8].into(),
|
||||
client_data_json: vec![9, 10, 11, 12].into(),
|
||||
transports: None,
|
||||
},
|
||||
type_: "public-key".to_string(),
|
||||
extensions: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn fake_authentication_credential() -> PublicKeyCredential {
|
||||
PublicKeyCredential {
|
||||
id: "fake-auth".to_string(),
|
||||
raw_id: vec![1, 2, 3].into(),
|
||||
response: AuthenticatorAssertionResponseRaw {
|
||||
authenticator_data: vec![4, 5, 6].into(),
|
||||
client_data_json: vec![7, 8, 9].into(),
|
||||
signature: vec![10, 11, 12].into(),
|
||||
user_handle: None,
|
||||
},
|
||||
extensions: Default::default(),
|
||||
type_: "public-key".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_rejects_invalid_password() -> Result<()> {
|
||||
let _lock = acquire_db_lock().await;
|
||||
let app = TestApp::new().await?;
|
||||
|
||||
let password = "valid";
|
||||
app.insert_user("robin", password, "admin").await?;
|
||||
|
||||
let payload = json!({ "username": "robin", "password": "wrong" });
|
||||
let response = app.post_json("/api/auth/login", &payload, None).await?;
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
let body = body_to_vec(response.into_body()).await?;
|
||||
let err: ErrorResponse = serde_json::from_slice(&body)?;
|
||||
assert_eq!(err.error, "password authentication is no longer supported");
|
||||
|
||||
app.cleanup().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_rotates_refresh_token() -> Result<()> {
|
||||
let _lock = acquire_db_lock().await;
|
||||
let app = TestApp::new().await?;
|
||||
|
||||
let password = "rotate";
|
||||
app.insert_user("rita", password, "admin").await?;
|
||||
|
||||
let (login, refresh_cookie) = login_with_session(&app, "rita", password).await?;
|
||||
|
||||
let response = app
|
||||
.post_json_with_cookie("/api/auth/refresh", &json!({}), None, Some(&refresh_cookie))
|
||||
.await?;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let new_cookie = extract_refresh_cookie(response.headers())?;
|
||||
let body = body_to_vec(response.into_body()).await?;
|
||||
let refreshed: LoginResponse = serde_json::from_slice(&body)?;
|
||||
assert_eq!(refreshed.tenant.name, login.tenant.name);
|
||||
|
||||
let me_response = app
|
||||
.get("/api/auth/me", Some(&refreshed.access_token))
|
||||
.await?;
|
||||
assert_eq!(me_response.status(), StatusCode::OK);
|
||||
|
||||
let retry = app
|
||||
.post_json_with_cookie("/api/auth/refresh", &json!({}), None, Some(&refresh_cookie))
|
||||
.await?;
|
||||
assert_eq!(retry.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
// new cookie should differ from old to avoid reuse
|
||||
assert_ne!(new_cookie, refresh_cookie);
|
||||
|
||||
app.cleanup().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn logout_revokes_refresh_token() -> Result<()> {
|
||||
let _lock = acquire_db_lock().await;
|
||||
let app = TestApp::new().await?;
|
||||
|
||||
let password = "logout";
|
||||
app.insert_user("logan", password, "admin").await?;
|
||||
|
||||
let (login, refresh_cookie) = login_with_session(&app, "logan", password).await?;
|
||||
|
||||
let response = app
|
||||
.post_json_with_cookie(
|
||||
"/api/auth/logout",
|
||||
&json!({}),
|
||||
Some(&login.access_token),
|
||||
Some(&refresh_cookie),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
||||
let cleared_cookie = extract_refresh_cookie(response.headers())?;
|
||||
assert!(cleared_cookie.ends_with("="));
|
||||
|
||||
let after_logout = app
|
||||
.post_json_with_cookie("/api/auth/refresh", &json!({}), None, Some(&refresh_cookie))
|
||||
.await?;
|
||||
assert_eq!(after_logout.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
app.cleanup().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn me_requires_authentication() -> Result<()> {
|
||||
let _lock = acquire_db_lock().await;
|
||||
let app = TestApp::new().await?;
|
||||
|
||||
let unauthenticated = app.get("/api/auth/me", None).await?;
|
||||
assert_eq!(unauthenticated.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
let invalid = app.get("/api/auth/me", Some("invalid")).await?;
|
||||
assert_eq!(invalid.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
app.cleanup().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_returns_tenant_selection_when_multiple_memberships() -> Result<()> {
|
||||
let _lock = acquire_db_lock().await;
|
||||
let app = TestApp::new().await?;
|
||||
|
||||
let password = "multipass";
|
||||
let user_id = app.insert_user("multipass", password, "admin").await?;
|
||||
|
||||
let secondary_name = "secondary".to_string();
|
||||
let name_for_insert = secondary_name.clone();
|
||||
let secondary_id = Uuid::new_v4();
|
||||
app.with_conn(move |conn| {
|
||||
diesel::insert_into(tenants::table)
|
||||
.values((
|
||||
tenants::id.eq(secondary_id),
|
||||
tenants::name.eq(&name_for_insert),
|
||||
tenants::status.eq(TenantStatus::Active),
|
||||
))
|
||||
.execute(conn)?;
|
||||
|
||||
let membership = NewUserMembership {
|
||||
id: Uuid::new_v4(),
|
||||
user_id,
|
||||
tenant_id: secondary_id,
|
||||
};
|
||||
|
||||
diesel::insert_into(user_memberships::table)
|
||||
.values(&membership)
|
||||
.execute(conn)?;
|
||||
Ok(())
|
||||
})
|
||||
.await?;
|
||||
|
||||
let (login, refresh_cookie) = login_with_session(&app, "multipass", password).await?;
|
||||
|
||||
let tenants_response = app
|
||||
.get("/api/auth/tenants", Some(&login.access_token))
|
||||
.await?;
|
||||
assert_eq!(tenants_response.status(), StatusCode::OK);
|
||||
let tenants_body = body_to_vec(tenants_response.into_body()).await?;
|
||||
let tenant_list: TenantListResponse = serde_json::from_slice(&tenants_body)?;
|
||||
assert!(tenant_list.tenants.len() >= 2);
|
||||
|
||||
let secondary = tenant_list
|
||||
.tenants
|
||||
.iter()
|
||||
.find(|tenant| tenant.name == secondary_name)
|
||||
.map(|t| t.id)
|
||||
.context("secondary tenant missing from listing")?;
|
||||
|
||||
let select_response = app
|
||||
.post_json_with_cookie(
|
||||
"/api/auth/select-tenant",
|
||||
&json!({ "tenant_id": secondary }),
|
||||
Some(&login.access_token),
|
||||
Some(&refresh_cookie),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(select_response.status(), StatusCode::OK);
|
||||
let select_body = body_to_vec(select_response.into_body()).await?;
|
||||
let rotated: LoginResponse = serde_json::from_slice(&select_body)?;
|
||||
assert_eq!(rotated.tenant.id, secondary);
|
||||
assert_eq!(rotated.tenant.name, secondary_name);
|
||||
|
||||
app.cleanup().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn login_with_session(
|
||||
app: &TestApp,
|
||||
username: &str,
|
||||
_password: &str,
|
||||
) -> Result<(LoginResponse, String)> {
|
||||
let username = username.to_string();
|
||||
let state = app.state.clone();
|
||||
app.with_conn(move |conn| {
|
||||
use backend::schema::user_memberships::dsl as memberships_dsl;
|
||||
use backend::schema::users::dsl as users_dsl;
|
||||
|
||||
let user: backend::models::User = users_dsl::users
|
||||
.filter(users_dsl::username.eq(&username))
|
||||
.first(conn)?;
|
||||
|
||||
let membership: backend::models::UserMembership = memberships_dsl::user_memberships
|
||||
.filter(memberships_dsl::user_id.eq(user.id))
|
||||
.first(conn)?;
|
||||
|
||||
let tenant: backend::models::Tenant =
|
||||
tenants::table.find(membership.tenant_id).first(conn)?;
|
||||
|
||||
let now = Utc::now();
|
||||
let access_token = state
|
||||
.jwt
|
||||
.generate_token(user.id, tenant.id, &user.username)
|
||||
.map_err(|err| anyhow!(err))?;
|
||||
|
||||
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: tenant.id,
|
||||
};
|
||||
|
||||
diesel::insert_into(refresh_tokens::table)
|
||||
.values(&new_refresh)
|
||||
.execute(conn)?;
|
||||
|
||||
let login = LoginResponse {
|
||||
access_token,
|
||||
tenant: LoginTenant {
|
||||
id: tenant.id,
|
||||
name: tenant.name.clone(),
|
||||
},
|
||||
};
|
||||
|
||||
let cookie = format!("refresh_token={refresh_value}");
|
||||
Ok((login, cookie))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
fn extract_refresh_cookie(headers: &axum::http::HeaderMap) -> Result<String> {
|
||||
let header_value = headers
|
||||
.get(SET_COOKIE)
|
||||
.context("missing set-cookie header")?
|
||||
.to_str()
|
||||
.context("invalid set-cookie header")?;
|
||||
let cookie = header_value
|
||||
.split(';')
|
||||
.next()
|
||||
.context("set-cookie missing cookie value")?
|
||||
.to_string();
|
||||
Ok(cookie)
|
||||
}
|
||||
|
||||
fn generate_refresh_token() -> String {
|
||||
let mut bytes = [0u8; 32];
|
||||
OsRng.fill_bytes(&mut bytes);
|
||||
hex::encode(bytes)
|
||||
}
|
||||
|
||||
fn hash_refresh_token(value: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(value.as_bytes());
|
||||
hex::encode(hasher.finalize())
|
||||
}
|
||||
|
||||
+204
-98
@@ -6,15 +6,20 @@ use std::time::Duration;
|
||||
use anyhow::{anyhow, ensure, Context, Result};
|
||||
use async_trait::async_trait;
|
||||
use axum::body::Body;
|
||||
use axum::http::{Method, Request, StatusCode};
|
||||
use axum::http::{header, Method, Request};
|
||||
use axum::Router;
|
||||
use backend::auth::jwt::JwtService;
|
||||
use backend::config::AppConfig;
|
||||
use backend::db::{self, PgPool};
|
||||
use backend::models::{Job, NewUser, NewUserMembership, Tenant};
|
||||
use backend::models::{
|
||||
Job, NewRefreshToken, NewUser, NewUserMembership, NewUserPasskey, Tenant, TenantStatus, User,
|
||||
UserMembership,
|
||||
};
|
||||
use backend::routes;
|
||||
use backend::schema::refresh_tokens::dsl as refresh_dsl;
|
||||
use backend::state::AppState;
|
||||
use backend::storage::ObjectStorage;
|
||||
use chrono::{Duration as ChronoDuration, Utc};
|
||||
use diesel::connection::SimpleConnection;
|
||||
use diesel::prelude::*;
|
||||
use diesel::OptionalExtension;
|
||||
@@ -23,8 +28,10 @@ use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
|
||||
use http_body_util::BodyExt;
|
||||
use once_cell::sync::Lazy;
|
||||
use rand::rngs::OsRng;
|
||||
use rand::RngCore;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json;
|
||||
use serde_json::{self, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
use tokio::sync::Mutex;
|
||||
use tower::util::ServiceExt;
|
||||
use uuid::Uuid;
|
||||
@@ -33,6 +40,8 @@ const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
|
||||
|
||||
static DB_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
|
||||
|
||||
const TEST_TENANT_NAME: &str = "test_tenant";
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone)]
|
||||
pub struct StoredObject {
|
||||
@@ -140,7 +149,11 @@ impl TestApp {
|
||||
s3_bucket: "test-bucket".to_string(),
|
||||
quickwit_endpoint: None,
|
||||
quickwit_index: None,
|
||||
default_tenant_slug: "admin".to_string(),
|
||||
worker_max_document_bytes: 200 * 1024 * 1024,
|
||||
upload_body_limit_bytes: 128 * 1024 * 1024,
|
||||
webauthn_rp_id: Some("localhost".to_string()),
|
||||
webauthn_origin: Some("http://localhost".to_string()),
|
||||
webauthn_rp_name: "Papercrate".to_string(),
|
||||
};
|
||||
|
||||
let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?;
|
||||
@@ -186,10 +199,11 @@ impl TestApp {
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn storage_key_for(&self, key: &str) -> Result<String> {
|
||||
self.ensure_default_tenant().await?;
|
||||
let tenant = self
|
||||
.state
|
||||
.tenants
|
||||
.get_by_slug(&self.state.config.default_tenant_slug)
|
||||
.get_by_name(TEST_TENANT_NAME)
|
||||
.map_err(|err| anyhow!("default tenant not found: {:?}", err))?;
|
||||
let root = tenant
|
||||
.storage_root
|
||||
@@ -198,21 +212,13 @@ impl TestApp {
|
||||
Ok(format!("{}{}", root, key))
|
||||
}
|
||||
|
||||
pub async fn insert_user(&self, username: &str, password: &str, role: &str) -> Result<Uuid> {
|
||||
pub async fn insert_user(&self, username: &str, _password: &str, _role: &str) -> Result<Uuid> {
|
||||
let username = username.to_string();
|
||||
let password = password.to_string();
|
||||
let role = role.to_string();
|
||||
let tenant_id = self
|
||||
.state
|
||||
.tenants
|
||||
.tenant_id_for_slug(&self.state.config.default_tenant_slug)
|
||||
.map_err(|err| anyhow!("default tenant not found: {:?}", err))?;
|
||||
let tenant_id = self.ensure_default_tenant().await?;
|
||||
self.with_conn(move |conn| {
|
||||
let password_hash = hash_password(&password)?;
|
||||
let user = NewUser {
|
||||
id: Uuid::new_v4(),
|
||||
username,
|
||||
password_hash,
|
||||
};
|
||||
diesel::insert_into(backend::schema::users::table)
|
||||
.values(&user)
|
||||
@@ -223,7 +229,6 @@ impl TestApp {
|
||||
id: Uuid::new_v4(),
|
||||
user_id: user.id,
|
||||
tenant_id,
|
||||
role,
|
||||
};
|
||||
|
||||
diesel::insert_into(backend::schema::user_memberships::table)
|
||||
@@ -235,14 +240,42 @@ impl TestApp {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn insert_passkey(&self, user_id: Uuid, nickname: Option<&str>) -> Result<Uuid> {
|
||||
let passkey_id = Uuid::new_v4();
|
||||
let nickname = nickname.map(|value| value.to_string());
|
||||
self.with_conn(move |conn| {
|
||||
let credential_id = passkey_id.as_bytes().to_vec();
|
||||
let public_key = passkey_id.as_bytes().iter().copied().collect::<Vec<u8>>();
|
||||
let passkey = NewUserPasskey {
|
||||
id: passkey_id,
|
||||
user_id,
|
||||
credential_id,
|
||||
public_key,
|
||||
credential: json!({ "dummy": passkey_id.to_string() }),
|
||||
sign_count: 0,
|
||||
transports: vec![Some("usb".to_string())],
|
||||
aaguid: None,
|
||||
nickname,
|
||||
};
|
||||
|
||||
diesel::insert_into(backend::schema::user_passkeys::table)
|
||||
.values(&passkey)
|
||||
.execute(conn)
|
||||
.context("failed to insert passkey")?;
|
||||
|
||||
Ok(passkey_id)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn ensure_default_tenant(&self) -> Result<Uuid> {
|
||||
let slug_value = self.state.config.default_tenant_slug.clone();
|
||||
let name_value = TEST_TENANT_NAME.to_string();
|
||||
let quickwit_enabled = self.state.config.quickwit_endpoint.is_some();
|
||||
self.with_conn(move |conn| {
|
||||
use backend::schema::tenants::dsl as tenants_dsl;
|
||||
|
||||
let existing = tenants_dsl::tenants
|
||||
.filter(tenants_dsl::slug.eq(&slug_value))
|
||||
.filter(tenants_dsl::name.eq(&name_value))
|
||||
.first::<Tenant>(conn)
|
||||
.optional()
|
||||
.context("failed to load default tenant")?;
|
||||
@@ -274,9 +307,10 @@ impl TestApp {
|
||||
diesel::insert_into(tenants_dsl::tenants)
|
||||
.values((
|
||||
tenants_dsl::id.eq(new_id),
|
||||
tenants_dsl::slug.eq(&slug_value),
|
||||
tenants_dsl::name.eq(&name_value),
|
||||
tenants_dsl::storage_root.eq(Some(root)),
|
||||
tenants_dsl::quickwit_index.eq(quickwit_value),
|
||||
tenants_dsl::status.eq(TenantStatus::Active),
|
||||
))
|
||||
.execute(conn)
|
||||
.context("failed to insert default tenant")?;
|
||||
@@ -289,80 +323,59 @@ impl TestApp {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn login_token(&self, username: &str, password: &str) -> Result<String> {
|
||||
#[derive(Serialize)]
|
||||
struct LoginPayload<'a> {
|
||||
username: &'a str,
|
||||
password: &'a str,
|
||||
}
|
||||
pub async fn login_token(&self, username: &str, _password: &str) -> Result<String> {
|
||||
let (access_token, _, _) = self.create_session(username).await?;
|
||||
Ok(access_token)
|
||||
}
|
||||
|
||||
let response = self
|
||||
.post_json(
|
||||
"/api/auth/login",
|
||||
&LoginPayload { username, password },
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
pub async fn create_session(&self, username: &str) -> Result<(String, String, Uuid)> {
|
||||
let username = username.to_string();
|
||||
let state = self.state.clone();
|
||||
self.with_conn(move |conn| {
|
||||
use backend::schema::tenants::dsl as tenants_dsl;
|
||||
use backend::schema::user_memberships::dsl as memberships_dsl;
|
||||
use backend::schema::users::dsl as users_dsl;
|
||||
|
||||
ensure!(
|
||||
response.status() == StatusCode::OK,
|
||||
"login failed with status {}",
|
||||
response.status()
|
||||
);
|
||||
let user: User = users_dsl::users
|
||||
.filter(users_dsl::username.eq(&username))
|
||||
.first(conn)?;
|
||||
|
||||
let body = body_to_vec(response.into_body()).await?;
|
||||
#[derive(Deserialize)]
|
||||
struct LoginResponse {
|
||||
access_token: String,
|
||||
}
|
||||
let membership: UserMembership = memberships_dsl::user_memberships
|
||||
.filter(memberships_dsl::user_id.eq(user.id))
|
||||
.first(conn)?;
|
||||
|
||||
if let Ok(parsed) = serde_json::from_slice::<LoginResponse>(&body) {
|
||||
return Ok(parsed.access_token);
|
||||
}
|
||||
let tenant: Tenant = tenants_dsl::tenants
|
||||
.find(membership.tenant_id)
|
||||
.first(conn)?;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TenantSummary {
|
||||
tenant_id: Uuid,
|
||||
_slug: String,
|
||||
}
|
||||
let now = Utc::now();
|
||||
let access_token = state
|
||||
.jwt
|
||||
.generate_token(user.id, tenant.id, &user.username)
|
||||
.map_err(|err| anyhow!(err))?;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TenantSelectionResponse {
|
||||
selection_token: String,
|
||||
tenants: Vec<TenantSummary>,
|
||||
}
|
||||
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 selection: TenantSelectionResponse = serde_json::from_slice(&body)?;
|
||||
ensure!(
|
||||
!selection.tenants.is_empty(),
|
||||
"login returned no tenant options",
|
||||
);
|
||||
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: tenant.id,
|
||||
};
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SelectTenantPayload {
|
||||
tenant_id: Uuid,
|
||||
}
|
||||
diesel::insert_into(refresh_dsl::refresh_tokens)
|
||||
.values(&new_refresh)
|
||||
.execute(conn)?;
|
||||
|
||||
let target_tenant = selection.tenants[0].tenant_id;
|
||||
let select_response = self
|
||||
.post_json(
|
||||
"/api/auth/select-tenant",
|
||||
&SelectTenantPayload {
|
||||
tenant_id: target_tenant,
|
||||
},
|
||||
Some(&selection.selection_token),
|
||||
)
|
||||
.await?;
|
||||
|
||||
ensure!(
|
||||
select_response.status() == StatusCode::OK,
|
||||
"tenant selection failed with status {}",
|
||||
select_response.status()
|
||||
);
|
||||
|
||||
let select_body = body_to_vec(select_response.into_body()).await?;
|
||||
let parsed: LoginResponse = serde_json::from_slice(&select_body)?;
|
||||
Ok(parsed.access_token)
|
||||
let cookie = format!("refresh_token={refresh_value}");
|
||||
Ok((access_token, cookie, tenant.id))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
@@ -396,6 +409,16 @@ impl TestApp {
|
||||
path: &str,
|
||||
payload: &T,
|
||||
token: Option<&str>,
|
||||
) -> Result<hyper::Response<Body>> {
|
||||
self.post_json_with_cookie(path, payload, token, None).await
|
||||
}
|
||||
|
||||
pub async fn post_json_with_cookie<T: Serialize + ?Sized>(
|
||||
&self,
|
||||
path: &str,
|
||||
payload: &T,
|
||||
token: Option<&str>,
|
||||
cookie: Option<&str>,
|
||||
) -> Result<hyper::Response<Body>> {
|
||||
let body = serde_json::to_vec(payload)?;
|
||||
let mut builder = Request::builder()
|
||||
@@ -405,6 +428,9 @@ impl TestApp {
|
||||
if let Some(token) = token {
|
||||
builder = builder.header("authorization", format!("Bearer {token}"));
|
||||
}
|
||||
if let Some(cookie) = cookie {
|
||||
builder = builder.header(header::COOKIE, cookie);
|
||||
}
|
||||
let request = builder.body(Body::from(body))?;
|
||||
Ok(self
|
||||
.router
|
||||
@@ -479,14 +505,14 @@ impl TestApp {
|
||||
folder_id: Option<Uuid>,
|
||||
token: &str,
|
||||
) -> Result<hyper::Response<Body>> {
|
||||
self.upload_document_with_options(
|
||||
let extras = UploadExtras::empty();
|
||||
self.upload_document_with_extras(
|
||||
path,
|
||||
filename,
|
||||
content_type,
|
||||
data,
|
||||
folder_id,
|
||||
None,
|
||||
None,
|
||||
extras,
|
||||
token,
|
||||
)
|
||||
.await
|
||||
@@ -502,6 +528,36 @@ impl TestApp {
|
||||
title: Option<&str>,
|
||||
metadata_json: Option<&str>,
|
||||
token: &str,
|
||||
) -> Result<hyper::Response<Body>> {
|
||||
let extras = UploadExtras {
|
||||
title,
|
||||
metadata_json,
|
||||
tag_ids_json: None,
|
||||
correspondents_json: None,
|
||||
issued_at: None,
|
||||
skip_existing: false,
|
||||
};
|
||||
self.upload_document_with_extras(
|
||||
path,
|
||||
filename,
|
||||
content_type,
|
||||
data,
|
||||
folder_id,
|
||||
extras,
|
||||
token,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn upload_document_with_extras(
|
||||
&self,
|
||||
path: &str,
|
||||
filename: &str,
|
||||
content_type: &str,
|
||||
data: &[u8],
|
||||
folder_id: Option<Uuid>,
|
||||
extras: UploadExtras<'_>,
|
||||
token: &str,
|
||||
) -> Result<hyper::Response<Body>> {
|
||||
let boundary = format!("boundary-{}", Uuid::new_v4());
|
||||
let mut body = Vec::new();
|
||||
@@ -524,20 +580,46 @@ impl TestApp {
|
||||
body.extend(b"\r\n");
|
||||
}
|
||||
|
||||
if let Some(title_value) = title {
|
||||
if let Some(title_value) = extras.title {
|
||||
body.extend(format!("--{boundary}\r\n").as_bytes());
|
||||
body.extend(b"Content-Disposition: form-data; name=\"title\"\r\n\r\n");
|
||||
body.extend(title_value.as_bytes());
|
||||
body.extend(b"\r\n");
|
||||
}
|
||||
|
||||
if let Some(metadata_value) = metadata_json {
|
||||
if let Some(metadata_value) = extras.metadata_json {
|
||||
body.extend(format!("--{boundary}\r\n").as_bytes());
|
||||
body.extend(b"Content-Disposition: form-data; name=\"metadata\"\r\n\r\n");
|
||||
body.extend(metadata_value.as_bytes());
|
||||
body.extend(b"\r\n");
|
||||
}
|
||||
|
||||
if let Some(tag_ids_value) = extras.tag_ids_json {
|
||||
body.extend(format!("--{boundary}\r\n").as_bytes());
|
||||
body.extend(b"Content-Disposition: form-data; name=\"tag_ids\"\r\n\r\n");
|
||||
body.extend(tag_ids_value.as_bytes());
|
||||
body.extend(b"\r\n");
|
||||
}
|
||||
|
||||
if let Some(correspondents_value) = extras.correspondents_json {
|
||||
body.extend(format!("--{boundary}\r\n").as_bytes());
|
||||
body.extend(b"Content-Disposition: form-data; name=\"correspondents\"\r\n\r\n");
|
||||
body.extend(correspondents_value.as_bytes());
|
||||
body.extend(b"\r\n");
|
||||
}
|
||||
|
||||
if let Some(issued_at_value) = extras.issued_at {
|
||||
body.extend(format!("--{boundary}\r\n").as_bytes());
|
||||
body.extend(b"Content-Disposition: form-data; name=\"issued_at\"\r\n\r\n");
|
||||
body.extend(issued_at_value.as_bytes());
|
||||
body.extend(b"\r\n");
|
||||
}
|
||||
|
||||
if extras.skip_existing {
|
||||
body.extend(format!("--{boundary}\r\n").as_bytes());
|
||||
body.extend(b"Content-Disposition: form-data; name=\"skip_existing\"\r\n\r\ntrue\r\n");
|
||||
}
|
||||
|
||||
body.extend(format!("--{boundary}--\r\n").as_bytes());
|
||||
|
||||
let builder = Request::builder()
|
||||
@@ -558,7 +640,7 @@ impl TestApp {
|
||||
.expect("infallible response"))
|
||||
}
|
||||
|
||||
async fn with_conn<F, T>(&self, f: F) -> Result<T>
|
||||
pub async fn with_conn<F, T>(&self, f: F) -> Result<T>
|
||||
where
|
||||
F: FnOnce(&mut PgConnection) -> Result<T> + Send + 'static,
|
||||
T: Send + 'static,
|
||||
@@ -575,6 +657,28 @@ impl TestApp {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct UploadExtras<'a> {
|
||||
pub title: Option<&'a str>,
|
||||
pub metadata_json: Option<&'a str>,
|
||||
pub tag_ids_json: Option<&'a str>,
|
||||
pub correspondents_json: Option<&'a str>,
|
||||
pub issued_at: Option<&'a str>,
|
||||
pub skip_existing: bool,
|
||||
}
|
||||
|
||||
impl<'a> UploadExtras<'a> {
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
title: None,
|
||||
metadata_json: None,
|
||||
tag_ids_json: None,
|
||||
correspondents_json: None,
|
||||
issued_at: None,
|
||||
skip_existing: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn acquire_db_lock() -> tokio::sync::MutexGuard<'static, ()> {
|
||||
DB_LOCK.lock().await
|
||||
}
|
||||
@@ -616,6 +720,7 @@ fn truncate_all(conn: &mut PgConnection) -> Result<()> {
|
||||
jobs, \
|
||||
refresh_tokens, \
|
||||
tags, \
|
||||
webdav_tokens, \
|
||||
user_memberships, \
|
||||
users, \
|
||||
tenants \
|
||||
@@ -625,13 +730,14 @@ fn truncate_all(conn: &mut PgConnection) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn hash_password(password: &str) -> Result<String> {
|
||||
use argon2::password_hash::{PasswordHasher, SaltString};
|
||||
use argon2::Argon2;
|
||||
fn generate_refresh_token() -> String {
|
||||
let mut bytes = [0u8; 32];
|
||||
OsRng.fill_bytes(&mut bytes);
|
||||
hex::encode(bytes)
|
||||
}
|
||||
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
Ok(Argon2::default()
|
||||
.hash_password(password.as_bytes(), &salt)
|
||||
.map_err(|err| anyhow!("failed to hash password: {err}"))?
|
||||
.to_string())
|
||||
fn hash_refresh_token(value: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(value.as_bytes());
|
||||
hex::encode(hasher.finalize())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
mod common;
|
||||
|
||||
use anyhow::Result;
|
||||
use common::{acquire_db_lock, body_to_vec, TestApp};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DocumentDetail {
|
||||
document: DocumentSummary,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DocumentSummary {
|
||||
id: Uuid,
|
||||
title: String,
|
||||
#[serde(default)]
|
||||
correspondents: Vec<DocumentCorrespondentSummary>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DocumentCorrespondentSummary {
|
||||
id: Uuid,
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CorrespondentSummary {
|
||||
id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct BulkCorrespondentResult {
|
||||
assigned: usize,
|
||||
removed: usize,
|
||||
}
|
||||
|
||||
struct TestContext {
|
||||
app: TestApp,
|
||||
token: String,
|
||||
document_ids: Vec<Uuid>,
|
||||
sender_id: Uuid,
|
||||
receiver_id: Uuid,
|
||||
}
|
||||
|
||||
impl TestContext {
|
||||
const SENDER_NAME: &'static str = "Acme Corp";
|
||||
const RECEIVER_NAME: &'static str = "Bank Ltd";
|
||||
|
||||
async fn new(prefix: &str) -> Result<Self> {
|
||||
let app = TestApp::new().await?;
|
||||
let username = format!("{prefix}_user");
|
||||
let password = format!("{prefix}_pw");
|
||||
app.insert_user(&username, &password, "admin").await?;
|
||||
let token = app.login_token(&username, &password).await?;
|
||||
|
||||
let first_id =
|
||||
upload_document(&app, &token, &format!("{prefix}-one.txt"), b"letter one").await?;
|
||||
let second_id =
|
||||
upload_document(&app, &token, &format!("{prefix}-two.txt"), b"letter two").await?;
|
||||
let sender_id = create_correspondent(&app, &token, Self::SENDER_NAME).await?;
|
||||
let receiver_id = create_correspondent(&app, &token, Self::RECEIVER_NAME).await?;
|
||||
|
||||
Ok(Self {
|
||||
app,
|
||||
token,
|
||||
document_ids: vec![first_id, second_id],
|
||||
sender_id,
|
||||
receiver_id,
|
||||
})
|
||||
}
|
||||
|
||||
async fn assign(&self, correspondent_ids: &[Uuid]) -> Result<BulkCorrespondentResult> {
|
||||
self.assign_with_action(correspondent_ids, None).await
|
||||
}
|
||||
|
||||
async fn assign_with_action(
|
||||
&self,
|
||||
correspondent_ids: &[Uuid],
|
||||
action: Option<&str>,
|
||||
) -> Result<BulkCorrespondentResult> {
|
||||
let assignments: Vec<_> = correspondent_ids
|
||||
.iter()
|
||||
.map(|id| json!({ "correspondent_id": id }))
|
||||
.collect();
|
||||
|
||||
let mut payload = json!({
|
||||
"document_ids": self.document_ids,
|
||||
"assignments": assignments,
|
||||
});
|
||||
|
||||
if let Some(action) = action {
|
||||
if let Some(obj) = payload.as_object_mut() {
|
||||
obj.insert("action".to_string(), json!(action));
|
||||
}
|
||||
}
|
||||
|
||||
let response = self
|
||||
.app
|
||||
.post_json(
|
||||
"/api/documents/bulk/correspondents",
|
||||
&payload,
|
||||
Some(&self.token),
|
||||
)
|
||||
.await?;
|
||||
assert!(response.status().is_success());
|
||||
let body = body_to_vec(response.into_body()).await?;
|
||||
Ok(serde_json::from_slice(&body)?)
|
||||
}
|
||||
|
||||
async fn fetch_correspondents(
|
||||
&self,
|
||||
document_id: Uuid,
|
||||
) -> Result<Vec<DocumentCorrespondentSummary>> {
|
||||
let detail = fetch_document_detail(&self.app, &self.token, document_id).await?;
|
||||
Ok(detail.document.correspondents)
|
||||
}
|
||||
|
||||
async fn create_correspondent(&self, name: &str) -> Result<Uuid> {
|
||||
create_correspondent(&self.app, &self.token, name).await
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bulk_assign_correspondents_adds_new_links() -> Result<()> {
|
||||
let _lock = acquire_db_lock().await;
|
||||
let context = TestContext::new("corresp_add").await?;
|
||||
|
||||
let result = context
|
||||
.assign(&[context.sender_id, context.receiver_id])
|
||||
.await?;
|
||||
assert_eq!(result.assigned, 4);
|
||||
assert_eq!(result.removed, 0);
|
||||
|
||||
for doc_id in &context.document_ids {
|
||||
let correspondents = context.fetch_correspondents(*doc_id).await?;
|
||||
let names: Vec<_> = correspondents
|
||||
.iter()
|
||||
.map(|entry| entry.name.as_str())
|
||||
.collect();
|
||||
assert!(names.contains(&TestContext::SENDER_NAME));
|
||||
assert!(names.contains(&TestContext::RECEIVER_NAME));
|
||||
let ids: Vec<_> = correspondents.iter().map(|entry| entry.id).collect();
|
||||
assert!(ids.contains(&context.sender_id));
|
||||
assert!(ids.contains(&context.receiver_id));
|
||||
}
|
||||
|
||||
context.app.cleanup().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bulk_assign_correspondents_is_idempotent() -> Result<()> {
|
||||
let _lock = acquire_db_lock().await;
|
||||
let context = TestContext::new("corresp_idempotent").await?;
|
||||
|
||||
context
|
||||
.assign(&[context.sender_id, context.receiver_id])
|
||||
.await?;
|
||||
let repeat = context
|
||||
.assign(&[context.sender_id, context.receiver_id])
|
||||
.await?;
|
||||
assert_eq!(repeat.assigned, 0);
|
||||
assert_eq!(repeat.removed, 0);
|
||||
|
||||
context.app.cleanup().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bulk_remove_correspondents_detaches_links() -> Result<()> {
|
||||
let _lock = acquire_db_lock().await;
|
||||
let context = TestContext::new("corresp_remove").await?;
|
||||
|
||||
context
|
||||
.assign(&[context.sender_id, context.receiver_id])
|
||||
.await?;
|
||||
let removal = context
|
||||
.assign_with_action(&[context.sender_id], Some("remove"))
|
||||
.await?;
|
||||
assert_eq!(removal.assigned, 0);
|
||||
assert_eq!(removal.removed, 2);
|
||||
|
||||
for doc_id in &context.document_ids {
|
||||
let correspondents = context.fetch_correspondents(*doc_id).await?;
|
||||
assert_eq!(correspondents.len(), 1);
|
||||
let entry = &correspondents[0];
|
||||
assert_eq!(entry.id, context.receiver_id);
|
||||
assert_eq!(entry.name, TestContext::RECEIVER_NAME);
|
||||
}
|
||||
|
||||
context.app.cleanup().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bulk_assign_correspondents_appends_new_entries() -> Result<()> {
|
||||
let _lock = acquire_db_lock().await;
|
||||
let context = TestContext::new("corresp_append").await?;
|
||||
|
||||
context
|
||||
.assign(&[context.sender_id, context.receiver_id])
|
||||
.await?;
|
||||
context
|
||||
.assign_with_action(&[context.sender_id], Some("remove"))
|
||||
.await?;
|
||||
|
||||
let charlie_name = "Charlie";
|
||||
let charlie_id = context.create_correspondent(charlie_name).await?;
|
||||
let add_result = context.assign(&[charlie_id]).await?;
|
||||
assert_eq!(add_result.assigned, 2);
|
||||
assert_eq!(add_result.removed, 0);
|
||||
|
||||
for doc_id in &context.document_ids {
|
||||
let correspondents = context.fetch_correspondents(*doc_id).await?;
|
||||
assert_eq!(correspondents.len(), 2);
|
||||
let ids: Vec<_> = correspondents.iter().map(|entry| entry.id).collect();
|
||||
assert!(ids.contains(&context.receiver_id));
|
||||
assert!(ids.contains(&charlie_id));
|
||||
let names: Vec<_> = correspondents
|
||||
.iter()
|
||||
.map(|entry| entry.name.as_str())
|
||||
.collect();
|
||||
assert!(names.contains(&TestContext::RECEIVER_NAME));
|
||||
assert!(names.contains(&charlie_name));
|
||||
}
|
||||
|
||||
context.app.cleanup().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn upload_document(
|
||||
app: &TestApp,
|
||||
token: &str,
|
||||
filename: &str,
|
||||
contents: &[u8],
|
||||
) -> Result<Uuid> {
|
||||
let response = app
|
||||
.upload_document(
|
||||
"/api/documents",
|
||||
filename,
|
||||
"text/plain",
|
||||
contents,
|
||||
None,
|
||||
token,
|
||||
)
|
||||
.await?;
|
||||
assert!(response.status().is_success());
|
||||
let body = body_to_vec(response.into_body()).await?;
|
||||
let detail: DocumentDetail = serde_json::from_slice(&body)?;
|
||||
Ok(detail.document.id)
|
||||
}
|
||||
|
||||
async fn create_correspondent(app: &TestApp, token: &str, name: &str) -> Result<Uuid> {
|
||||
let response = app
|
||||
.post_json("/api/correspondents", &json!({ "name": name }), Some(token))
|
||||
.await?;
|
||||
assert!(response.status().is_success());
|
||||
let body = body_to_vec(response.into_body()).await?;
|
||||
let summary: CorrespondentSummary = serde_json::from_slice(&body)?;
|
||||
Ok(summary.id)
|
||||
}
|
||||
|
||||
async fn fetch_document_detail(
|
||||
app: &TestApp,
|
||||
token: &str,
|
||||
document_id: Uuid,
|
||||
) -> Result<DocumentDetail> {
|
||||
let response = app
|
||||
.get(&format!("/api/documents/{document_id}"), Some(token))
|
||||
.await?;
|
||||
assert!(response.status().is_success());
|
||||
let body = body_to_vec(response.into_body()).await?;
|
||||
Ok(serde_json::from_slice(&body)?)
|
||||
}
|
||||
+973
-285
File diff suppressed because it is too large
Load Diff
@@ -73,7 +73,7 @@ async fn folder_move_and_delete_flow() -> Result<()> {
|
||||
Some(&token),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(folder_resp.status(), StatusCode::OK);
|
||||
assert_eq!(folder_resp.status(), StatusCode::CREATED);
|
||||
let folder_body = body_to_vec(folder_resp.into_body()).await?;
|
||||
let folder: FolderResponse = serde_json::from_slice(&folder_body)?;
|
||||
|
||||
@@ -164,7 +164,7 @@ async fn update_folder_parent_to_root() -> Result<()> {
|
||||
Some(&token),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(parent_resp.status(), StatusCode::OK);
|
||||
assert_eq!(parent_resp.status(), StatusCode::CREATED);
|
||||
let parent_body = body_to_vec(parent_resp.into_body()).await?;
|
||||
let parent: FolderResponse = serde_json::from_slice(&parent_body)?;
|
||||
|
||||
@@ -179,7 +179,7 @@ async fn update_folder_parent_to_root() -> Result<()> {
|
||||
Some(&token),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(child_resp.status(), StatusCode::OK);
|
||||
assert_eq!(child_resp.status(), StatusCode::CREATED);
|
||||
let child_body = body_to_vec(child_resp.into_body()).await?;
|
||||
let child: FolderResponse = serde_json::from_slice(&child_body)?;
|
||||
|
||||
@@ -232,14 +232,14 @@ async fn ensure_path_creates_nested_folders() -> Result<()> {
|
||||
let first_resp = app
|
||||
.post_json("/api/folders/path", &base_path, Some(&token))
|
||||
.await?;
|
||||
assert_eq!(first_resp.status(), StatusCode::OK);
|
||||
assert!(first_resp.status().is_success());
|
||||
let first_body = body_to_vec(first_resp.into_body()).await?;
|
||||
let first_folder: FolderResponse = serde_json::from_slice(&first_body)?;
|
||||
|
||||
let second_resp = app
|
||||
.post_json("/api/folders/path", &base_path, Some(&token))
|
||||
.await?;
|
||||
assert_eq!(second_resp.status(), StatusCode::OK);
|
||||
assert!(second_resp.status().is_success());
|
||||
let second_body = body_to_vec(second_resp.into_body()).await?;
|
||||
let second_folder: FolderResponse = serde_json::from_slice(&second_body)?;
|
||||
assert_eq!(second_folder.folder.id, first_folder.folder.id);
|
||||
@@ -293,6 +293,118 @@ async fn ensure_path_creates_nested_folders() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_folder_is_idempotent() -> Result<()> {
|
||||
let _lock = acquire_db_lock().await;
|
||||
let app = TestApp::new().await?;
|
||||
|
||||
let password = "idempotent";
|
||||
app.insert_user("folders-idem", password, "admin").await?;
|
||||
let token = app.login_token("folders-idem", password).await?;
|
||||
|
||||
let payload = CreateFolder {
|
||||
name: "Archive",
|
||||
parent_id: None,
|
||||
};
|
||||
|
||||
let first_resp = app
|
||||
.post_json("/api/folders", &payload, Some(&token))
|
||||
.await?;
|
||||
assert_eq!(first_resp.status(), StatusCode::CREATED);
|
||||
let first_body = body_to_vec(first_resp.into_body()).await?;
|
||||
let first_folder: FolderResponse = serde_json::from_slice(&first_body)?;
|
||||
|
||||
let second_resp = app
|
||||
.post_json("/api/folders", &payload, Some(&token))
|
||||
.await?;
|
||||
assert_eq!(second_resp.status(), StatusCode::OK);
|
||||
let second_body = body_to_vec(second_resp.into_body()).await?;
|
||||
let second_folder: FolderResponse = serde_json::from_slice(&second_body)?;
|
||||
|
||||
assert_eq!(first_folder.folder.id, second_folder.folder.id);
|
||||
|
||||
let root_contents = app.get("/api/folders/root/contents", Some(&token)).await?;
|
||||
let root_body = body_to_vec(root_contents.into_body()).await?;
|
||||
let root: FolderContents = serde_json::from_slice(&root_body)?;
|
||||
let occurrences = root
|
||||
.subfolders
|
||||
.iter()
|
||||
.filter(|folder| folder.id == first_folder.folder.id)
|
||||
.count();
|
||||
assert_eq!(occurrences, 1);
|
||||
|
||||
app.cleanup().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ensure_folder_path_is_idempotent() -> Result<()> {
|
||||
let _lock = acquire_db_lock().await;
|
||||
let app = TestApp::new().await?;
|
||||
|
||||
let password = "pathpass";
|
||||
app.insert_user("path-admin", password, "admin").await?;
|
||||
let token = app.login_token("path-admin", password).await?;
|
||||
|
||||
let segments = ["500 Immobilien", "501 Kreuzweg 2", "501.01 Rechtliches"];
|
||||
let payload = EnsureFolderPath {
|
||||
parent_id: None,
|
||||
segments: &segments,
|
||||
};
|
||||
|
||||
let first_resp = app
|
||||
.post_json("/api/folders/path", &payload, Some(&token))
|
||||
.await?;
|
||||
assert!(first_resp.status().is_success());
|
||||
let first_body = body_to_vec(first_resp.into_body()).await?;
|
||||
let first_folder: FolderResponse = serde_json::from_slice(&first_body)?;
|
||||
|
||||
let second_resp = app
|
||||
.post_json("/api/folders/path", &payload, Some(&token))
|
||||
.await?;
|
||||
assert!(second_resp.status().is_success());
|
||||
let second_body = body_to_vec(second_resp.into_body()).await?;
|
||||
let second_folder: FolderResponse = serde_json::from_slice(&second_body)?;
|
||||
|
||||
assert_eq!(first_folder.folder.id, second_folder.folder.id);
|
||||
|
||||
// Verify intermediate folders are not duplicated
|
||||
let root_contents = app.get("/api/folders/root/contents", Some(&token)).await?;
|
||||
let root_body = body_to_vec(root_contents.into_body()).await?;
|
||||
let root: FolderContents = serde_json::from_slice(&root_body)?;
|
||||
let root_occurrences = root
|
||||
.subfolders
|
||||
.iter()
|
||||
.filter(|folder| folder.name == segments[0])
|
||||
.count();
|
||||
assert_eq!(root_occurrences, 1);
|
||||
|
||||
let level_one = root
|
||||
.subfolders
|
||||
.iter()
|
||||
.find(|folder| folder.name == segments[0])
|
||||
.map(|folder| folder.id)
|
||||
.expect("root segment not created");
|
||||
|
||||
let level_one_contents = app
|
||||
.get(
|
||||
&format!("/api/folders/{}/contents", level_one),
|
||||
Some(&token),
|
||||
)
|
||||
.await?;
|
||||
let level_one_body = body_to_vec(level_one_contents.into_body()).await?;
|
||||
let level_one_folders: FolderContents = serde_json::from_slice(&level_one_body)?;
|
||||
let level_one_occurrences = level_one_folders
|
||||
.subfolders
|
||||
.iter()
|
||||
.filter(|folder| folder.name == segments[1])
|
||||
.count();
|
||||
assert_eq!(level_one_occurrences, 1);
|
||||
|
||||
app.cleanup().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn folder_rename_updates_name_and_child_paths() -> Result<()> {
|
||||
let _lock = acquire_db_lock().await;
|
||||
@@ -312,7 +424,7 @@ async fn folder_rename_updates_name_and_child_paths() -> Result<()> {
|
||||
Some(&token),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(parent_resp.status(), StatusCode::OK);
|
||||
assert_eq!(parent_resp.status(), StatusCode::CREATED);
|
||||
let parent_body = body_to_vec(parent_resp.into_body()).await?;
|
||||
let parent: FolderResponse = serde_json::from_slice(&parent_body)?;
|
||||
|
||||
@@ -326,7 +438,7 @@ async fn folder_rename_updates_name_and_child_paths() -> Result<()> {
|
||||
Some(&token),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(child_resp.status(), StatusCode::OK);
|
||||
assert_eq!(child_resp.status(), StatusCode::CREATED);
|
||||
let child_body = body_to_vec(child_resp.into_body()).await?;
|
||||
let child: FolderResponse = serde_json::from_slice(&child_body)?;
|
||||
|
||||
|
||||
@@ -2,11 +2,23 @@ mod common;
|
||||
|
||||
use anyhow::Result;
|
||||
use axum::http::StatusCode;
|
||||
use backend::models::{NewUser, NewUserMembership, Tag, TenantStatus};
|
||||
use backend::schema::{
|
||||
tags::dsl as tags_dsl, tenants::dsl as tenants_dsl, user_memberships::dsl as memberships_dsl,
|
||||
users::dsl as users_dsl,
|
||||
};
|
||||
use common::{acquire_db_lock, body_to_vec, TestApp};
|
||||
use diesel::prelude::*;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CreateTagPayload<'a> {
|
||||
label: &'a str,
|
||||
color: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DocumentDetail {
|
||||
document: DocumentInfo,
|
||||
@@ -61,12 +73,6 @@ async fn tag_assignment_flow() -> Result<()> {
|
||||
let upload_body = body_to_vec(upload.into_body()).await?;
|
||||
let detail: DocumentDetail = serde_json::from_slice(&upload_body)?;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CreateTagPayload<'a> {
|
||||
label: &'a str,
|
||||
color: Option<&'a str>,
|
||||
}
|
||||
|
||||
let create_tag = app
|
||||
.post_json(
|
||||
"/api/tags",
|
||||
@@ -172,3 +178,89 @@ async fn tag_assignment_flow() -> Result<()> {
|
||||
app.cleanup().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tags_are_isolated_between_tenants() -> Result<()> {
|
||||
let _lock = acquire_db_lock().await;
|
||||
let app = TestApp::new().await?;
|
||||
|
||||
let password_a = "tenant-a";
|
||||
app.insert_user("alice", password_a, "admin").await?;
|
||||
let token_a = app.login_token("alice", password_a).await?;
|
||||
|
||||
let shared_label = "Shared Label";
|
||||
|
||||
let create_a = app
|
||||
.post_json(
|
||||
"/api/tags",
|
||||
&CreateTagPayload {
|
||||
label: shared_label,
|
||||
color: Some("#123456"),
|
||||
},
|
||||
Some(&token_a),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(create_a.status(), StatusCode::OK);
|
||||
|
||||
let tenant_b_id = Uuid::new_v4();
|
||||
let user_b_id = Uuid::new_v4();
|
||||
app.with_conn(move |conn| {
|
||||
let storage_root = format!("test-tenants/{tenant_b_id}/");
|
||||
diesel::insert_into(tenants_dsl::tenants)
|
||||
.values((
|
||||
tenants_dsl::id.eq(tenant_b_id),
|
||||
tenants_dsl::name.eq("tenant-b"),
|
||||
tenants_dsl::storage_root.eq(Some(storage_root)),
|
||||
tenants_dsl::status.eq(TenantStatus::Active),
|
||||
))
|
||||
.execute(conn)?;
|
||||
|
||||
let new_user = NewUser {
|
||||
id: user_b_id,
|
||||
username: "bob".to_string(),
|
||||
};
|
||||
diesel::insert_into(users_dsl::users)
|
||||
.values(&new_user)
|
||||
.execute(conn)?;
|
||||
|
||||
let membership = NewUserMembership {
|
||||
id: Uuid::new_v4(),
|
||||
user_id: user_b_id,
|
||||
tenant_id: tenant_b_id,
|
||||
};
|
||||
diesel::insert_into(memberships_dsl::user_memberships)
|
||||
.values(&membership)
|
||||
.execute(conn)?;
|
||||
|
||||
Ok::<_, anyhow::Error>(())
|
||||
})
|
||||
.await?;
|
||||
|
||||
let token_b = app.login_token("bob", "").await?;
|
||||
|
||||
let create_b = app
|
||||
.post_json(
|
||||
"/api/tags",
|
||||
&CreateTagPayload {
|
||||
label: shared_label,
|
||||
color: Some("#654321"),
|
||||
},
|
||||
Some(&token_b),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(create_b.status(), StatusCode::OK);
|
||||
|
||||
app.with_conn(move |conn| {
|
||||
let tags: Vec<Tag> = tags_dsl::tags
|
||||
.filter(tags_dsl::label.eq(shared_label))
|
||||
.order(tags_dsl::tenant_id.asc())
|
||||
.load(conn)?;
|
||||
|
||||
assert_eq!(tags.len(), 2);
|
||||
assert_ne!(tags[0].tenant_id, tags[1].tenant_id);
|
||||
Ok::<_, anyhow::Error>(())
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
mod common;
|
||||
|
||||
use anyhow::Result;
|
||||
use axum::body::Body;
|
||||
use axum::http::{header, Method, Request, StatusCode};
|
||||
use backend::models::WebdavToken;
|
||||
use backend::routes::webdav;
|
||||
use backend::schema::webdav_tokens;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||
use base64::Engine;
|
||||
use common::{acquire_db_lock, body_to_vec, TestApp};
|
||||
use diesel::prelude::*;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use tower::ServiceExt;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TokenInfo {
|
||||
id: Uuid,
|
||||
label: Option<String>,
|
||||
last_used_at: Option<String>,
|
||||
revoked_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CreateTokenResponse {
|
||||
token: String,
|
||||
#[serde(rename = "token_info")]
|
||||
info: TokenInfo,
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn webdav_token_api_crud() -> Result<()> {
|
||||
let _guard = acquire_db_lock().await;
|
||||
let app = TestApp::new().await?;
|
||||
|
||||
let username = "alice";
|
||||
let password = "correct horse battery";
|
||||
app.insert_user(username, password, "admin").await?;
|
||||
let access_token = app.login_token(username, password).await?;
|
||||
|
||||
let create_response = app
|
||||
.post_json(
|
||||
"/api/profile/webdav-tokens",
|
||||
&json!({ "label": "dav" }),
|
||||
Some(&access_token),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(create_response.status(), StatusCode::CREATED);
|
||||
let create_body = body_to_vec(create_response.into_body()).await?;
|
||||
let created: CreateTokenResponse = serde_json::from_slice(&create_body)?;
|
||||
let token_id = created.info.id;
|
||||
assert_eq!(created.info.label.as_deref(), Some("dav"));
|
||||
assert!(created.info.last_used_at.is_none());
|
||||
|
||||
let list_response = app
|
||||
.get("/api/profile/webdav-tokens", Some(&access_token))
|
||||
.await?;
|
||||
assert_eq!(list_response.status(), StatusCode::OK);
|
||||
let list_body = body_to_vec(list_response.into_body()).await?;
|
||||
let listed: Vec<TokenInfo> = serde_json::from_slice(&list_body)?;
|
||||
assert_eq!(listed.len(), 1);
|
||||
assert_eq!(listed[0].id, token_id);
|
||||
|
||||
let delete_response = app
|
||||
.delete(
|
||||
&format!("/api/profile/webdav-tokens/{token_id}"),
|
||||
Some(&access_token),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(delete_response.status(), StatusCode::NO_CONTENT);
|
||||
|
||||
let list_after = app
|
||||
.get("/api/profile/webdav-tokens", Some(&access_token))
|
||||
.await?;
|
||||
let list_after_body = body_to_vec(list_after.into_body()).await?;
|
||||
let listed_after: Vec<TokenInfo> = serde_json::from_slice(&list_after_body)?;
|
||||
assert_eq!(listed_after.len(), 1);
|
||||
assert_eq!(listed_after[0].id, token_id);
|
||||
assert!(listed_after[0].revoked_at.is_some());
|
||||
|
||||
app.cleanup().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn webdav_basic_auth_uses_tokens() -> Result<()> {
|
||||
let _guard = acquire_db_lock().await;
|
||||
let app = TestApp::new().await?;
|
||||
|
||||
let username = "bruce";
|
||||
let password = "wayne";
|
||||
app.insert_user(username, password, "admin").await?;
|
||||
let access_token = app.login_token(username, password).await?;
|
||||
|
||||
let create_response = app
|
||||
.post_json(
|
||||
"/api/profile/webdav-tokens",
|
||||
&json!({ "label": "webdav" }),
|
||||
Some(&access_token),
|
||||
)
|
||||
.await?;
|
||||
let create_body = body_to_vec(create_response.into_body()).await?;
|
||||
let created: CreateTokenResponse = serde_json::from_slice(&create_body)?;
|
||||
let token_id = created.info.id;
|
||||
|
||||
let router = webdav::create_router().with_state(app.state.clone());
|
||||
let auth_header = format!(
|
||||
"Basic {}",
|
||||
BASE64.encode(format!("{}:{}", username, created.token))
|
||||
);
|
||||
|
||||
let propfind = Method::from_bytes(b"PROPFIND")?;
|
||||
let success_request = Request::builder()
|
||||
.method(propfind.clone())
|
||||
.uri("/")
|
||||
.header(header::AUTHORIZATION, auth_header.clone())
|
||||
.header("depth", "0")
|
||||
.body(Body::empty())?;
|
||||
let response = router.clone().oneshot(success_request).await?;
|
||||
assert_eq!(response.status(), StatusCode::MULTI_STATUS);
|
||||
|
||||
let used = app
|
||||
.with_conn(move |conn| {
|
||||
let record = webdav_tokens::table
|
||||
.find(token_id)
|
||||
.first::<WebdavToken>(conn)?;
|
||||
Ok::<_, anyhow::Error>(record.last_used_at)
|
||||
})
|
||||
.await?;
|
||||
assert!(used.is_some());
|
||||
|
||||
let delete_response = app
|
||||
.delete(
|
||||
&format!("/api/profile/webdav-tokens/{token_id}"),
|
||||
Some(&access_token),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(delete_response.status(), StatusCode::NO_CONTENT);
|
||||
|
||||
let failure_request = Request::builder()
|
||||
.method(propfind)
|
||||
.uri("/")
|
||||
.header(header::AUTHORIZATION, auth_header)
|
||||
.header("depth", "0")
|
||||
.body(Body::empty())?;
|
||||
let response = router.oneshot(failure_request).await?;
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
app.cleanup().await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -45,7 +45,6 @@ services:
|
||||
S3_BUCKET: documents
|
||||
JWT_SECRET: change-me-super-secret
|
||||
QUICKWIT_ENDPOINT: http://quickwit-test:7280
|
||||
DEFAULT_TENANT_SLUG: admin
|
||||
entrypoint: []
|
||||
command: >
|
||||
/bin/sh -c "
|
||||
@@ -57,12 +56,17 @@ services:
|
||||
else
|
||||
papercrate-admin create-tenant admin;
|
||||
fi &&
|
||||
ADMIN_TENANT_ID=$(papercrate-admin list-tenants | awk '/^admin / {print $2; exit}') &&
|
||||
if [ -z "$ADMIN_TENANT_ID" ]; then
|
||||
echo 'failed to resolve admin tenant id' >&2;
|
||||
exit 1;
|
||||
fi &&
|
||||
echo 'Ensuring demo user credentials' &&
|
||||
(papercrate-admin create-user admin adminadmin || papercrate-admin set-password admin adminadmin) &&
|
||||
echo 'Ensuring demo membership' &&
|
||||
papercrate-admin add-user-to-tenant admin admin admin &&
|
||||
papercrate-admin add-user-to-tenant admin "$ADMIN_TENANT_ID" &&
|
||||
echo 'Ensuring Quickwit index for admin tenant' &&
|
||||
papercrate-admin quickwit-create-index admin
|
||||
papercrate-admin quickwit-create-index "$ADMIN_TENANT_ID"
|
||||
"
|
||||
user: root
|
||||
restart: "no"
|
||||
|
||||
+7
-3
@@ -80,7 +80,6 @@ services:
|
||||
S3_BUCKET: documents
|
||||
JWT_SECRET: change-me-super-secret
|
||||
QUICKWIT_ENDPOINT: http://quickwit:7280
|
||||
DEFAULT_TENANT_SLUG: admin
|
||||
entrypoint: []
|
||||
command: >
|
||||
/bin/sh -c "
|
||||
@@ -92,12 +91,17 @@ services:
|
||||
else
|
||||
papercrate-admin create-tenant admin;
|
||||
fi &&
|
||||
ADMIN_TENANT_ID=$(papercrate-admin list-tenants | awk '/^admin / {print $2; exit}') &&
|
||||
if [ -z "$ADMIN_TENANT_ID" ]; then
|
||||
echo 'failed to resolve admin tenant id' >&2;
|
||||
exit 1;
|
||||
fi &&
|
||||
echo 'Ensuring demo user credentials' &&
|
||||
(papercrate-admin create-user admin adminadmin || papercrate-admin set-password admin adminadmin) &&
|
||||
echo 'Ensuring demo membership' &&
|
||||
papercrate-admin add-user-to-tenant admin admin admin &&
|
||||
papercrate-admin add-user-to-tenant admin "$ADMIN_TENANT_ID" &&
|
||||
echo 'Ensuring Quickwit index for admin tenant' &&
|
||||
papercrate-admin quickwit-create-index admin
|
||||
papercrate-admin quickwit-create-index "$ADMIN_TENANT_ID"
|
||||
"
|
||||
user: root
|
||||
restart: "no"
|
||||
|
||||
+11
-9
@@ -5,8 +5,8 @@ Unless noted otherwise, endpoints below require a valid `Authorization: Bearer <
|
||||
|
||||
Authentication
|
||||
--------------
|
||||
- POST /api/auth/login - Exchange username/password for an access token and refresh cookie (public).
|
||||
- POST /api/auth/refresh - Rotate the refresh cookie and return a new access token (public, requires refresh cookie).
|
||||
- POST /api/auth/login - Exchange username/password for an access token and refresh cookie (public). Returns the active tenant as `{ tenant: { id, name } }`. When multiple tenants are available, the response contains an `access_token` (tenant-selector token) and tenant list instead.
|
||||
- POST /api/auth/refresh - Rotate the refresh cookie and return a new access token (public, requires refresh cookie). Response also includes the current tenant `{ tenant: { id, name } }`.
|
||||
- POST /api/auth/logout - Revoke the caller's refresh tokens and clear the cookie.
|
||||
- GET /api/auth/me - Return the authenticated principal payload.
|
||||
|
||||
@@ -16,22 +16,24 @@ Health
|
||||
|
||||
Documents
|
||||
---------
|
||||
- GET /api/documents - List or search documents. Optional filters: `folder_id` (defaults to root when omitted), `include_deleted`, `include_descendants` (defaults to true when a `folder_id` is provided and no other override is supplied), `query` (Quickwit full-text), `tags` (comma-separated tag UUIDs), and `correspondents` (comma-separated correspondent UUIDs). Each entry includes tags, correspondent assignments, and current version info.
|
||||
- GET /api/documents - List or search documents. Optional filters: `folder_id` (defaults to root when omitted), `include_descendants` (defaults to true unless explicitly set to `false` without filters), `status` (`active`, `deleted`, or `all`; defaults to `active`), `query` (Quickwit full-text), `tags` (comma-separated tag UUIDs), and `correspondents` (comma-separated correspondent UUIDs). Each entry includes tags, correspondent assignments, and current version info.
|
||||
- GET /api/documents/check?checksum=<sha256> - Lightweight checksum preflight. Returns `exists=false` when no document with the supplied SHA-256 checksum is present; otherwise returns `exists=true` plus the current document metadata.
|
||||
- POST /api/documents - Upload a document via multipart form-data. Required field: `file`. Optional fields: `title`, `folder_id`, JSON `metadata`, JSON array `tag_ids`, JSON array `correspondents` (each with `correspondent_id` and `role`), and `issued_at` (RFC3339). When `title` is supplied, the stored filename becomes `<title><original_extension>`. Include `skip_existing=true` to receive `204 No Content` instead of reusing a matching document.
|
||||
- POST /api/documents - Upload a document via multipart form-data. Required field: `file`. Optional fields: `title`, `folder_id`, JSON `metadata`, JSON array `tag_ids`, JSON array `correspondents` (each with `correspondent_id`), and `issued_at` (RFC3339). When `title` is supplied, the stored filename becomes `<title><original_extension>`. Include `skip_existing=true` to receive `204 No Content` instead of reusing a matching document.
|
||||
- POST /api/documents/bulk/move - Move multiple documents to a target folder.
|
||||
- POST /api/documents/bulk/tags - Add or remove tags across multiple documents.
|
||||
- POST /api/documents/bulk/correspondents - Bulk correspondent actions. Default `action=add` replaces existing assignments for the provided roles before adding the supplied correspondents; `action=remove` drops the specified correspondent/role pairs.
|
||||
- POST /api/documents/bulk/correspondents - Bulk correspondent actions. Use `action=add` (default) to attach correspondents or `action=remove` to detach the provided correspondents.
|
||||
- POST /api/documents/bulk/reanalyze - Queue re-analysis jobs for selected documents.
|
||||
- GET /api/documents/:id - Retrieve metadata and current version details for a document.
|
||||
- PATCH /api/documents/:id - Update document metadata (currently title).
|
||||
- DELETE /api/documents/:id - Soft-delete a document.
|
||||
- GET /api/documents/:id/download - Create a pre-signed download URL for the current version.
|
||||
- PATCH /api/documents/:id/folder - Move a document to another folder.
|
||||
- POST /api/documents/:id/restore - Restore a soft-deleted document. Optional body `{ "folder_id": <uuid> }` to send it to a specific folder; defaults to the original folder or root if missing.
|
||||
- GET /api/documents/:id/versions - List version history for a document.
|
||||
- GET /api/documents/:id/versions/:version_id - Fetch metadata and assets for a specific version.
|
||||
- POST /api/documents/:id/tags - Assign one or more tags to a document.
|
||||
- DELETE /api/documents/:id/tags/:tag_id - Remove a single tag from a document.
|
||||
- POST /api/documents/:id/correspondents - Assign correspondents to roles (`assignments[]` with `correspondent_id` and `role`; optional `replace=true` overwrites existing assignments for those roles). Valid roles: `sender`, `receiver`, `other`.
|
||||
- DELETE /api/documents/:id/correspondents/:correspondent_id - Remove a correspondent assignment (requires `role` query string).
|
||||
- POST /api/documents/:id/correspondents - Assign correspondents (`assignments[]` with `correspondent_id`; optional `replace=true` overwrites existing assignments).
|
||||
- DELETE /api/documents/:id/correspondents/:correspondent_id - Remove a correspondent assignment.
|
||||
|
||||
Document Assets
|
||||
---------------
|
||||
@@ -62,7 +64,7 @@ Tags
|
||||
|
||||
Correspondents
|
||||
--------------
|
||||
- GET /api/correspondents - List correspondents with usage totals and per-role counts (roles: `sender`, `receiver`, `other`).
|
||||
- GET /api/correspondents - List correspondents with usage totals.
|
||||
- POST /api/correspondents - Create a correspondent (name + optional metadata JSON).
|
||||
- PATCH /api/correspondents/:id - Update name and/or metadata.
|
||||
- DELETE /api/correspondents/:id - Remove a correspondent; fails with 400 if referenced by any document.
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
dist
|
||||
node_modules
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2021": true
|
||||
},
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:react/recommended",
|
||||
"plugin:react-hooks/recommended"
|
||||
],
|
||||
"parserOptions": {
|
||||
"ecmaFeatures": {
|
||||
"jsx": true
|
||||
},
|
||||
"ecmaVersion": "latest",
|
||||
"sourceType": "module"
|
||||
},
|
||||
"settings": {
|
||||
"react": {
|
||||
"version": "detect"
|
||||
}
|
||||
},
|
||||
"rules": {
|
||||
"no-use-before-define": [
|
||||
"error",
|
||||
{ "functions": false, "classes": true, "variables": true }
|
||||
],
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"react/prop-types": "off"
|
||||
}
|
||||
}
|
||||
+1
-2
@@ -12,10 +12,9 @@ RUN npm run build
|
||||
FROM nginx:alpine
|
||||
WORKDIR /usr/share/nginx/html
|
||||
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist ./
|
||||
|
||||
ENV API_BASE_URL=""
|
||||
ENV API_PROXY_PASS=""
|
||||
|
||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||
RUN chmod +x /docker-entrypoint.sh
|
||||
|
||||
@@ -1,11 +1,45 @@
|
||||
#!/bin/sh
|
||||
set -euo pipefail
|
||||
|
||||
API_BASE_URL_TRIMMED="${API_BASE_URL:-}"
|
||||
API_BASE_URL_TRIMMED="${API_BASE_URL_TRIMMED%%/}"
|
||||
API_PROXY_PASS_TRIMMED="${API_PROXY_PASS:-}"
|
||||
API_PROXY_PASS_TRIMMED="${API_PROXY_PASS_TRIMMED%%/}"
|
||||
|
||||
cat <<CONFIG > /usr/share/nginx/html/config.js
|
||||
window.__PAPERCRATE_API_BASE_URL = "${API_BASE_URL_TRIMMED}";
|
||||
CONFIG
|
||||
cat <<'BASE' > /etc/nginx/conf.d/default.conf
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri /index.html;
|
||||
}
|
||||
BASE
|
||||
|
||||
if [ -n "$API_PROXY_PASS_TRIMMED" ]; then
|
||||
cat <<PROXY >> /etc/nginx/conf.d/default.conf
|
||||
|
||||
location /api/ {
|
||||
proxy_pass ${API_PROXY_PASS_TRIMMED};
|
||||
proxy_set_header Host \$host;
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||
}
|
||||
|
||||
location /download/ {
|
||||
proxy_pass ${API_PROXY_PASS_TRIMMED};
|
||||
proxy_set_header Host \$host;
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||
}
|
||||
PROXY
|
||||
fi
|
||||
|
||||
cat <<'ENDCFG' >> /etc/nginx/conf.d/default.conf
|
||||
}
|
||||
ENDCFG
|
||||
|
||||
exec "$@"
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri /index.html;
|
||||
}
|
||||
}
|
||||
Generated
+2625
File diff suppressed because it is too large
Load Diff
@@ -6,9 +6,10 @@
|
||||
"scripts": {
|
||||
"dev": "webpack serve --mode development --open",
|
||||
"build": "webpack --mode production",
|
||||
"lint": "echo \"No linting configured\""
|
||||
"lint": "eslint src --ext .js,.jsx"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@tabler/icons-react": "3.11.0",
|
||||
"axios": "1.7.7",
|
||||
"react": "18.3.1",
|
||||
@@ -19,6 +20,10 @@
|
||||
"@babel/core": "7.26.0",
|
||||
"@babel/preset-env": "7.26.0",
|
||||
"@babel/preset-react": "7.26.3",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-plugin-react": "7.37.1",
|
||||
"eslint-plugin-react-hooks": "4.6.0",
|
||||
"@svgr/webpack": "8.1.0",
|
||||
"babel-loader": "9.2.1",
|
||||
"css-loader": "7.1.2",
|
||||
"dotenv": "16.4.5",
|
||||
@@ -26,7 +31,6 @@
|
||||
"style-loader": "4.0.0",
|
||||
"webpack": "5.95.0",
|
||||
"webpack-cli": "5.1.4",
|
||||
"webpack-dev-server": "5.1.0",
|
||||
"@svgr/webpack": "8.1.0"
|
||||
"webpack-dev-server": "5.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
window.__PAPERCRATE_API_BASE_URL = window.__PAPERCRATE_API_BASE_URL || '';
|
||||
@@ -1,4 +1,4 @@
|
||||
/* Skeuomorphic workspace styles */
|
||||
/* Desktop workspace styles */
|
||||
.skeuo-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -6,6 +6,18 @@
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.skeuo-item {
|
||||
position: absolute;
|
||||
display: block;
|
||||
width: auto;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
transform-origin: center center;
|
||||
transition: box-shadow 0.16s ease;
|
||||
outline: none;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.skeuo-shell {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -13,57 +25,6 @@
|
||||
grid-column: 2 / -1;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
background-color: var(--surface-subtle);
|
||||
}
|
||||
|
||||
.skeuo-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem 0.5rem;
|
||||
background-color: var(--surface-subtle);
|
||||
}
|
||||
|
||||
.skeuo-header__meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.skeuo-header__meta h2 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.skeuo-breadcrumbs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.skeuo-crumb {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.skeuo-crumb.is-current {
|
||||
color: var(--fg);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.skeuo-crumb-separator {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.skeuo-header__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.skeuo-canvas {
|
||||
@@ -84,29 +45,12 @@
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.skeuo-item {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: auto;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
transform-origin: center center;
|
||||
transition: transform 0.28s ease, box-shadow 0.16s ease;
|
||||
outline: none;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
|
||||
.skeuo-item__body {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
transition: width 0.28s ease, height 0.28s ease;
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.skeuo-item:focus-visible {
|
||||
@@ -119,13 +63,9 @@
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.skeuo-item.is-zoomed {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.skeuo-item.is-tag-target .skeuo-item__card {
|
||||
outline: 1em dashed var(--accent);
|
||||
outline-offset: 1.41em;
|
||||
outline: 0.35rem dashed var(--accent);
|
||||
outline-offset: 0.35rem;
|
||||
}
|
||||
|
||||
.skeuo-item.is-tag-pending .skeuo-item__card {
|
||||
@@ -154,50 +94,39 @@
|
||||
transition: transform 0.28s ease;
|
||||
}
|
||||
|
||||
.skeuo-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding: 0.28rem 0.7rem;
|
||||
border-radius: 1rem;
|
||||
background: linear-gradient(180deg, rgba(0, 0, 0, 0.04), rgba(0, 0, 0, 0) 70%), var(--surface);
|
||||
color: var(--fg);
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
box-shadow: 2px 2px 3px rgba(0, 0, 0, 0.18);
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.skeuo-item__tags .skeuo-tag {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.18rem 0.55rem;
|
||||
.tag-chip--draggable {
|
||||
user-select: none;
|
||||
pointer-events: auto;
|
||||
cursor: grab;
|
||||
transition: transform 0.16s ease, opacity 0.2s ease;
|
||||
transition: transform 0.16s ease, opacity 0.2s ease, box-shadow 0.2s ease;
|
||||
box-shadow: 2px 2px 4px color-mix(in oklch, black 18%, transparent);
|
||||
}
|
||||
|
||||
.skeuo-tag.is-drag-hidden {
|
||||
.tag-chip--draggable:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.tag-chip--draggable.is-drag-hidden {
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.skeuo-tag span {
|
||||
display: block;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
.skeuo-item__tags .tag-chip {
|
||||
font-size: 0.85rem;
|
||||
padding: 0.18rem 0.55rem;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
|
||||
.skeuo-card__nav {
|
||||
position: absolute;
|
||||
bottom: calc(3em * 0.707 * var(--nav-scale));
|
||||
bottom: 1.8rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) scale(calc(0.707 * var(--nav-scale, 1)));
|
||||
transform: translateX(-50%);
|
||||
transform-origin: center;
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
gap: 1.5rem;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease;
|
||||
@@ -212,19 +141,19 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 3.3em;
|
||||
height: 3.3em;
|
||||
padding: 0.36em;
|
||||
border-radius: 3.3em;
|
||||
width: 2.4em;
|
||||
height: 2.4em;
|
||||
padding: 0.25em;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: #fff;
|
||||
background: oklch(0.22 0.06 260deg);
|
||||
color: var(--on-accent);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.skeuo-card__nav-button:hover:not([disabled]) {
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
background: oklch(0.18 0.06 260deg);
|
||||
}
|
||||
|
||||
.skeuo-card__nav-button:disabled {
|
||||
@@ -242,8 +171,8 @@
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.skeuo-item__tags .skeuo-tag.is-tear-pending {
|
||||
opacity: 0.4;
|
||||
.skeuo-item__tags .tag-chip--tear-pending {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
body.skeuo-cursor-remove,
|
||||
@@ -261,15 +190,23 @@ body.skeuo-cursor-remove * {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 24px 72px rgba(0, 0, 0, 0.24);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-shadow: 0 12px 32px color-mix(in oklch, black 18%, transparent);
|
||||
overflow: hidden;
|
||||
--nav-scale: 1;
|
||||
}
|
||||
|
||||
.skeuo-item__card img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.skeuo-item__card--empty {
|
||||
background:
|
||||
radial-gradient(circle at 28% 24%, rgba(255, 255, 255, 0.32), transparent 60%),
|
||||
radial-gradient(circle at 72% 78%, rgba(0, 0, 0, 0.08), transparent 65%),
|
||||
radial-gradient(circle at 28% 24%, color-mix(in oklch, white 32%, transparent), transparent 60%),
|
||||
radial-gradient(circle at 72% 78%, color-mix(in oklch, black 8%, transparent), transparent 65%),
|
||||
linear-gradient(135deg, #e6e1d6 0%, #d2cdc2 100%);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -305,6 +242,7 @@ body.skeuo-cursor-remove * {
|
||||
color: #3b3b3b;
|
||||
max-width: 90%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
white-space: normal;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<linearGradient id="a" gradientTransform="matrix(0.45451 0 0 0.455522 -1210.292114 616.172607)" gradientUnits="userSpaceOnUse" x1="2689.251953" x2="2918.069824" y1="-1106.802979" y2="-1106.802979">
|
||||
<stop offset="0" stop-color="#62a0ea"/>
|
||||
<stop offset="0.0576991" stop-color="#afd4ff"/>
|
||||
<stop offset="0.122204" stop-color="#62a0ea"/>
|
||||
<stop offset="0.873306" stop-color="#62a0ea"/>
|
||||
<stop offset="0.955997" stop-color="#c0d5ea"/>
|
||||
<stop offset="1" stop-color="#62a0ea"/>
|
||||
<linearGradient id="folderGradient" gradientTransform="matrix(0.45451 0 0 0.455522 -1210.292114 616.172607)" gradientUnits="userSpaceOnUse" x1="2689.251953" x2="2918.069824" y1="-1106.802979" y2="-1106.802979">
|
||||
<stop offset="0" stop-color="var(--folder-icon-back, #62a0ea)"/>
|
||||
<stop offset="0.5" stop-color="var(--folder-icon-mid, #afd4ff)"/>
|
||||
<stop offset="1" stop-color="var(--folder-icon-front, #62a0ea)"/>
|
||||
</linearGradient>
|
||||
<path d="m 21.976562 12 c -5.527343 0 -9.976562 4.460938 -9.976562 10 v 86.03125 c 0 5.542969 4.449219 10 9.976562 10 h 84.042969 c 5.53125 0 9.980469 -4.457031 9.980469 -10 v -72.085938 c 0 -6.628906 -5.359375 -12 -11.972656 -12 h -46.027344 c -2.453125 0 -4.695312 -1.386718 -5.796875 -3.582031 l -1.503906 -2.992187 c -1.65625 -3.292969 -5.019531 -5.371094 -8.699219 -5.371094 z m 0 0" fill="#438de6"/>
|
||||
<path d="m 65.976562 36 c -2.746093 0 -5.226562 1.101562 -7.027343 2.890625 c -2.273438 2.253906 -5.382813 5.109375 -8.632813 5.109375 h -28.339844 c -5.527343 0 -9.976562 4.460938 -9.976562 10 v 54.03125 c 0 5.542969 4.449219 10 9.976562 10 h 84.042969 c 5.53125 0 9.980469 -4.457031 9.980469 -10 v -62.03125 c 0 -5.539062 -4.449219 -10 -9.980469 -10 z m 0 0" fill="url(#a)"/>
|
||||
<path d="m 65.976562 32 c -2.746093 0 -5.226562 1.101562 -7.027343 2.890625 c -2.273438 2.253906 -5.382813 5.109375 -8.632813 5.109375 h -28.339844 c -5.527343 0 -9.976562 4.460938 -9.976562 10 v 55.976562 c 0 5.539063 4.449219 10 9.976562 10 h 84.042969 c 5.53125 0 9.980469 -4.460937 9.980469 -10 v -63.976562 c 0 -5.539062 -4.449219 -10 -9.980469 -10 z m 0 0" fill="#a4caee"/>
|
||||
<path d="m 21.976562 12 c -5.527343 0 -9.976562 4.460938 -9.976562 10 v 86.03125 c 0 5.542969 4.449219 10 9.976562 10 h 84.042969 c 5.53125 0 9.980469 -4.457031 9.980469 -10 v -72.085938 c 0 -6.628906 -5.359375 -12 -11.972656 -12 h -46.027344 c -2.453125 0 -4.695312 -1.386718 -5.796875 -3.582031 l -1.503906 -2.992187 c -1.65625 -3.292969 -5.019531 -5.371094 -8.699219 -5.371094 z m 0 0" fill="var(--folder-icon-back, #438de6)"/>
|
||||
<path d="m 65.976562 36 c -2.746093 0 -5.226562 1.101562 -7.027343 2.890625 c -2.273438 2.253906 -5.382813 5.109375 -8.632813 5.109375 h -28.339844 c -5.527343 0 -9.976562 4.460938 -9.976562 10 v 54.03125 c 0 5.542969 4.449219 10 9.976562 10 h 84.042969 c 5.53125 0 9.980469 -4.457031 9.980469 -10 v -62.03125 c 0 -5.539062 -4.449219 -10 -9.980469 -10 z m 0 0" fill="url(#folderGradient)"/>
|
||||
<path d="m 65.976562 32 c -2.746093 0 -5.226562 1.101562 -7.027343 2.890625 c -2.273438 2.253906 -5.382813 5.109375 -8.632813 5.109375 h -28.339844 c -5.527343 0 -9.976562 4.460938 -9.976562 10 v 55.976562 c 0 5.539063 4.449219 10 9.976562 10 h 84.042969 c 5.53125 0 9.980469 -4.460937 9.980469 -10 v -63.976562 c 0 -5.539062 -4.449219 -10 -9.980469 -10 z m 0 0" fill="var(--folder-icon-front, #a4caee)"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
@@ -1,3 +0,0 @@
|
||||
export const CORRESPONDENT_ROLES = ['sender', 'receiver', 'other'];
|
||||
|
||||
export default CORRESPONDENT_ROLES;
|
||||
@@ -38,10 +38,11 @@ function CorrespondentsPanel({
|
||||
try {
|
||||
await onUpdate(editingId, { name: trimmed });
|
||||
cancelEdit();
|
||||
} catch (
|
||||
// eslint-disable-next-line no-empty
|
||||
error
|
||||
) {}
|
||||
} catch (error) {
|
||||
onNotify?.('Failed to update correspondent.', 'error');
|
||||
console.error('[correspondents] update failed', error);
|
||||
setSaving(false);
|
||||
}
|
||||
}, [editingId, draftName, onUpdate, cancelEdit, onNotify]);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
@@ -53,16 +54,14 @@ function CorrespondentsPanel({
|
||||
if (editingId === correspondent.id) {
|
||||
cancelEdit();
|
||||
}
|
||||
} catch (
|
||||
// eslint-disable-next-line no-empty
|
||||
error
|
||||
) {
|
||||
return;
|
||||
} catch (error) {
|
||||
onNotify?.('Failed to delete correspondent.', 'error');
|
||||
console.error('[correspondents] delete failed', error);
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
},
|
||||
[onDelete, editingId, cancelEdit],
|
||||
[onDelete, editingId, cancelEdit, onNotify],
|
||||
);
|
||||
|
||||
const handleCreate = useCallback(
|
||||
@@ -77,11 +76,9 @@ function CorrespondentsPanel({
|
||||
try {
|
||||
await onCreate({ name: trimmed });
|
||||
setCreateName('');
|
||||
} catch (
|
||||
// eslint-disable-next-line no-empty
|
||||
error
|
||||
) {
|
||||
return;
|
||||
} catch (error) {
|
||||
onNotify?.('Failed to create correspondent.', 'error');
|
||||
console.error('[correspondents] create failed', error);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
@@ -107,22 +104,15 @@ function CorrespondentsPanel({
|
||||
return '0';
|
||||
}
|
||||
const total = typeof usage.total === 'number' ? usage.total : 0;
|
||||
const entries = usage.by_role ? Object.entries(usage.by_role) : [];
|
||||
if (!entries.length) {
|
||||
return total.toString();
|
||||
}
|
||||
const roleSummary = entries
|
||||
.map(([role, count]) => `${role}: ${count}`)
|
||||
.join(', ');
|
||||
return `${total} (${roleSummary})`;
|
||||
return total.toString();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section className="correspondents-panel column">
|
||||
<div className="column-header">
|
||||
<div className="column-header__titles">
|
||||
<section className="correspondents-panel">
|
||||
<div className="panel-section__header">
|
||||
<div className="panel-section__titles">
|
||||
<h2>Correspondents</h2>
|
||||
<div className="column-subtitle">{correspondents.length} total</div>
|
||||
<div className="panel-section__subtitle">{correspondents.length} total</div>
|
||||
</div>
|
||||
<div className="header-actions correspondents-actions">
|
||||
<form className="correspondents-actions__form" onSubmit={handleCreate}>
|
||||
@@ -147,7 +137,7 @@ function CorrespondentsPanel({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="column-body tags-panel__body">
|
||||
<div className="panel-section__body tags-panel__body">
|
||||
{correspondents.length === 0 ? (
|
||||
<div className="empty-state">No correspondents created yet.</div>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import React, { createContext, useContext } from 'react';
|
||||
|
||||
const DesktopContext = createContext(null);
|
||||
|
||||
export const DesktopProvider = ({ value, children }) => (
|
||||
<DesktopContext.Provider value={value}>{children}</DesktopContext.Provider>
|
||||
);
|
||||
|
||||
export const useDesktopContext = () => {
|
||||
const context = useContext(DesktopContext);
|
||||
if (!context) {
|
||||
throw new Error('useDesktopContext must be used within a DesktopProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export default DesktopContext;
|
||||
@@ -0,0 +1,15 @@
|
||||
export const preventAll = (event) => {
|
||||
if (!event) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
event.preventDefault();
|
||||
} catch (error) {
|
||||
console.warn('[events] preventDefault failed', error);
|
||||
}
|
||||
try {
|
||||
event.stopPropagation();
|
||||
} catch (error) {
|
||||
console.warn('[events] stopPropagation failed', error);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
export const clamp = (value, min, max) => {
|
||||
if (value < min) return min;
|
||||
if (value > max) return max;
|
||||
return value;
|
||||
};
|
||||
|
||||
export const formatTransform = (x, y, rotation = 0, scale = 1) =>
|
||||
`translate3d(${x}px, ${y}px, 0) rotate(${rotation}deg) scale(${scale})`;
|
||||
@@ -0,0 +1,255 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useDesktopContext } from './context';
|
||||
import { preventAll } from './events';
|
||||
import { clamp, formatTransform } from './math';
|
||||
|
||||
const DRAG_HYSTERESIS_PX = 4;
|
||||
const DRAG_HYSTERESIS_SQUARED = DRAG_HYSTERESIS_PX * DRAG_HYSTERESIS_PX;
|
||||
|
||||
const useDocumentDrag = () => {
|
||||
const {
|
||||
layoutRef,
|
||||
itemRefs,
|
||||
documentLookup,
|
||||
ensureDocumentSize,
|
||||
resolveBaseMetrics,
|
||||
bringToFront,
|
||||
setDraggingId,
|
||||
syncLayoutSnapshot,
|
||||
canvasSize,
|
||||
openOverlayForDoc,
|
||||
recalcVisibleDocIds,
|
||||
settings,
|
||||
} = useDesktopContext();
|
||||
|
||||
const dragStateRef = useRef(null);
|
||||
const { canvasPadding, defaultCanvasWidth, defaultCanvasHeight, debugDrag } = settings;
|
||||
|
||||
const finishDrag = useCallback(
|
||||
(pointerId) => {
|
||||
const state = dragStateRef.current;
|
||||
if (!state || state.pointerId !== pointerId) {
|
||||
return;
|
||||
}
|
||||
const capturedTarget = state.capturedTarget;
|
||||
if (capturedTarget && typeof capturedTarget.releasePointerCapture === 'function') {
|
||||
try {
|
||||
capturedTarget.releasePointerCapture(pointerId);
|
||||
} catch (error) {
|
||||
if (debugDrag) {
|
||||
console.warn('[skeuo] releasePointerCapture failed', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
dragStateRef.current = null;
|
||||
setDraggingId((current) => (current === state.docId ? null : current));
|
||||
syncLayoutSnapshot();
|
||||
},
|
||||
[debugDrag, setDraggingId, syncLayoutSnapshot],
|
||||
);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(event, docId) => {
|
||||
if (debugDrag) {
|
||||
console.log(
|
||||
'[skeuo] handlePointerDown fired for doc',
|
||||
docId,
|
||||
'button',
|
||||
event.button,
|
||||
'pointerType',
|
||||
event.pointerType,
|
||||
'pointerId',
|
||||
event.pointerId,
|
||||
);
|
||||
}
|
||||
preventAll(event);
|
||||
const docKey = docId != null ? String(docId) : null;
|
||||
const doc = docKey ? documentLookup.get(docKey) : null;
|
||||
const { width: docWidth, height: docHeight } = ensureDocumentSize(doc);
|
||||
const { baseScale } = resolveBaseMetrics(doc, docWidth, docHeight);
|
||||
const normalizedBaseScale =
|
||||
Number.isFinite(baseScale) && baseScale > 0 ? baseScale : 1;
|
||||
const defaultCenterX = canvasPadding + docWidth / 2;
|
||||
const defaultCenterY = canvasPadding + docHeight / 2;
|
||||
bringToFront(docId);
|
||||
const entry = layoutRef.current.get(docId) || null;
|
||||
const centerX = typeof entry?.centerX === 'number' ? entry.centerX : defaultCenterX;
|
||||
const centerY = typeof entry?.centerY === 'number' ? entry.centerY : defaultCenterY;
|
||||
|
||||
if (entry && (entry.centerX !== centerX || entry.centerY !== centerY)) {
|
||||
layoutRef.current.set(docId, { ...entry, centerX, centerY });
|
||||
}
|
||||
|
||||
const capturedTarget = event.currentTarget instanceof HTMLElement ? event.currentTarget : null;
|
||||
if (capturedTarget && typeof capturedTarget.setPointerCapture === 'function') {
|
||||
try {
|
||||
capturedTarget.setPointerCapture(event.pointerId);
|
||||
} catch (error) {
|
||||
if (debugDrag) {
|
||||
console.warn('[skeuo] setPointerCapture failed', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
dragStateRef.current = {
|
||||
docId,
|
||||
pointerId: event.pointerId,
|
||||
originCenterX: centerX,
|
||||
originCenterY: centerY,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
rotation: entry?.rotation ?? 0,
|
||||
moved: false,
|
||||
locked: false,
|
||||
width: docWidth,
|
||||
height: docHeight,
|
||||
dragScale: 1,
|
||||
baseScale: normalizedBaseScale,
|
||||
capturedTarget,
|
||||
};
|
||||
setDraggingId(docId);
|
||||
},
|
||||
[
|
||||
bringToFront,
|
||||
canvasPadding,
|
||||
documentLookup,
|
||||
ensureDocumentSize,
|
||||
layoutRef,
|
||||
resolveBaseMetrics,
|
||||
setDraggingId,
|
||||
debugDrag,
|
||||
],
|
||||
);
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
(event) => {
|
||||
const state = dragStateRef.current;
|
||||
if (!state) {
|
||||
if (debugDrag) {
|
||||
console.log('[skeuo] handlePointerMove: no drag state for pointer', event.pointerId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (state.pointerId !== event.pointerId) {
|
||||
if (debugDrag) {
|
||||
console.log(
|
||||
'[skeuo] handlePointerMove: pointer mismatch expected',
|
||||
state.pointerId,
|
||||
'got',
|
||||
event.pointerId,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
preventAll(event);
|
||||
if (state.locked) {
|
||||
if (debugDrag) {
|
||||
console.log('[skeuo] handlePointerMove: locked drag for doc', state.docId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = layoutRef.current.get(state.docId);
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaX = event.clientX - state.startX;
|
||||
const deltaY = event.clientY - state.startY;
|
||||
const nextCenterX = state.originCenterX + deltaX;
|
||||
const nextCenterY = state.originCenterY + deltaY;
|
||||
|
||||
const docWidth = state.width;
|
||||
const docHeight = state.height;
|
||||
const halfWidth = docWidth / 2;
|
||||
const halfHeight = docHeight / 2;
|
||||
const canvasWidth = canvasSize.width || defaultCanvasWidth;
|
||||
const canvasHeight = canvasSize.height || defaultCanvasHeight;
|
||||
const minCenterX = canvasPadding + halfWidth;
|
||||
const maxCenterX = Math.max(minCenterX, canvasWidth - canvasPadding - halfWidth);
|
||||
const minCenterY = canvasPadding + halfHeight;
|
||||
const maxCenterY = Math.max(minCenterY, canvasHeight - canvasPadding - halfHeight);
|
||||
const clampedCenterX = clamp(nextCenterX, minCenterX, maxCenterX);
|
||||
const clampedCenterY = clamp(nextCenterY, minCenterY, maxCenterY);
|
||||
|
||||
if (!state.moved) {
|
||||
const distanceSquared = deltaX * deltaX + deltaY * deltaY;
|
||||
if (distanceSquared < DRAG_HYSTERESIS_SQUARED) {
|
||||
return;
|
||||
}
|
||||
bringToFront(state.docId);
|
||||
state.moved = true;
|
||||
}
|
||||
|
||||
const updated = { ...entry, centerX: clampedCenterX, centerY: clampedCenterY };
|
||||
layoutRef.current.set(state.docId, updated);
|
||||
|
||||
const node = itemRefs.current.get(state.docId);
|
||||
if (node) {
|
||||
node.style.transform = formatTransform(
|
||||
clampedCenterX - state.width / 2,
|
||||
clampedCenterY - state.height / 2,
|
||||
state.rotation,
|
||||
state.dragScale || 1,
|
||||
);
|
||||
}
|
||||
if (debugDrag) {
|
||||
console.log('[skeuo] handlePointerMove: moved doc', state.docId, 'to', clampedCenterX, clampedCenterY);
|
||||
}
|
||||
recalcVisibleDocIds();
|
||||
},
|
||||
[
|
||||
bringToFront,
|
||||
canvasPadding,
|
||||
canvasSize.height,
|
||||
canvasSize.width,
|
||||
defaultCanvasHeight,
|
||||
defaultCanvasWidth,
|
||||
itemRefs,
|
||||
layoutRef,
|
||||
recalcVisibleDocIds,
|
||||
debugDrag,
|
||||
],
|
||||
);
|
||||
|
||||
const handlePointerUp = useCallback(
|
||||
(event) => {
|
||||
const state = dragStateRef.current;
|
||||
if (state && state.pointerId === event.pointerId) {
|
||||
if (state.moved) {
|
||||
finishDrag(event.pointerId);
|
||||
return;
|
||||
}
|
||||
|
||||
const docId = state.docId;
|
||||
bringToFront(docId);
|
||||
const originInfo = {
|
||||
rotation: state.rotation || 0,
|
||||
scale: state.baseScale || 1,
|
||||
width: state.width,
|
||||
height: state.height,
|
||||
};
|
||||
finishDrag(event.pointerId);
|
||||
openOverlayForDoc(docId, originInfo);
|
||||
return;
|
||||
}
|
||||
finishDrag(event.pointerId);
|
||||
},
|
||||
[bringToFront, finishDrag, openOverlayForDoc],
|
||||
);
|
||||
|
||||
const handlePointerCancel = useCallback(
|
||||
(event) => {
|
||||
finishDrag(event.pointerId);
|
||||
},
|
||||
[finishDrag],
|
||||
);
|
||||
|
||||
return {
|
||||
handlePointerDown,
|
||||
handlePointerMove,
|
||||
handlePointerUp,
|
||||
handlePointerCancel,
|
||||
};
|
||||
};
|
||||
|
||||
export default useDocumentDrag;
|
||||
+491
-256
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,328 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from '../ui/icons';
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
const clamp = (value, min, max) => {
|
||||
if (value < min) return min;
|
||||
if (value > max) return max;
|
||||
return value;
|
||||
};
|
||||
|
||||
const ensureDocumentRoot = () => {
|
||||
if (typeof document === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
return document.body;
|
||||
};
|
||||
|
||||
const PreviewZoomOverlay = ({
|
||||
open = false,
|
||||
display = null,
|
||||
onClose = noop,
|
||||
}) => {
|
||||
const portalTarget = ensureDocumentRoot();
|
||||
const [isNativeScale, setIsNativeScale] = useState(false);
|
||||
const [naturalSize, setNaturalSize] = useState({ width: null, height: null });
|
||||
const [renderBackdrop, setRenderBackdrop] = useState(false);
|
||||
const [isBackdropVisible, setBackdropVisible] = useState(false);
|
||||
const [displaySnapshot, setDisplaySnapshot] = useState(null);
|
||||
const scrollRef = useRef(null);
|
||||
const imageRef = useRef(null);
|
||||
const focusRef = useRef(null);
|
||||
const previouslyFocusedRef = useRef(null);
|
||||
const visibilityTimerRef = useRef(null);
|
||||
const displayTimerRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (display?.url) {
|
||||
setDisplaySnapshot(display);
|
||||
}
|
||||
}, [display]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visibilityTimerRef.current) {
|
||||
clearTimeout(visibilityTimerRef.current);
|
||||
visibilityTimerRef.current = null;
|
||||
}
|
||||
if (displayTimerRef.current) {
|
||||
cancelAnimationFrame(displayTimerRef.current);
|
||||
displayTimerRef.current = null;
|
||||
}
|
||||
|
||||
if (open && display?.url) {
|
||||
setRenderBackdrop(true);
|
||||
displayTimerRef.current = requestAnimationFrame(() => {
|
||||
displayTimerRef.current = requestAnimationFrame(() => {
|
||||
setBackdropVisible(true);
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
if (displayTimerRef.current) {
|
||||
cancelAnimationFrame(displayTimerRef.current);
|
||||
displayTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
setBackdropVisible(false);
|
||||
visibilityTimerRef.current = setTimeout(() => {
|
||||
setRenderBackdrop(false);
|
||||
}, 260);
|
||||
|
||||
return () => {
|
||||
if (visibilityTimerRef.current) {
|
||||
clearTimeout(visibilityTimerRef.current);
|
||||
visibilityTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [open, display?.url]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (visibilityTimerRef.current) {
|
||||
clearTimeout(visibilityTimerRef.current);
|
||||
}
|
||||
if (displayTimerRef.current) {
|
||||
cancelAnimationFrame(displayTimerRef.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setIsNativeScale(false);
|
||||
setNaturalSize({ width: null, height: null });
|
||||
focusRef.current = null;
|
||||
const scrollEl = scrollRef.current;
|
||||
if (scrollEl) {
|
||||
scrollEl.scrollLeft = 0;
|
||||
scrollEl.scrollTop = 0;
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !isNativeScale) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollEl = scrollRef.current;
|
||||
const imageEl = imageRef.current;
|
||||
if (!scrollEl || !imageEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const imageWidth = imageEl.naturalWidth || imageEl.clientWidth;
|
||||
const imageHeight = imageEl.naturalHeight || imageEl.clientHeight;
|
||||
if (!(imageWidth > 0 && imageHeight > 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = focusRef.current || { xRatio: 0.5, yRatio: 0.5 };
|
||||
const maxScrollLeft = Math.max(0, imageWidth - scrollEl.clientWidth);
|
||||
const maxScrollTop = Math.max(0, imageHeight - scrollEl.clientHeight);
|
||||
|
||||
const desiredLeft = target.xRatio * imageWidth - scrollEl.clientWidth / 2;
|
||||
const desiredTop = target.yRatio * imageHeight - scrollEl.clientHeight / 2;
|
||||
|
||||
scrollEl.scrollLeft = clamp(desiredLeft, 0, maxScrollLeft);
|
||||
scrollEl.scrollTop = clamp(desiredTop, 0, maxScrollTop);
|
||||
}, [open, isNativeScale, naturalSize.width, naturalSize.height]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
if (previouslyFocusedRef.current && typeof previouslyFocusedRef.current.focus === 'function') {
|
||||
previouslyFocusedRef.current.focus();
|
||||
}
|
||||
previouslyFocusedRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
const active = document.activeElement;
|
||||
if (active && typeof active.focus === 'function') {
|
||||
previouslyFocusedRef.current = active;
|
||||
} else {
|
||||
previouslyFocusedRef.current = null;
|
||||
}
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const activeDisplay = open && display?.url ? display : displaySnapshot;
|
||||
|
||||
useEffect(() => {
|
||||
if (!renderBackdrop || !activeDisplay?.url) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const frame = requestAnimationFrame(() => {
|
||||
const scrollEl = scrollRef.current;
|
||||
if (scrollEl && typeof scrollEl.focus === 'function') {
|
||||
scrollEl.focus({ preventScroll: true });
|
||||
}
|
||||
});
|
||||
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [renderBackdrop, activeDisplay?.url]);
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
event.stopPropagation();
|
||||
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowLeft') {
|
||||
if (activeDisplay?.canGoPrev && activeDisplay?.goPrev) {
|
||||
event.preventDefault();
|
||||
activeDisplay.goPrev();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowRight') {
|
||||
if (activeDisplay?.canGoNext && activeDisplay?.goNext) {
|
||||
event.preventDefault();
|
||||
activeDisplay.goNext();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!renderBackdrop || !activeDisplay?.url || !portalTarget) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const effectiveDisplay = activeDisplay;
|
||||
const navVisible = Boolean(effectiveDisplay?.canGoPrev || effectiveDisplay?.canGoNext);
|
||||
const stageClassName = [
|
||||
'preview-zoom__stage',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
const containerClassName = [
|
||||
'preview-zoom__scroll',
|
||||
isNativeScale ? 'preview-zoom__scroll--native' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
const backdropClassName = [
|
||||
'preview-zoom-backdrop',
|
||||
isBackdropVisible ? 'preview-zoom-backdrop--visible' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
const imageStyle = isNativeScale
|
||||
? {
|
||||
cursor: 'zoom-out',
|
||||
width: naturalSize.width ? `${naturalSize.width}px` : 'auto',
|
||||
height: naturalSize.height ? `${naturalSize.height}px` : 'auto',
|
||||
maxWidth: 'none',
|
||||
maxHeight: 'none',
|
||||
}
|
||||
: {
|
||||
cursor: 'zoom-in',
|
||||
maxWidth: '95vw',
|
||||
maxHeight: '95vh',
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
(
|
||||
<div
|
||||
className={backdropClassName}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Enlarged document preview"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className={stageClassName}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<div
|
||||
className={containerClassName}
|
||||
ref={scrollRef}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<img
|
||||
src={effectiveDisplay.url}
|
||||
alt={effectiveDisplay.alt || 'Document preview'}
|
||||
className="preview-zoom__image"
|
||||
ref={imageRef}
|
||||
draggable={false}
|
||||
onLoad={(event) => {
|
||||
setNaturalSize({
|
||||
width: event.currentTarget.naturalWidth || null,
|
||||
height: event.currentTarget.naturalHeight || null,
|
||||
});
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (!isNativeScale) {
|
||||
const img = imageRef.current;
|
||||
if (img) {
|
||||
const rect = img.getBoundingClientRect();
|
||||
const xRatio = rect.width > 0 ? (event.clientX - rect.left) / rect.width : 0.5;
|
||||
const yRatio = rect.height > 0 ? (event.clientY - rect.top) / rect.height : 0.5;
|
||||
focusRef.current = {
|
||||
xRatio: clamp(xRatio, 0, 1),
|
||||
yRatio: clamp(yRatio, 0, 1),
|
||||
};
|
||||
} else {
|
||||
focusRef.current = null;
|
||||
}
|
||||
} else {
|
||||
focusRef.current = null;
|
||||
}
|
||||
setIsNativeScale((current) => !current);
|
||||
}}
|
||||
style={imageStyle}
|
||||
/>
|
||||
</div>
|
||||
{navVisible ? (
|
||||
<div className="preview-zoom__nav">
|
||||
<button
|
||||
type="button"
|
||||
className="preview-zoom__nav-button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (effectiveDisplay?.canGoPrev && effectiveDisplay?.goPrev) {
|
||||
effectiveDisplay.goPrev();
|
||||
}
|
||||
}}
|
||||
aria-label="Previous preview"
|
||||
disabled={!effectiveDisplay?.canGoPrev}
|
||||
>
|
||||
<ArrowLeftIcon />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="preview-zoom__nav-button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (effectiveDisplay?.canGoNext && effectiveDisplay?.goNext) {
|
||||
effectiveDisplay.goNext();
|
||||
}
|
||||
}}
|
||||
aria-label="Next preview"
|
||||
disabled={!effectiveDisplay?.canGoNext}
|
||||
>
|
||||
<ArrowRightIcon />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
portalTarget,
|
||||
);
|
||||
};
|
||||
|
||||
export default PreviewZoomOverlay;
|
||||
@@ -1,11 +1,21 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { getAssetFromVersion, resolveDocumentAssetUrl, createAssetView } from '../asset_manager';
|
||||
import { getTagColorStyle } from '../utils/colors';
|
||||
import { DownloadIcon, EditIcon, ViewListIcon, ViewGridIcon, FolderIcon } from '../ui/icons';
|
||||
import DetailPanel from '../detail/DetailPanel';
|
||||
import {
|
||||
DownloadIcon,
|
||||
EditIcon,
|
||||
ViewListIcon,
|
||||
ViewGridIcon,
|
||||
FolderIcon,
|
||||
TrashIcon,
|
||||
RefreshIcon,
|
||||
MinusVerticalIcon,
|
||||
ArrowUpIcon,
|
||||
} from '../ui/icons';
|
||||
|
||||
const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag'];
|
||||
const DEFAULT_GRID_ICON_SIZE = 96;
|
||||
const DEFAULT_GRID_TITLE_SIZE = '11px';
|
||||
const DEFAULT_GRID_ICON_SIZE = 144;
|
||||
const LIST_ICON_SIZE = 48;
|
||||
|
||||
const getPageCount = (doc) =>
|
||||
@@ -13,13 +23,95 @@ const getPageCount = (doc) =>
|
||||
? doc.current_version.metadata.page_count
|
||||
: null;
|
||||
|
||||
const resolveCorrespondents = (doc) => {
|
||||
if (!doc || !Array.isArray(doc.correspondents)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seen = new Set();
|
||||
const results = [];
|
||||
|
||||
doc.correspondents.forEach((entry, index) => {
|
||||
if (!entry) return;
|
||||
|
||||
const id = entry.id ?? entry.correspondent_id ?? null;
|
||||
const name = (entry.name || entry.label || entry.slug || '').trim();
|
||||
if (!name) return;
|
||||
|
||||
if (id && seen.has(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (id) {
|
||||
seen.add(id);
|
||||
}
|
||||
|
||||
results.push({
|
||||
id,
|
||||
name,
|
||||
key: id ?? `${name}-${index}`,
|
||||
});
|
||||
});
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
// Detects when an element becomes visible within a scroll container.
|
||||
const useLazyVisibility = (rootRef, resetKey) => {
|
||||
const targetRef = useRef(null);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsVisible(false);
|
||||
}, [resetKey]);
|
||||
|
||||
const rootNode = rootRef?.current || null;
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible) {
|
||||
return undefined;
|
||||
}
|
||||
const element = targetRef.current;
|
||||
if (!element) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof window === 'undefined' || typeof IntersectionObserver === 'undefined') {
|
||||
setIsVisible(true);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
setIsVisible(true);
|
||||
observer.disconnect();
|
||||
}
|
||||
});
|
||||
},
|
||||
{
|
||||
root: rootNode,
|
||||
rootMargin: '200px 0px',
|
||||
threshold: 0.01,
|
||||
},
|
||||
);
|
||||
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, [isVisible, rootNode, resetKey]);
|
||||
|
||||
return { ref: targetRef, isVisible };
|
||||
};
|
||||
|
||||
const DocumentThumbnailImage = ({
|
||||
document,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
alt,
|
||||
maxSize = LIST_ICON_SIZE,
|
||||
scrollRootRef = null,
|
||||
}) => {
|
||||
const { ref: visibilityRef, isVisible } = useLazyVisibility(scrollRootRef, document?.id);
|
||||
const resolvedMaxSize = Math.max(1, Math.round(maxSize || 1));
|
||||
const thumbnailAsset = useMemo(
|
||||
() => getAssetFromVersion(document?.current_version, 'thumbnail'),
|
||||
@@ -45,14 +137,15 @@ const DocumentThumbnailImage = ({
|
||||
() => ({ width: `${dimensions.width}px`, height: `${dimensions.height}px` }),
|
||||
[dimensions.height, dimensions.width],
|
||||
);
|
||||
const url = useMemo(
|
||||
() =>
|
||||
resolveDocumentAssetUrl(document, 'thumbnail', {
|
||||
ensureAssetUrl,
|
||||
getAsset: getDocumentAsset,
|
||||
}),
|
||||
[document, ensureAssetUrl, getDocumentAsset],
|
||||
);
|
||||
const url = useMemo(() => {
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
return resolveDocumentAssetUrl(document, 'thumbnail', {
|
||||
ensureAssetUrl,
|
||||
getAsset: getDocumentAsset,
|
||||
});
|
||||
}, [document, ensureAssetUrl, getDocumentAsset, isVisible]);
|
||||
|
||||
const pageCount = getPageCount(document);
|
||||
const showMultiPageBadge = Number.isFinite(pageCount) && pageCount > 1;
|
||||
@@ -61,14 +154,35 @@ const DocumentThumbnailImage = ({
|
||||
innerClasses.push('document-thumbnail-inner--multipage');
|
||||
}
|
||||
|
||||
const aspectRatio = useMemo(() => {
|
||||
if (Number.isFinite(assetWidth) && Number.isFinite(assetHeight) && assetWidth > 0 && assetHeight > 0) {
|
||||
return assetWidth / assetHeight;
|
||||
}
|
||||
return null;
|
||||
}, [assetWidth, assetHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
const node = visibilityRef.current;
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
if (aspectRatio) {
|
||||
node.dataset.thumbnailAspect = String(aspectRatio);
|
||||
} else {
|
||||
delete node.dataset.thumbnailAspect;
|
||||
}
|
||||
}, [aspectRatio, visibilityRef]);
|
||||
|
||||
return (
|
||||
<div className="document-thumbnail-wrapper">
|
||||
<div className="document-thumbnail-wrapper" ref={visibilityRef}>
|
||||
<div className={innerClasses.join(' ')} style={innerStyle}>
|
||||
{url ? (
|
||||
<img
|
||||
src={url}
|
||||
alt={alt || ''}
|
||||
className="document-thumbnail"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
onDragStart={(event) => event.preventDefault()}
|
||||
/>
|
||||
@@ -82,15 +196,11 @@ const DocumentThumbnailImage = ({
|
||||
|
||||
const DocumentsTable = ({
|
||||
currentFolderName,
|
||||
breadcrumbs,
|
||||
onRefresh,
|
||||
onShowSkeuoWorkspace = () => {},
|
||||
onRequestCreateFolder,
|
||||
creatingFolder = false,
|
||||
subfolders,
|
||||
documents,
|
||||
searchResults,
|
||||
isFilterActive,
|
||||
onFolderSelect,
|
||||
onFolderDrop,
|
||||
onFolderDragOver,
|
||||
@@ -104,7 +214,6 @@ const DocumentsTable = ({
|
||||
onDocumentRowClick,
|
||||
onDocumentOpen,
|
||||
selectedDocumentIds,
|
||||
focusedDocumentId,
|
||||
focusedRowKey,
|
||||
draggingDocumentIds = [],
|
||||
onDocumentDragStart,
|
||||
@@ -113,6 +222,7 @@ const DocumentsTable = ({
|
||||
onFolderRename,
|
||||
onDocumentRename,
|
||||
tagLookupById,
|
||||
activeCorrespondentIds = [],
|
||||
onDocumentListFocus,
|
||||
onDocumentListKeyDown,
|
||||
onFocusedRowChange,
|
||||
@@ -120,11 +230,13 @@ const DocumentsTable = ({
|
||||
getDocumentAsset = () => null,
|
||||
getDownloadHref,
|
||||
onTagClick,
|
||||
onCorrespondentClick,
|
||||
isSearchLoading = false,
|
||||
onDocumentTagDrop,
|
||||
viewMode = 'list',
|
||||
onViewModeChange,
|
||||
onClearSelection,
|
||||
showHeader = true,
|
||||
}) => {
|
||||
const showingSearchResults = searchResults !== null;
|
||||
const rows = showingSearchResults ? searchResults : documents;
|
||||
@@ -141,7 +253,24 @@ const DocumentsTable = ({
|
||||
() => new Set(draggingDocumentIds || []),
|
||||
[draggingDocumentIds],
|
||||
);
|
||||
const activeCorrespondentIdSet = useMemo(
|
||||
() => new Set(activeCorrespondentIds || []),
|
||||
[activeCorrespondentIds],
|
||||
);
|
||||
const scrollRef = useRef(null);
|
||||
const suppressDocumentClickRef = useRef(false);
|
||||
const [, forceVisibilityTick] = useState(0);
|
||||
const lastScrollNodeRef = useRef(null);
|
||||
const assignScrollRef = useCallback((node) => {
|
||||
if (lastScrollNodeRef.current === node) {
|
||||
return;
|
||||
}
|
||||
lastScrollNodeRef.current = node;
|
||||
scrollRef.current = node;
|
||||
if (node) {
|
||||
forceVisibilityTick((value) => value + 1);
|
||||
}
|
||||
}, []);
|
||||
const isGridView = viewMode === 'grid';
|
||||
const gridIconSize = DEFAULT_GRID_ICON_SIZE;
|
||||
const handleSetViewMode = useCallback(
|
||||
@@ -156,6 +285,11 @@ const DocumentsTable = ({
|
||||
},
|
||||
[onViewModeChange],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = 0;
|
||||
}
|
||||
}, [viewMode]);
|
||||
const isTagDragEvent = useCallback((event) => {
|
||||
const types = Array.from(event.dataTransfer?.types || []);
|
||||
return TAG_MIME_TYPES.some((type) => types.includes(type));
|
||||
@@ -255,22 +389,84 @@ const DocumentsTable = ({
|
||||
if (parsed?.id && onDocumentTagDrop) {
|
||||
onDocumentTagDrop(documentId, parsed);
|
||||
}
|
||||
} catch (
|
||||
// eslint-disable-next-line no-empty
|
||||
error
|
||||
) {}
|
||||
} catch (error) {
|
||||
console.warn('[documents] Failed to parse tag drop payload', error);
|
||||
}
|
||||
},
|
||||
[isTagDragEvent, onDocumentTagDrop],
|
||||
);
|
||||
|
||||
const handleGridBackgroundClick = useCallback(
|
||||
(event) => {
|
||||
if (event.target !== event.currentTarget) {
|
||||
const handleDocumentClick = useCallback(
|
||||
(documentId, event) => {
|
||||
if (suppressDocumentClickRef.current) {
|
||||
return;
|
||||
}
|
||||
onClearSelection?.();
|
||||
onDocumentRowClick?.(documentId, event);
|
||||
},
|
||||
[onClearSelection],
|
||||
[onDocumentRowClick],
|
||||
);
|
||||
|
||||
const handleDocumentDragStartLocal = useCallback(
|
||||
(event, doc) => {
|
||||
suppressDocumentClickRef.current = true;
|
||||
onDocumentDragStart?.(event, doc);
|
||||
},
|
||||
[onDocumentDragStart],
|
||||
);
|
||||
|
||||
const handleDocumentDragEndLocal = useCallback(
|
||||
(event) => {
|
||||
onDocumentDragEnd?.(event);
|
||||
requestAnimationFrame(() => {
|
||||
suppressDocumentClickRef.current = false;
|
||||
});
|
||||
},
|
||||
[onDocumentDragEnd],
|
||||
);
|
||||
|
||||
const renderCorrespondentLinks = useCallback(
|
||||
(correspondents) =>
|
||||
correspondents.map((correspondent, index) => {
|
||||
const isActive = correspondent.id != null && activeCorrespondentIdSet.has(correspondent.id);
|
||||
const hasClickHandler = Boolean(onCorrespondentClick) && correspondent.id != null;
|
||||
const classNames = ['doc-correspondent-link'];
|
||||
if (isActive) classNames.push('is-active');
|
||||
if (!hasClickHandler) classNames.push('is-static');
|
||||
const isLast = index === correspondents.length - 1;
|
||||
const label = isLast
|
||||
? `${correspondent.name}:${String.fromCharCode(160)}`
|
||||
: correspondent.name;
|
||||
return (
|
||||
<React.Fragment key={correspondent.key ?? correspondent.id ?? `${correspondent.name}-${index}`}>
|
||||
<button
|
||||
type="button"
|
||||
className={classNames.join(' ')}
|
||||
aria-disabled={hasClickHandler ? undefined : true}
|
||||
onClick={(event) => {
|
||||
if (!hasClickHandler) {
|
||||
return;
|
||||
}
|
||||
event.stopPropagation();
|
||||
onCorrespondentClick(correspondent.id, correspondent);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (!hasClickHandler) {
|
||||
return;
|
||||
}
|
||||
if (event.key === ' ' || event.key === 'Enter') {
|
||||
event.stopPropagation();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
{!isLast ? (
|
||||
<span className="doc-correspondent-link__separator">, </span>
|
||||
) : null}
|
||||
</React.Fragment>
|
||||
);
|
||||
}),
|
||||
[activeCorrespondentIdSet, onCorrespondentClick],
|
||||
);
|
||||
|
||||
const showDefaultEmptyState = !showingSearchResults && !subfolders.length && rows.length === 0;
|
||||
@@ -282,73 +478,46 @@ const DocumentsTable = ({
|
||||
|
||||
return (
|
||||
<section
|
||||
className={`documents-panel column documents-panel--view-${isGridView ? 'grid' : 'list'}`}
|
||||
className={`documents-panel documents-panel--view-${isGridView ? 'grid' : 'list'}`}
|
||||
>
|
||||
<div className="column-header">
|
||||
<div className="column-header__titles">
|
||||
<nav className="breadcrumb" aria-label="Folder breadcrumbs">
|
||||
{breadcrumbs.map((crumb, index) => {
|
||||
const isLast = index === breadcrumbs.length - 1;
|
||||
return (
|
||||
<span key={crumb.id} className="breadcrumb-item">
|
||||
{isLast ? (
|
||||
<span className="breadcrumb-current">{crumb.name}</span>
|
||||
) : (
|
||||
<a
|
||||
href="#"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
onFolderSelect(crumb.id);
|
||||
}}
|
||||
>
|
||||
{crumb.name}
|
||||
</a>
|
||||
)}
|
||||
{!isLast && <span className="breadcrumb-separator">›</span>}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
{showingSearchResults && (
|
||||
<div className="column-subtitle">Search results</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<div className="view-toggle" role="group" aria-label="Change view">
|
||||
<button
|
||||
type="button"
|
||||
className={`view-toggle__button${isGridView ? '' : ' active'}`}
|
||||
onClick={() => handleSetViewMode('list')}
|
||||
aria-pressed={!isGridView}
|
||||
title="List view"
|
||||
>
|
||||
<ViewListIcon className="view-toggle__icon" size={18} />
|
||||
{showHeader ? (
|
||||
<div className="panel-section__header">
|
||||
<div className="panel-section__titles">
|
||||
<h2>{currentFolderName}</h2>
|
||||
{showingSearchResults && (
|
||||
<div className="panel-section__subtitle">Search results</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<div className="view-toggle" role="group" aria-label="Change view">
|
||||
<button
|
||||
type="button"
|
||||
className={`view-toggle__button${isGridView ? '' : ' active'}`}
|
||||
onClick={() => handleSetViewMode('list')}
|
||||
aria-pressed={!isGridView}
|
||||
title="List view"
|
||||
>
|
||||
<ViewListIcon className="view-toggle__icon" size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`view-toggle__button${isGridView ? ' active' : ''}`}
|
||||
onClick={() => handleSetViewMode('grid')}
|
||||
aria-pressed={isGridView}
|
||||
title="Icons view"
|
||||
>
|
||||
<ViewGridIcon className="view-toggle__icon" size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<button className="secondary" onClick={onRefresh}>
|
||||
Refresh
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`view-toggle__button${isGridView ? ' active' : ''}`}
|
||||
onClick={() => handleSetViewMode('grid')}
|
||||
aria-pressed={isGridView}
|
||||
title="Icons view"
|
||||
>
|
||||
<ViewGridIcon className="view-toggle__icon" size={18} />
|
||||
<button className="secondary" type="button" onClick={onShowSkeuoWorkspace}>
|
||||
Desk View
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRequestCreateFolder}
|
||||
disabled={creatingFolder}
|
||||
>
|
||||
{creatingFolder ? 'Creating…' : 'New folder'}
|
||||
</button>
|
||||
<button className="secondary" onClick={onRefresh}>
|
||||
Refresh
|
||||
</button>
|
||||
<button className="secondary" type="button" onClick={onShowSkeuoWorkspace}>
|
||||
Desk View
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{showDefaultEmptyState && (
|
||||
<div className="empty-state">
|
||||
Drop files anywhere or onto a folder to upload documents.
|
||||
@@ -359,9 +528,9 @@ const DocumentsTable = ({
|
||||
No documents match the current filters.
|
||||
</div>
|
||||
)}
|
||||
<div className="column-body">
|
||||
<div className="panel-section__body">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
ref={assignScrollRef}
|
||||
className="documents-scroll"
|
||||
onFocus={(event) => {
|
||||
if (event.target === scrollRef.current) {
|
||||
@@ -376,13 +545,22 @@ const DocumentsTable = ({
|
||||
onDocumentListKeyDown(event);
|
||||
}
|
||||
}}
|
||||
onClick={(event) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
onClearSelection?.();
|
||||
}
|
||||
}}
|
||||
aria-activedescendant={isGridView ? undefined : activeDescendantId}
|
||||
>
|
||||
{showDefaultEmptyState ? null : isGridView ? (
|
||||
<div
|
||||
className="documents-grid"
|
||||
role="list"
|
||||
onClick={handleGridBackgroundClick}
|
||||
onClick={(event) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
onClearSelection?.();
|
||||
}
|
||||
}}
|
||||
style={{ '--documents-grid-icon-size': `${gridIconSize}px` }}
|
||||
>
|
||||
{!showingSearchResults &&
|
||||
@@ -449,6 +627,7 @@ const DocumentsTable = ({
|
||||
const tagList = Array.isArray(doc.tags) ? doc.tags : [];
|
||||
const visibleTags = tagList.slice(0, 3);
|
||||
const remainingTagCount = tagList.length > 3 ? tagList.length - 3 : 0;
|
||||
const correspondents = resolveCorrespondents(doc);
|
||||
const cardClasses = ['document-card', 'document'];
|
||||
if (isSelected) cardClasses.push('selected');
|
||||
if (isDraggingDoc) cardClasses.push('is-dragging');
|
||||
@@ -459,11 +638,11 @@ const DocumentsTable = ({
|
||||
role="listitem"
|
||||
id={`document-card-${doc.id}`}
|
||||
data-doc-id={doc.id}
|
||||
onClick={(event) => onDocumentRowClick(doc.id, event)}
|
||||
onClick={(event) => handleDocumentClick(doc.id, event)}
|
||||
onDoubleClick={() => onDocumentOpen(doc.id)}
|
||||
draggable
|
||||
onDragStart={(event) => onDocumentDragStart(event, doc)}
|
||||
onDragEnd={onDocumentDragEnd}
|
||||
onDragStart={(event) => handleDocumentDragStartLocal(event, doc)}
|
||||
onDragEnd={handleDocumentDragEndLocal}
|
||||
onDragOver={(event) => handleDocumentTagDragOver(event)}
|
||||
onDragOverCapture={(event) => handleDocumentTagDragOver(event)}
|
||||
onDragLeave={handleDocumentTagDragLeave}
|
||||
@@ -477,13 +656,19 @@ const DocumentsTable = ({
|
||||
getDocumentAsset={getDocumentAsset}
|
||||
alt={`Thumbnail for ${doc.title || doc.original_name}`}
|
||||
maxSize={gridIconSize}
|
||||
scrollRootRef={scrollRef}
|
||||
/>
|
||||
<div className="document-card__meta">
|
||||
<div
|
||||
className="document-card__title"
|
||||
title={doc.title || doc.original_name}
|
||||
>
|
||||
{doc.title || doc.original_name}
|
||||
{correspondents.length > 0 ? (
|
||||
<span className="doc-correspondents">
|
||||
{renderCorrespondentLinks(correspondents)}
|
||||
</span>
|
||||
) : null}
|
||||
<span>{doc.title || doc.original_name}</span>
|
||||
</div>
|
||||
{visibleTags.length > 0 && (
|
||||
<div className="document-card__tags">
|
||||
@@ -518,10 +703,9 @@ const DocumentsTable = ({
|
||||
event.dataTransfer?.setData('application/x-papercrate-tag', payload);
|
||||
event.dataTransfer?.setData('text/papercrate-tag', payload);
|
||||
event.dataTransfer?.setData('text/plain', tag.label || 'Tag');
|
||||
} catch (
|
||||
// eslint-disable-next-line no-empty
|
||||
error
|
||||
) {}
|
||||
} catch (error) {
|
||||
console.warn('[documents] Failed to configure tag drag payload', error);
|
||||
}
|
||||
}}
|
||||
onDragEnd={(event) => {
|
||||
event.stopPropagation();
|
||||
@@ -556,8 +740,7 @@ const DocumentsTable = ({
|
||||
<tr>
|
||||
<th className="thumb-column">Preview</th>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Updated</th>
|
||||
<th>Issued</th>
|
||||
<th className="actions-column">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -619,13 +802,19 @@ const DocumentsTable = ({
|
||||
<td className="doc-list__name">
|
||||
<div className="doc-list__name-content">
|
||||
<span>{folder.name}</span>
|
||||
{folder.id !== 'root' && (
|
||||
</div>
|
||||
</td>
|
||||
<td>—</td>
|
||||
<td className="actions">
|
||||
<div className="action-buttons">
|
||||
{folder.id !== 'root' && onFolderRename && (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button ghost doc-list__icon-button"
|
||||
className="icon-button"
|
||||
title="Rename"
|
||||
aria-label={`Rename folder ${folder.name}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (!onFolderRename) return;
|
||||
const nextName = window.prompt('Rename folder', folder.name || '');
|
||||
if (!nextName) {
|
||||
return;
|
||||
@@ -636,28 +825,24 @@ const DocumentsTable = ({
|
||||
}
|
||||
onFolderRename(folder.id, trimmed);
|
||||
}}
|
||||
title="Rename folder"
|
||||
aria-label={`Rename folder ${folder.name}`}
|
||||
>
|
||||
<EditIcon className="doc-list__icon" size={16} />
|
||||
<EditIcon className="icon-inline" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button danger"
|
||||
title="Delete"
|
||||
aria-label={`Delete folder ${folder.name}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onFolderDelete(folder.id);
|
||||
}}
|
||||
>
|
||||
<TrashIcon className="icon-inline" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td>Folder</td>
|
||||
<td>—</td>
|
||||
<td className="actions">
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button danger"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onFolderDelete(folder.id);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
@@ -668,6 +853,7 @@ const DocumentsTable = ({
|
||||
if (isSelected) rowClasses.push('selected');
|
||||
if (isDraggingDoc) rowClasses.push('is-dragging');
|
||||
const downloadHref = getDownloadHref?.(doc) || null;
|
||||
const correspondents = resolveCorrespondents(doc);
|
||||
|
||||
return (
|
||||
<tr
|
||||
@@ -675,11 +861,11 @@ const DocumentsTable = ({
|
||||
className={rowClasses.join(' ')}
|
||||
id={`document-row-${doc.id}`}
|
||||
data-doc-id={doc.id}
|
||||
onClick={(event) => onDocumentRowClick(doc.id, event)}
|
||||
onClick={(event) => handleDocumentClick(doc.id, event)}
|
||||
onDoubleClick={() => onDocumentOpen(doc.id)}
|
||||
draggable
|
||||
onDragStart={(event) => onDocumentDragStart(event, doc)}
|
||||
onDragEnd={onDocumentDragEnd}
|
||||
onDragStart={(event) => handleDocumentDragStartLocal(event, doc)}
|
||||
onDragEnd={handleDocumentDragEndLocal}
|
||||
onDragOver={handleDocumentTagDragOver}
|
||||
onDragLeave={handleDocumentTagDragLeave}
|
||||
onDrop={(event) => handleDocumentTagDrop(event, doc.id)}
|
||||
@@ -690,36 +876,20 @@ const DocumentsTable = ({
|
||||
ensureAssetUrl={ensureAssetUrl}
|
||||
getDocumentAsset={getDocumentAsset}
|
||||
alt={`Thumbnail for ${doc.title || doc.original_name}`}
|
||||
scrollRootRef={scrollRef}
|
||||
/>
|
||||
</td>
|
||||
<td className="doc-list__name">
|
||||
<div className="doc-name">
|
||||
<div className="doc-list__name-content">
|
||||
<span className="doc-name__title">{doc.title || doc.original_name}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button ghost doc-list__icon-button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (!onDocumentRename) return;
|
||||
const nextName = window.prompt(
|
||||
'Rename document',
|
||||
doc.title || doc.original_name || '',
|
||||
);
|
||||
if (!nextName) {
|
||||
return;
|
||||
}
|
||||
const trimmed = nextName.trim();
|
||||
if (!trimmed || trimmed === (doc.title || doc.original_name)) {
|
||||
return;
|
||||
}
|
||||
onDocumentRename(doc.id, trimmed);
|
||||
}}
|
||||
title="Rename document"
|
||||
aria-label={`Rename document ${doc.title || doc.original_name}`}
|
||||
>
|
||||
<EditIcon className="doc-list__icon" size={16} />
|
||||
</button>
|
||||
<span className="doc-name__title">
|
||||
{correspondents.length > 0 ? (
|
||||
<span className="doc-correspondents">
|
||||
{renderCorrespondentLinks(correspondents)}
|
||||
</span>
|
||||
) : null}
|
||||
<span>{doc.title || doc.original_name}</span>
|
||||
</span>
|
||||
</div>
|
||||
{(doc.tags || []).length > 0 && (
|
||||
<div className="doc-name__tags">
|
||||
@@ -743,21 +913,20 @@ const DocumentsTable = ({
|
||||
onDragStart={(event) => {
|
||||
event.stopPropagation();
|
||||
try {
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'copyMove';
|
||||
}
|
||||
const payload = JSON.stringify({
|
||||
id: tag.id,
|
||||
label: tag.label,
|
||||
sourceDocId: doc.id,
|
||||
});
|
||||
event.dataTransfer?.setData('application/x-papercrate-tag', payload);
|
||||
event.dataTransfer?.setData('text/papercrate-tag', payload);
|
||||
event.dataTransfer?.setData('text/plain', tag.label || 'Tag');
|
||||
} catch (
|
||||
// eslint-disable-next-line no-empty
|
||||
error
|
||||
) {}
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'copyMove';
|
||||
}
|
||||
const payload = JSON.stringify({
|
||||
id: tag.id,
|
||||
label: tag.label,
|
||||
sourceDocId: doc.id,
|
||||
});
|
||||
event.dataTransfer?.setData('application/x-papercrate-tag', payload);
|
||||
event.dataTransfer?.setData('text/papercrate-tag', payload);
|
||||
event.dataTransfer?.setData('text/plain', tag.label || 'Tag');
|
||||
} catch (error) {
|
||||
console.warn('[documents] Failed to configure tag drag payload', error);
|
||||
}
|
||||
}}
|
||||
onDragEnd={(event) => {
|
||||
event.stopPropagation();
|
||||
@@ -780,39 +949,74 @@ const DocumentsTable = ({
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td>{doc.content_type || 'Document'}</td>
|
||||
<td>
|
||||
{doc.updated_at
|
||||
? new Date(doc.updated_at).toLocaleString()
|
||||
: '—'}
|
||||
{(() => {
|
||||
const issuedAt = doc.issued_at || doc.updated_at || null;
|
||||
if (!issuedAt) {
|
||||
return '—';
|
||||
}
|
||||
const timestamp = Date.parse(issuedAt);
|
||||
if (Number.isNaN(timestamp)) {
|
||||
return '—';
|
||||
}
|
||||
return new Date(timestamp).toLocaleDateString();
|
||||
})()}
|
||||
</td>
|
||||
<td className="actions">
|
||||
<div className="action-buttons">
|
||||
{onDocumentRename && (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
title="Rename"
|
||||
aria-label={`Rename document ${doc.title || doc.original_name}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
const nextName = window.prompt(
|
||||
'Rename document',
|
||||
doc.title || doc.original_name || '',
|
||||
);
|
||||
if (!nextName) {
|
||||
return;
|
||||
}
|
||||
const trimmed = nextName.trim();
|
||||
if (!trimmed || trimmed === (doc.title || doc.original_name)) {
|
||||
return;
|
||||
}
|
||||
onDocumentRename(doc.id, trimmed);
|
||||
}}
|
||||
>
|
||||
<EditIcon className="icon-inline" />
|
||||
</button>
|
||||
)}
|
||||
{downloadHref ? (
|
||||
<a
|
||||
className="button-link with-icon"
|
||||
className="icon-button"
|
||||
href={downloadHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="Download"
|
||||
aria-label="Download document"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onAuxClick={(event) => event.stopPropagation()}
|
||||
onContextMenu={(event) => event.stopPropagation()}
|
||||
>
|
||||
<DownloadIcon className="icon-inline" />
|
||||
<span>Download</span>
|
||||
</a>
|
||||
) : (
|
||||
<span className="meta">No download</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="danger"
|
||||
className="icon-button danger"
|
||||
title="Delete"
|
||||
aria-label="Delete document"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDocumentDelete?.(doc.id);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
<TrashIcon className="icon-inline" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
@@ -838,3 +1042,115 @@ const DocumentsTable = ({
|
||||
|
||||
export default DocumentsTable;
|
||||
export { DocumentThumbnailImage };
|
||||
|
||||
export const createDocumentsTableHeaderActions = ({
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
onRefresh,
|
||||
onShowSkeuoWorkspace,
|
||||
}) => {
|
||||
const isGridView = viewMode === 'grid';
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="view-toggle" role="group" aria-label="Change view">
|
||||
<button
|
||||
type="button"
|
||||
className={`view-toggle__button${isGridView ? '' : ' active'}`}
|
||||
onClick={() => onViewModeChange?.('list')}
|
||||
aria-pressed={!isGridView}
|
||||
title="List view"
|
||||
>
|
||||
<ViewListIcon className="view-toggle__icon" size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`view-toggle__button${isGridView ? ' active' : ''}`}
|
||||
onClick={() => onViewModeChange?.('grid')}
|
||||
aria-pressed={isGridView}
|
||||
title="Icons view"
|
||||
>
|
||||
<ViewGridIcon className="view-toggle__icon" size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<span className="main-content__actions-divider" aria-hidden="true">
|
||||
<MinusVerticalIcon />
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={onRefresh}
|
||||
aria-label="Refresh"
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshIcon />
|
||||
</button>
|
||||
<button className="secondary" type="button" onClick={onShowSkeuoWorkspace}>
|
||||
Desk View
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const createDocumentsSurface = ({
|
||||
tableProps,
|
||||
parentBreadcrumb,
|
||||
onNavigateParent,
|
||||
renderSidebarToggle,
|
||||
detailProps,
|
||||
detailOpen = false,
|
||||
}) => {
|
||||
const {
|
||||
currentFolderName,
|
||||
searchResults,
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
onRefresh,
|
||||
onShowSkeuoWorkspace,
|
||||
} = tableProps;
|
||||
|
||||
const title = Array.isArray(searchResults) ? 'Search results' : currentFolderName;
|
||||
const subtitle = Array.isArray(searchResults)
|
||||
? `${searchResults.length} matching document${searchResults.length === 1 ? '' : 's'}`
|
||||
: null;
|
||||
|
||||
const actions = createDocumentsTableHeaderActions({
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
onRefresh,
|
||||
onShowSkeuoWorkspace,
|
||||
});
|
||||
|
||||
const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null;
|
||||
const parentControl = parentBreadcrumb
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={onNavigateParent}
|
||||
aria-label="Go to parent folder"
|
||||
title="Go to parent folder"
|
||||
>
|
||||
<ArrowUpIcon />
|
||||
</button>
|
||||
)
|
||||
: null;
|
||||
const leading = sidebarToggle || parentControl
|
||||
? (
|
||||
<>
|
||||
{sidebarToggle}
|
||||
{parentControl}
|
||||
</>
|
||||
)
|
||||
: null;
|
||||
|
||||
const detail = detailOpen && detailProps ? <DetailPanel {...detailProps} /> : null;
|
||||
|
||||
return {
|
||||
key: 'documents',
|
||||
variant: 'documents',
|
||||
header: { title, subtitle, leading, actions },
|
||||
content: <DocumentsTable {...tableProps} showHeader={false} />,
|
||||
detail,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
<title>Papercrate</title>
|
||||
</head>
|
||||
<body>
|
||||
<script src="/config.js"></script>
|
||||
<main id="app"></main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+2060
-1166
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user