Compare commits
78
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3c6a7c633 | ||
|
|
fc3d6cb826 | ||
|
|
f9e798f3b3 | ||
|
|
831ec1d989 | ||
|
|
757536c277 | ||
|
|
d003f0783d | ||
|
|
5666dc4a4b | ||
|
|
c2b0351598 | ||
|
|
a8a3d95c40 | ||
|
|
97e800bfd5 | ||
|
|
d7c65ed551 | ||
|
|
9405f07f06 | ||
|
|
41f17733a5 | ||
|
|
3019172b88 | ||
|
|
b741c181e0 | ||
|
|
e45574779b | ||
|
|
fecb7af3f6 | ||
|
|
e1e046f846 | ||
|
|
d1f0a2d48b | ||
|
|
b9057f6d56 | ||
|
|
44b59edcb3 | ||
|
|
05dffec42e | ||
|
|
aef34791f3 | ||
|
|
5b420a6b62 | ||
|
|
939f248499 | ||
|
|
a8d362ff89 | ||
|
|
068f96880b | ||
|
|
b5119a8e6f | ||
|
|
6b89913efd | ||
|
|
01c8599853 | ||
|
|
43d27cfd27 | ||
|
|
2d2e046c9d | ||
|
|
abee49a335 | ||
|
|
5b5c1c4c0b | ||
|
|
5c8cd98aac | ||
|
|
3db221cce4 | ||
|
|
60939306e7 | ||
|
|
80161fe86e | ||
|
|
4c36ea2e9a | ||
|
|
32432026db | ||
|
|
8a9f3644a1 | ||
|
|
d7f221850f | ||
|
|
4d5f7b4ccd | ||
|
|
9aa43d2753 | ||
|
|
c17a9484ca | ||
|
|
9060905e7a | ||
|
|
159577dff9 | ||
|
|
86c75cea8e | ||
|
|
3f0c607383 | ||
|
|
1cc230985f | ||
|
|
9cd69dc00c | ||
|
|
111d2cada3 | ||
|
|
a55a476d97 | ||
|
|
6724634870 | ||
|
|
797a641263 | ||
|
|
a2d7caa6e4 | ||
|
|
351d635f39 | ||
|
|
9147442822 | ||
|
|
6987fe1bfe | ||
|
|
4b74ee54b0 | ||
|
|
dc22b12df3 | ||
|
|
d9a36a99a7 | ||
|
|
f30e455c2d | ||
|
|
1b950a8f9a | ||
|
|
3a5d7607e3 | ||
|
|
7233e3a534 | ||
|
|
0a669064dd | ||
|
|
cd27f36e3d | ||
|
|
6f6abef5d2 | ||
|
|
a21853c874 | ||
|
|
6850918e3d | ||
|
|
41a9e8f76c | ||
|
|
30f3fe015b | ||
|
|
5fd4a41e64 | ||
|
|
dfadc8ba23 | ||
|
|
ddce0e39b3 | ||
|
|
4a428b9af6 | ||
|
|
768f8cb21c |
@@ -1,15 +1,5 @@
|
||||
# Papercrate
|
||||
|
||||
## Local Development
|
||||
|
||||
Use the provided `papercrate.tmux` to spin up the full stack in one tmux session:
|
||||
|
||||
```bash
|
||||
tmux -f papercrate.tmux attach
|
||||
```
|
||||
|
||||
This creates windows for the compose stack, frontend dev server, backend API, and background worker using the repository-relative paths defined in the tmux file. Detach with `Ctrl+b d` and reattach later with the same command.
|
||||
|
||||
## Backend Integration Tests
|
||||
|
||||
Integration tests require a running Postgres instance (and, optionally, Quickwit for OCR indexing). The repository includes a lightweight compose file for local runs:
|
||||
|
||||
Generated
+25
-129
@@ -67,7 +67,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -611,7 +611,6 @@ dependencies = [
|
||||
"diesel",
|
||||
"diesel_migrations",
|
||||
"dotenv",
|
||||
"envy",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"http-body-util",
|
||||
@@ -626,7 +625,6 @@ dependencies = [
|
||||
"rand 0.8.5",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde-aux",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tempfile",
|
||||
@@ -637,7 +635,6 @@ dependencies = [
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
"utoipa",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
@@ -707,7 +704,7 @@ dependencies = [
|
||||
"regex",
|
||||
"rustc-hash",
|
||||
"shlex",
|
||||
"syn 2.0.106",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -992,7 +989,7 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"syn 2.0.106",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1003,7 +1000,7 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1053,7 +1050,7 @@ dependencies = [
|
||||
"dsl_auto_type",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1073,7 +1070,7 @@ version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe2444076b48641147115697648dc743c2c00b61adade0f01ce67133c7babe8c"
|
||||
dependencies = [
|
||||
"syn 2.0.106",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1095,7 +1092,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1121,7 +1118,7 @@ dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1177,15 +1174,6 @@ 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"
|
||||
@@ -1299,7 +1287,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1824,8 +1812,6 @@ checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.16.0",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2171,15 +2157,6 @@ version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
|
||||
|
||||
[[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"
|
||||
@@ -2290,7 +2267,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2382,31 +2359,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-error"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
|
||||
dependencies = [
|
||||
"proc-macro-error-attr",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-error-attr"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"version_check",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2924,28 +2877,6 @@ 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_core"
|
||||
version = "1.0.228"
|
||||
@@ -2963,7 +2894,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3151,16 +3082,6 @@ version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "1.0.109"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.106"
|
||||
@@ -3189,7 +3110,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3231,7 +3152,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3242,7 +3163,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3338,7 +3259,7 @@ checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3488,7 +3409,7 @@ checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3593,31 +3514,6 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "utoipa"
|
||||
version = "4.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c5afb1a60e207dca502682537fefcfd9921e71d0b83e9576060f09abc6efab23"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"utoipa-gen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utoipa-gen"
|
||||
version = "4.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20c24e8ab68ff9ee746aad22d39b5535601e6416d1b0feeabf78be986a5c4392"
|
||||
dependencies = [
|
||||
"proc-macro-error",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.18.1"
|
||||
@@ -3719,7 +3615,7 @@ dependencies = [
|
||||
"log",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
"syn",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
@@ -3754,7 +3650,7 @@ checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
"syn",
|
||||
"wasm-bindgen-backend",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
@@ -3831,7 +3727,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3842,7 +3738,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4069,7 +3965,7 @@ checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
"syn",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
@@ -4090,7 +3986,7 @@ checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4110,7 +4006,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
"syn",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
@@ -4150,7 +4046,7 @@ checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -25,8 +25,6 @@ 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"
|
||||
@@ -46,8 +44,6 @@ base64 = "0.21"
|
||||
quick-xml = "0.32"
|
||||
futures-util = "0.3"
|
||||
url = "2.5"
|
||||
once_cell = "1.19"
|
||||
utoipa = { version = "4.2", default-features = false, features = ["chrono", "uuid", "preserve_order"] }
|
||||
|
||||
# Error handling
|
||||
thiserror = "1.0"
|
||||
|
||||
+1
-2
@@ -20,7 +20,7 @@ COPY migrations ./migrations
|
||||
COPY tests ./tests
|
||||
COPY diesel.toml ./
|
||||
|
||||
RUN cargo build --release --bin backend --bin worker --bin webdav --bin admin
|
||||
RUN cargo build --release --bin backend --bin worker --bin webdav
|
||||
RUN cargo install diesel_cli --no-default-features --features postgres
|
||||
|
||||
FROM debian:trixie-slim AS runtime
|
||||
@@ -53,7 +53,6 @@ RUN apt-get update \
|
||||
COPY --from=builder /app/target/release/backend /usr/local/bin/papercrate-backend
|
||||
COPY --from=builder /app/target/release/worker /usr/local/bin/papercrate-worker
|
||||
COPY --from=builder /app/target/release/webdav /usr/local/bin/papercrate-webdav
|
||||
COPY --from=builder /app/target/release/admin /usr/local/bin/papercrate-admin
|
||||
COPY --from=builder /usr/local/cargo/bin/diesel /usr/local/bin/diesel
|
||||
COPY migrations ./migrations
|
||||
COPY diesel.toml ./
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
use argon2::{
|
||||
password_hash::{PasswordHasher, SaltString},
|
||||
Argon2,
|
||||
};
|
||||
use rand::thread_rng;
|
||||
use std::env;
|
||||
|
||||
fn main() {
|
||||
let password = env::args()
|
||||
.nth(1)
|
||||
.expect("Usage: cargo run --example hash_password <password>");
|
||||
let salt = SaltString::generate(&mut thread_rng());
|
||||
let argon2 = Argon2::default();
|
||||
let hash = argon2
|
||||
.hash_password(password.as_bytes(), &salt)
|
||||
.expect("hashing failed")
|
||||
.to_string();
|
||||
println!("{}", hash);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
DROP INDEX IF EXISTS idx_document_tags_tag;
|
||||
DROP TABLE IF EXISTS document_tags;
|
||||
DROP INDEX IF EXISTS idx_document_versions_document;
|
||||
DROP TABLE IF EXISTS document_versions;
|
||||
DROP INDEX IF EXISTS idx_documents_deleted_at;
|
||||
DROP INDEX IF EXISTS idx_documents_folder;
|
||||
DROP TABLE IF EXISTS documents;
|
||||
DROP INDEX IF EXISTS idx_folders_parent;
|
||||
DROP TABLE IF EXISTS folders;
|
||||
DROP TABLE IF EXISTS tags;
|
||||
DROP TABLE IF EXISTS users;
|
||||
@@ -0,0 +1,77 @@
|
||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY,
|
||||
username VARCHAR(100) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
role VARCHAR(16) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE folders (
|
||||
id UUID PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
parent_id UUID REFERENCES folders(id) ON DELETE SET NULL,
|
||||
path_cache VARCHAR(1000),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT folders_parent_name_unique UNIQUE (parent_id, name)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_folders_parent ON folders(parent_id);
|
||||
|
||||
CREATE TABLE documents (
|
||||
id UUID PRIMARY KEY,
|
||||
filename VARCHAR(255) NOT NULL,
|
||||
original_name VARCHAR(255) NOT NULL,
|
||||
content_type VARCHAR(100),
|
||||
folder_id UUID REFERENCES folders(id) ON DELETE SET NULL,
|
||||
current_version INTEGER NOT NULL,
|
||||
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb
|
||||
);
|
||||
|
||||
CREATE INDEX idx_documents_folder ON documents(folder_id);
|
||||
CREATE INDEX idx_documents_deleted_at ON documents(deleted_at);
|
||||
|
||||
CREATE TABLE document_versions (
|
||||
id UUID PRIMARY KEY,
|
||||
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
version_number INTEGER NOT NULL,
|
||||
s3_key VARCHAR(500) NOT NULL,
|
||||
size_bytes BIGINT NOT NULL,
|
||||
checksum VARCHAR(64) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
operations_summary JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
CONSTRAINT document_versions_unique_version UNIQUE (document_id, version_number)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_document_versions_document ON document_versions(document_id);
|
||||
|
||||
CREATE TABLE tags (
|
||||
id UUID PRIMARY KEY,
|
||||
label VARCHAR(100) NOT NULL UNIQUE,
|
||||
color VARCHAR(7),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE document_tags (
|
||||
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||
assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
assigned_by UUID REFERENCES users(id),
|
||||
PRIMARY KEY (document_id, tag_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_document_tags_tag ON document_tags(tag_id);
|
||||
|
||||
INSERT INTO users (id, username, password_hash, role)
|
||||
VALUES (
|
||||
gen_random_uuid(),
|
||||
'admin',
|
||||
'$argon2id$v=19$m=19456,t=2,p=1$UMkfsNut028fmZupy9JoQg$/YFvGQoEZ2hhMiDCyv68ZROF97GcwAxxRwRgwSbpX5U',
|
||||
'admin'
|
||||
);
|
||||
@@ -0,0 +1,5 @@
|
||||
DROP TRIGGER IF EXISTS trg_jobs_updated_at ON jobs;
|
||||
DROP FUNCTION IF EXISTS touch_jobs_updated_at;
|
||||
DROP INDEX IF EXISTS idx_jobs_job_type;
|
||||
DROP INDEX IF EXISTS idx_jobs_status_run_after;
|
||||
DROP TABLE IF EXISTS jobs;
|
||||
@@ -0,0 +1,28 @@
|
||||
CREATE TABLE jobs (
|
||||
id UUID PRIMARY KEY,
|
||||
job_type TEXT NOT NULL,
|
||||
payload JSONB NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
run_after TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
last_error TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT jobs_status_check CHECK (status IN ('queued', 'processing', 'succeeded', 'failed'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_jobs_status_run_after ON jobs (status, run_after);
|
||||
CREATE INDEX idx_jobs_job_type ON jobs (job_type);
|
||||
|
||||
CREATE OR REPLACE FUNCTION touch_jobs_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trg_jobs_updated_at
|
||||
BEFORE UPDATE ON jobs
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION touch_jobs_updated_at();
|
||||
@@ -0,0 +1,3 @@
|
||||
DROP INDEX IF EXISTS idx_document_assets_type;
|
||||
DROP INDEX IF EXISTS idx_document_assets_version;
|
||||
DROP TABLE IF EXISTS document_assets;
|
||||
@@ -0,0 +1,15 @@
|
||||
CREATE TABLE document_assets (
|
||||
id UUID PRIMARY KEY,
|
||||
document_version_id UUID NOT NULL REFERENCES document_versions(id) ON DELETE CASCADE,
|
||||
asset_type TEXT NOT NULL,
|
||||
s3_key TEXT NOT NULL,
|
||||
mime_type TEXT NOT NULL,
|
||||
width INTEGER,
|
||||
height INTEGER,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT document_assets_unique UNIQUE (document_version_id, asset_type)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_document_assets_version ON document_assets(document_version_id);
|
||||
CREATE INDEX idx_document_assets_type ON document_assets(asset_type);
|
||||
@@ -0,0 +1,4 @@
|
||||
DROP INDEX IF EXISTS folders_parent_name_unique_idx;
|
||||
|
||||
ALTER TABLE folders
|
||||
ADD CONSTRAINT folders_parent_name_unique UNIQUE (parent_id, name);
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE folders
|
||||
DROP CONSTRAINT IF EXISTS folders_parent_name_unique;
|
||||
|
||||
CREATE UNIQUE INDEX folders_parent_name_unique_idx
|
||||
ON folders (COALESCE(parent_id, '00000000-0000-0000-0000-000000000000'::uuid), name);
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE documents
|
||||
DROP COLUMN issued_at;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE documents
|
||||
ADD COLUMN issued_at TIMESTAMPTZ;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE documents
|
||||
DROP COLUMN name;
|
||||
@@ -0,0 +1,11 @@
|
||||
ALTER TABLE documents
|
||||
ADD COLUMN name VARCHAR(255);
|
||||
|
||||
UPDATE documents
|
||||
SET name = CASE
|
||||
WHEN filename ~ '\\.[^./]+$' THEN regexp_replace(filename, '\\.[^./]+$', '')
|
||||
ELSE filename
|
||||
END;
|
||||
|
||||
ALTER TABLE documents
|
||||
ALTER COLUMN name SET NOT NULL;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE documents
|
||||
RENAME COLUMN title TO name;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE documents
|
||||
RENAME COLUMN name TO title;
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE refresh_tokens;
|
||||
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE refresh_tokens (
|
||||
id UUID PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash TEXT NOT NULL,
|
||||
issued_at TIMESTAMPTZ NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id);
|
||||
CREATE INDEX idx_refresh_tokens_token_hash ON refresh_tokens(token_hash);
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Restore width/height columns and repopulate from metadata where available.
|
||||
|
||||
ALTER TABLE document_assets
|
||||
ADD COLUMN width INTEGER,
|
||||
ADD COLUMN height INTEGER;
|
||||
|
||||
UPDATE document_assets
|
||||
SET width = (metadata->>'width')::INTEGER
|
||||
WHERE metadata ? 'width';
|
||||
|
||||
UPDATE document_assets
|
||||
SET height = (metadata->>'height')::INTEGER
|
||||
WHERE metadata ? 'height';
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Backfill existing width/height values into metadata then drop the columns.
|
||||
|
||||
UPDATE document_assets
|
||||
SET metadata = metadata || jsonb_build_object('width', width)
|
||||
WHERE width IS NOT NULL
|
||||
AND NOT (metadata ? 'width');
|
||||
|
||||
UPDATE document_assets
|
||||
SET metadata = metadata || jsonb_build_object('height', height)
|
||||
WHERE height IS NOT NULL
|
||||
AND NOT (metadata ? 'height');
|
||||
|
||||
ALTER TABLE document_assets
|
||||
DROP COLUMN width,
|
||||
DROP COLUMN height;
|
||||
@@ -0,0 +1,17 @@
|
||||
ALTER TABLE documents ADD COLUMN current_version INT4;
|
||||
|
||||
UPDATE documents AS d
|
||||
SET current_version = dv.version_number
|
||||
FROM document_versions AS dv
|
||||
WHERE dv.id = d.current_version_id;
|
||||
|
||||
ALTER TABLE documents
|
||||
ALTER COLUMN current_version SET NOT NULL;
|
||||
|
||||
DROP INDEX IF EXISTS idx_documents_current_version_id;
|
||||
|
||||
ALTER TABLE documents
|
||||
DROP CONSTRAINT IF EXISTS documents_current_version_fk;
|
||||
|
||||
ALTER TABLE documents
|
||||
DROP COLUMN current_version_id;
|
||||
@@ -0,0 +1,21 @@
|
||||
ALTER TABLE documents ADD COLUMN current_version_id UUID;
|
||||
|
||||
UPDATE documents AS d
|
||||
SET current_version_id = dv.id
|
||||
FROM document_versions AS dv
|
||||
WHERE dv.document_id = d.id
|
||||
AND dv.version_number = d.current_version;
|
||||
|
||||
ALTER TABLE documents
|
||||
ALTER COLUMN current_version_id SET NOT NULL;
|
||||
|
||||
ALTER TABLE documents
|
||||
ADD CONSTRAINT documents_current_version_fk
|
||||
FOREIGN KEY (current_version_id)
|
||||
REFERENCES document_versions(id)
|
||||
DEFERRABLE INITIALLY DEFERRED;
|
||||
|
||||
CREATE INDEX idx_documents_current_version_id
|
||||
ON documents(current_version_id);
|
||||
|
||||
ALTER TABLE documents DROP COLUMN current_version;
|
||||
@@ -0,0 +1,5 @@
|
||||
DROP INDEX IF EXISTS idx_document_correspondents_role;
|
||||
DROP INDEX IF EXISTS idx_document_correspondents_correspondent;
|
||||
DROP INDEX IF EXISTS idx_document_correspondents_document;
|
||||
DROP TABLE IF EXISTS document_correspondents;
|
||||
DROP TABLE IF EXISTS correspondents;
|
||||
@@ -0,0 +1,27 @@
|
||||
CREATE TABLE correspondents (
|
||||
id UUID PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT correspondents_name_unique UNIQUE (name)
|
||||
);
|
||||
|
||||
CREATE TABLE document_correspondents (
|
||||
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
correspondent_id UUID NOT NULL REFERENCES correspondents(id) ON DELETE CASCADE,
|
||||
role VARCHAR(32) NOT NULL,
|
||||
assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
assigned_by UUID REFERENCES users(id),
|
||||
PRIMARY KEY (document_id, correspondent_id, role),
|
||||
CONSTRAINT document_correspondents_role_check CHECK (role IN ('sender', 'receiver', 'other'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_document_correspondents_document
|
||||
ON document_correspondents(document_id);
|
||||
|
||||
CREATE INDEX idx_document_correspondents_correspondent
|
||||
ON document_correspondents(correspondent_id);
|
||||
|
||||
CREATE INDEX idx_document_correspondents_role
|
||||
ON document_correspondents(role);
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP INDEX IF EXISTS idx_documents_folder_filename;
|
||||
DROP INDEX IF EXISTS idx_documents_folder_title;
|
||||
@@ -0,0 +1,13 @@
|
||||
CREATE INDEX idx_documents_folder_title
|
||||
ON documents (
|
||||
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
title
|
||||
)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE INDEX idx_documents_folder_filename
|
||||
ON documents (
|
||||
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
filename
|
||||
)
|
||||
WHERE deleted_at IS NULL;
|
||||
@@ -0,0 +1,16 @@
|
||||
DROP INDEX IF EXISTS documents_unique_folder_filename;
|
||||
DROP INDEX IF EXISTS idx_documents_folder_title;
|
||||
|
||||
CREATE INDEX idx_documents_folder_title
|
||||
ON documents (
|
||||
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
title
|
||||
)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE INDEX idx_documents_folder_filename
|
||||
ON documents (
|
||||
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
filename
|
||||
)
|
||||
WHERE deleted_at IS NULL;
|
||||
@@ -0,0 +1,18 @@
|
||||
DROP INDEX IF EXISTS idx_documents_folder_title;
|
||||
DROP INDEX IF EXISTS idx_documents_folder_filename;
|
||||
DROP INDEX IF EXISTS documents_unique_folder_title;
|
||||
DROP INDEX IF EXISTS documents_unique_folder_filename;
|
||||
|
||||
CREATE INDEX idx_documents_folder_title
|
||||
ON documents (
|
||||
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
title
|
||||
)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE UNIQUE INDEX documents_unique_folder_filename
|
||||
ON documents (
|
||||
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
filename
|
||||
)
|
||||
WHERE deleted_at IS NULL;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE document_versions
|
||||
DROP COLUMN metadata;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE document_versions
|
||||
ADD COLUMN metadata JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE folders
|
||||
ADD COLUMN path_cache VARCHAR(1000);
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE folders
|
||||
DROP COLUMN path_cache;
|
||||
@@ -1,21 +0,0 @@
|
||||
DROP TRIGGER IF EXISTS trg_jobs_updated_at ON jobs;
|
||||
DROP FUNCTION IF EXISTS touch_jobs_updated_at();
|
||||
|
||||
ALTER TABLE documents DROP CONSTRAINT IF EXISTS documents_current_version_fk;
|
||||
|
||||
DROP TABLE IF EXISTS document_asset_objects;
|
||||
DROP TABLE IF EXISTS document_assets;
|
||||
DROP TABLE IF EXISTS document_versions;
|
||||
DROP TABLE IF EXISTS document_tags;
|
||||
DROP TABLE IF EXISTS document_correspondents;
|
||||
DROP TABLE IF EXISTS correspondents;
|
||||
DROP TABLE IF EXISTS documents;
|
||||
DROP TABLE IF EXISTS folders;
|
||||
DROP TABLE IF EXISTS tags;
|
||||
DROP TABLE IF EXISTS jobs;
|
||||
DROP TABLE IF EXISTS refresh_tokens;
|
||||
DROP TABLE IF EXISTS user_memberships;
|
||||
DROP TABLE IF EXISTS users;
|
||||
DROP TABLE IF EXISTS tenants;
|
||||
|
||||
DROP EXTENSION IF EXISTS "pgcrypto";
|
||||
@@ -1,244 +0,0 @@
|
||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||
|
||||
CREATE TABLE tenants (
|
||||
id UUID PRIMARY KEY,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
storage_root TEXT,
|
||||
quickwit_index TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
config JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX tenants_storage_root_unique
|
||||
ON tenants (storage_root)
|
||||
WHERE storage_root IS NOT NULL;
|
||||
|
||||
CREATE UNIQUE INDEX tenants_quickwit_index_unique
|
||||
ON tenants (quickwit_index)
|
||||
WHERE quickwit_index IS NOT NULL;
|
||||
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY,
|
||||
username VARCHAR(100) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE user_memberships (
|
||||
id UUID PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (user_id, tenant_id)
|
||||
);
|
||||
|
||||
CREATE INDEX user_memberships_tenant_id_idx ON user_memberships (tenant_id);
|
||||
CREATE INDEX user_memberships_user_id_idx ON user_memberships (user_id);
|
||||
|
||||
CREATE TABLE folders (
|
||||
id UUID PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
parent_id UUID REFERENCES folders(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_folders_parent ON folders(parent_id);
|
||||
CREATE INDEX folders_tenant_id_idx ON folders(tenant_id);
|
||||
CREATE UNIQUE INDEX folders_parent_name_unique_idx
|
||||
ON folders (COALESCE(parent_id, '00000000-0000-0000-0000-000000000000'::uuid), name);
|
||||
|
||||
CREATE TABLE documents (
|
||||
id UUID PRIMARY KEY,
|
||||
filename VARCHAR(255) NOT NULL,
|
||||
original_name VARCHAR(255) NOT NULL,
|
||||
content_type VARCHAR(100),
|
||||
folder_id UUID REFERENCES folders(id) ON DELETE SET NULL,
|
||||
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
issued_at TIMESTAMPTZ,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
current_version_id UUID NOT NULL,
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_documents_folder ON documents (folder_id);
|
||||
CREATE INDEX idx_documents_deleted_at ON documents (deleted_at);
|
||||
CREATE INDEX documents_tenant_id_idx ON documents (tenant_id);
|
||||
CREATE INDEX idx_documents_current_version_id ON documents (current_version_id);
|
||||
|
||||
CREATE INDEX idx_documents_folder_title
|
||||
ON documents (
|
||||
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
title
|
||||
)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE UNIQUE INDEX documents_unique_folder_filename
|
||||
ON documents (
|
||||
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
filename
|
||||
)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE TABLE document_versions (
|
||||
id UUID PRIMARY KEY,
|
||||
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
version_number INT NOT NULL,
|
||||
s3_key VARCHAR(500) NOT NULL,
|
||||
size_bytes BIGINT NOT NULL,
|
||||
checksum VARCHAR(64) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
operations_summary JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
||||
CONSTRAINT document_versions_unique_version UNIQUE (document_id, version_number)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_document_versions_document ON document_versions (document_id);
|
||||
CREATE INDEX document_versions_tenant_id_idx ON document_versions (tenant_id);
|
||||
|
||||
ALTER TABLE documents
|
||||
ADD CONSTRAINT documents_current_version_fk
|
||||
FOREIGN KEY (current_version_id)
|
||||
REFERENCES document_versions(id)
|
||||
DEFERRABLE INITIALLY DEFERRED;
|
||||
|
||||
CREATE TABLE tags (
|
||||
id UUID PRIMARY KEY,
|
||||
label VARCHAR(100) NOT NULL UNIQUE,
|
||||
color VARCHAR(7),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id)
|
||||
);
|
||||
|
||||
CREATE INDEX tags_tenant_id_idx ON tags (tenant_id);
|
||||
|
||||
CREATE TABLE document_tags (
|
||||
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
assigned_by UUID REFERENCES users(id),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
||||
PRIMARY KEY (document_id, tag_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_document_tags_tag ON document_tags (tag_id);
|
||||
CREATE INDEX document_tags_tenant_id_idx ON document_tags (tenant_id);
|
||||
|
||||
CREATE TABLE correspondents (
|
||||
id UUID PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
||||
CONSTRAINT correspondents_name_unique UNIQUE (name)
|
||||
);
|
||||
|
||||
CREATE INDEX correspondents_tenant_id_idx ON correspondents (tenant_id);
|
||||
|
||||
CREATE TABLE document_correspondents (
|
||||
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
correspondent_id UUID NOT NULL REFERENCES correspondents(id) ON DELETE CASCADE,
|
||||
role VARCHAR(32) NOT NULL,
|
||||
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
assigned_by UUID REFERENCES users(id),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
||||
PRIMARY KEY (document_id, correspondent_id, role),
|
||||
CONSTRAINT document_correspondents_role_check CHECK (role IN ('sender', 'receiver', 'other'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_document_correspondents_document ON document_correspondents (document_id);
|
||||
CREATE INDEX idx_document_correspondents_correspondent ON document_correspondents (correspondent_id);
|
||||
CREATE INDEX idx_document_correspondents_role ON document_correspondents (role);
|
||||
CREATE INDEX document_correspondents_tenant_id_idx ON document_correspondents (tenant_id);
|
||||
|
||||
CREATE TABLE document_assets (
|
||||
id UUID PRIMARY KEY,
|
||||
document_version_id UUID NOT NULL REFERENCES document_versions(id) ON DELETE CASCADE,
|
||||
asset_type TEXT NOT NULL,
|
||||
mime_type TEXT NOT NULL,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
cardinality INTEGER,
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
||||
CONSTRAINT document_assets_unique UNIQUE (document_version_id, asset_type),
|
||||
CONSTRAINT document_assets_cardinality_positive CHECK (cardinality IS NULL OR cardinality >= 1)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_document_assets_version ON document_assets (document_version_id);
|
||||
CREATE INDEX idx_document_assets_type ON document_assets (asset_type);
|
||||
CREATE INDEX document_assets_tenant_id_idx ON document_assets (tenant_id);
|
||||
|
||||
CREATE TABLE document_asset_objects (
|
||||
id UUID PRIMARY KEY,
|
||||
asset_id UUID NOT NULL REFERENCES document_assets(id) ON DELETE CASCADE,
|
||||
ordinal INTEGER NOT NULL,
|
||||
s3_key TEXT NOT NULL,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
||||
CONSTRAINT document_asset_objects_ordinal_positive CHECK (ordinal >= 1),
|
||||
CONSTRAINT document_asset_objects_asset_ordinal_unique UNIQUE (asset_id, ordinal)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_document_asset_objects_asset_ordinal
|
||||
ON document_asset_objects (asset_id, ordinal);
|
||||
|
||||
CREATE INDEX document_asset_objects_tenant_id_idx
|
||||
ON document_asset_objects (tenant_id);
|
||||
|
||||
CREATE TABLE jobs (
|
||||
id UUID PRIMARY KEY,
|
||||
job_type TEXT NOT NULL,
|
||||
payload JSONB NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
run_after TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_error TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
||||
CONSTRAINT jobs_status_check CHECK (status IN ('queued', 'processing', 'succeeded', 'failed'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_jobs_status_run_after ON jobs (status, run_after);
|
||||
CREATE INDEX idx_jobs_job_type ON jobs (job_type);
|
||||
CREATE INDEX jobs_tenant_id_idx ON jobs (tenant_id);
|
||||
|
||||
CREATE OR REPLACE FUNCTION touch_jobs_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trg_jobs_updated_at
|
||||
BEFORE UPDATE ON jobs
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION touch_jobs_updated_at();
|
||||
|
||||
CREATE TABLE refresh_tokens (
|
||||
id UUID PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash TEXT NOT NULL,
|
||||
issued_at TIMESTAMPTZ NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens (user_id);
|
||||
CREATE INDEX idx_refresh_tokens_token_hash ON refresh_tokens (token_hash);
|
||||
CREATE INDEX refresh_tokens_tenant_id_idx ON refresh_tokens (tenant_id);
|
||||
@@ -1,6 +0,0 @@
|
||||
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
|
||||
);
|
||||
@@ -1,7 +0,0 @@
|
||||
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
|
||||
);
|
||||
@@ -1,16 +0,0 @@
|
||||
-- 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;
|
||||
@@ -1,20 +0,0 @@
|
||||
-- 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);
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE document_versions
|
||||
ADD COLUMN operations_summary JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE document_versions
|
||||
DROP COLUMN IF EXISTS operations_summary;
|
||||
@@ -1,7 +0,0 @@
|
||||
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);
|
||||
@@ -1,24 +0,0 @@
|
||||
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);
|
||||
@@ -1,3 +0,0 @@
|
||||
DROP INDEX IF EXISTS webdav_tokens_user_tenant_idx;
|
||||
DROP INDEX IF EXISTS webdav_tokens_token_prefix_key;
|
||||
DROP TABLE IF EXISTS webdav_tokens;
|
||||
@@ -1,16 +0,0 @@
|
||||
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);
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE webdav_tokens
|
||||
ADD COLUMN scopes JSONB NOT NULL DEFAULT '["webdav"]'::jsonb;
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE webdav_tokens
|
||||
DROP COLUMN IF EXISTS scopes;
|
||||
+4
-46
@@ -15,8 +15,6 @@ pub struct JwtService {
|
||||
expiry: Duration,
|
||||
download_audience: String,
|
||||
download_expiry: Duration,
|
||||
selector_audience: String,
|
||||
selector_expiry: Duration,
|
||||
}
|
||||
|
||||
impl JwtService {
|
||||
@@ -29,18 +27,16 @@ impl JwtService {
|
||||
expiry: Duration::minutes(config.jwt_expiry_minutes),
|
||||
download_audience: config.download_token_audience.clone(),
|
||||
download_expiry: Duration::minutes(config.download_token_expiry_minutes),
|
||||
selector_audience: format!("{}:tenant-selector", config.jwt_audience),
|
||||
selector_expiry: Duration::minutes(15),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn generate_token(&self, user_id: Uuid, tenant_id: Uuid, username: &str) -> Result<String> {
|
||||
pub fn generate_token(&self, user_id: Uuid, username: &str, role: &str) -> Result<String> {
|
||||
let now = Utc::now();
|
||||
let exp = now + self.expiry;
|
||||
let claims = Claims {
|
||||
sub: user_id,
|
||||
tenant_id,
|
||||
username: username.to_owned(),
|
||||
role: role.to_owned(),
|
||||
iss: self.issuer.clone(),
|
||||
aud: self.audience.clone(),
|
||||
iat: now.timestamp() as usize,
|
||||
@@ -58,18 +54,12 @@ impl JwtService {
|
||||
Ok(data.claims)
|
||||
}
|
||||
|
||||
pub fn generate_download_token(
|
||||
&self,
|
||||
document_id: Uuid,
|
||||
user_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
) -> Result<String> {
|
||||
pub fn generate_download_token(&self, document_id: Uuid, user_id: Uuid) -> Result<String> {
|
||||
let now = Utc::now();
|
||||
let exp = now + self.download_expiry;
|
||||
let claims = DownloadClaims {
|
||||
doc_id: document_id,
|
||||
user_id,
|
||||
tenant_id,
|
||||
iss: self.issuer.clone(),
|
||||
aud: self.download_audience.clone(),
|
||||
iat: now.timestamp() as usize,
|
||||
@@ -86,35 +76,13 @@ impl JwtService {
|
||||
let data = decode::<DownloadClaims>(token, &self.decoding, &validation)?;
|
||||
Ok(data.claims)
|
||||
}
|
||||
|
||||
pub fn generate_tenant_selector_token(&self, user_id: Uuid) -> Result<String> {
|
||||
let now = Utc::now();
|
||||
let exp = now + self.selector_expiry;
|
||||
let claims = TenantSelectionClaims {
|
||||
sub: user_id,
|
||||
iss: self.issuer.clone(),
|
||||
aud: self.selector_audience.clone(),
|
||||
iat: now.timestamp() as usize,
|
||||
exp: exp.timestamp() as usize,
|
||||
};
|
||||
|
||||
Ok(encode(&Header::default(), &claims, &self.encoding)?)
|
||||
}
|
||||
|
||||
pub fn verify_tenant_selector_token(&self, token: &str) -> Result<TenantSelectionClaims> {
|
||||
let mut validation = Validation::default();
|
||||
validation.set_audience(&[self.selector_audience.clone()]);
|
||||
validation.set_issuer(&[self.issuer.clone()]);
|
||||
let data = decode::<TenantSelectionClaims>(token, &self.decoding, &validation)?;
|
||||
Ok(data.claims)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Claims {
|
||||
pub sub: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub username: String,
|
||||
pub role: String,
|
||||
pub iss: String,
|
||||
pub aud: String,
|
||||
pub iat: usize,
|
||||
@@ -125,16 +93,6 @@ pub struct Claims {
|
||||
pub struct DownloadClaims {
|
||||
pub doc_id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub iss: String,
|
||||
pub aud: String,
|
||||
pub iat: usize,
|
||||
pub exp: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TenantSelectionClaims {
|
||||
pub sub: Uuid,
|
||||
pub iss: String,
|
||||
pub aud: String,
|
||||
pub iat: usize,
|
||||
|
||||
+4
-51
@@ -1,23 +1,18 @@
|
||||
pub mod jwt;
|
||||
pub mod password;
|
||||
pub mod webdav_tokens;
|
||||
|
||||
use axum::{async_trait, extract::FromRequestParts, http::request::Parts};
|
||||
use axum_extra::headers::{authorization::Bearer, Authorization};
|
||||
use axum_extra::TypedHeader;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
error::AppError,
|
||||
state::{AppState, PgPooledConnection},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
use crate::{error::AppError, state::AppState};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AuthenticatedUser {
|
||||
pub user_id: uuid::Uuid,
|
||||
pub username: String,
|
||||
pub tenant_id: uuid::Uuid,
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -28,10 +23,6 @@ impl FromRequestParts<AppState> for AuthenticatedUser {
|
||||
parts: &mut Parts,
|
||||
state: &AppState,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
if let Some(user) = parts.extensions.get::<AuthenticatedUser>() {
|
||||
return Ok(user.clone());
|
||||
}
|
||||
|
||||
let TypedHeader(Authorization(bearer)) =
|
||||
TypedHeader::<Authorization<Bearer>>::from_request_parts(parts, state)
|
||||
.await
|
||||
@@ -42,48 +33,10 @@ impl FromRequestParts<AppState> for AuthenticatedUser {
|
||||
.verify_token(bearer.token())
|
||||
.map_err(|_| AppError::unauthorized())?;
|
||||
|
||||
let user = AuthenticatedUser {
|
||||
Ok(AuthenticatedUser {
|
||||
user_id: claims.sub,
|
||||
username: claims.username,
|
||||
tenant_id: claims.tenant_id,
|
||||
};
|
||||
|
||||
parts.extensions.insert(user.clone());
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TenantScopedConn {
|
||||
pub conn: PgPooledConnection,
|
||||
pub tenant_id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub user: AuthenticatedUser,
|
||||
}
|
||||
|
||||
impl TenantScopedConn {
|
||||
pub fn conn(&mut self) -> &mut PgPooledConnection {
|
||||
&mut self.conn
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FromRequestParts<AppState> for TenantScopedConn {
|
||||
type Rejection = AppError;
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut Parts,
|
||||
state: &AppState,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
let user = AuthenticatedUser::from_request_parts(parts, state).await?;
|
||||
let tenant_id = user.tenant_id;
|
||||
let conn = state.db_for_tenant(tenant_id)?;
|
||||
|
||||
Ok(Self {
|
||||
conn,
|
||||
tenant_id,
|
||||
user_id: user.user_id,
|
||||
user,
|
||||
role: claims.role,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
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| AppError::internal(format!("failed to verify token: {err}")))
|
||||
}
|
||||
|
||||
fn generate_secret() -> Result<String, AppError> {
|
||||
let mut buffer = [0u8; TOKEN_SECRET_LENGTH];
|
||||
OsRng
|
||||
.try_fill_bytes(&mut buffer)
|
||||
.map_err(|err| AppError::internal(format!("failed to generate token: {err}")))?;
|
||||
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| AppError::internal(format!("failed to hash token: {err}")))?;
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -1,707 +0,0 @@
|
||||
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},
|
||||
jobs::{enqueue_job, JOB_ANALYZE_DOCUMENT},
|
||||
models::{DocumentAsset, DocumentAssetObject, NewUser, NewUserMembership, Tenant, User},
|
||||
s3,
|
||||
schema::{
|
||||
document_asset_objects, document_assets, documents, tenants, user_memberships, users,
|
||||
},
|
||||
storage::{ObjectStorage, S3Storage, TenantStorage},
|
||||
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,
|
||||
storage_root: Option<String>,
|
||||
quickwit_index: Option<String>,
|
||||
},
|
||||
DeleteTenant {
|
||||
slug: String,
|
||||
},
|
||||
AddUserToTenant {
|
||||
username: String,
|
||||
slug: String,
|
||||
role: Option<String>,
|
||||
},
|
||||
RemoveUserFromTenant {
|
||||
username: String,
|
||||
slug: String,
|
||||
},
|
||||
ReanalyzeDocuments {
|
||||
slug: String,
|
||||
},
|
||||
ListTenants,
|
||||
DeleteAssets(String),
|
||||
QuickwitCreate(String),
|
||||
QuickwitDelete(String),
|
||||
}
|
||||
|
||||
impl Command {
|
||||
fn usage() -> &'static str {
|
||||
"Usage: admin\n\
|
||||
create-user <username> <password>\n\
|
||||
set-password <username> <password>\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\
|
||||
list-tenants\n\
|
||||
delete-assets <slug>\n\
|
||||
quickwit-create-index <slug>\n\
|
||||
quickwit-delete-index <slug>"
|
||||
}
|
||||
|
||||
fn parse() -> Result<Self> {
|
||||
let mut args = env::args().skip(1);
|
||||
match args.next().as_deref() {
|
||||
Some("create-user") => Ok(Self::CreateUser {
|
||||
username: args.next().ok_or_else(|| anyhow!("username required"))?,
|
||||
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"))?,
|
||||
storage_root: args.next(),
|
||||
quickwit_index: args.next(),
|
||||
}),
|
||||
Some("delete-tenant") => Ok(Self::DeleteTenant {
|
||||
slug: args.next().ok_or_else(|| anyhow!("tenant slug required"))?,
|
||||
}),
|
||||
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(),
|
||||
}),
|
||||
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"))?,
|
||||
}),
|
||||
Some("reanalyze-documents") => Ok(Self::ReanalyzeDocuments {
|
||||
slug: args.next().ok_or_else(|| anyhow!("tenant slug required"))?,
|
||||
}),
|
||||
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"))?,
|
||||
)),
|
||||
_ => Err(anyhow!(Self::usage())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
init_tracing("info");
|
||||
let command = Command::parse()?;
|
||||
let config = AppConfig::load_and_log("admin")?;
|
||||
let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?;
|
||||
|
||||
match command {
|
||||
Command::CreateUser { username, password } => create_user(&pool, &username, &password)?,
|
||||
Command::SetPassword { username, password } => set_password(&pool, &username, &password)?,
|
||||
Command::ListUsers => list_users(&pool)?,
|
||||
Command::DeleteUser { username } => delete_user(&pool, &username)?,
|
||||
Command::CreateTenant {
|
||||
slug,
|
||||
storage_root,
|
||||
quickwit_index,
|
||||
} => create_tenant(&pool, &slug, storage_root, quickwit_index)?,
|
||||
Command::DeleteTenant { slug } => delete_tenant(&pool, &slug)?,
|
||||
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)?,
|
||||
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::QuickwitDelete(slug) => {
|
||||
quickwit_index(&config, &pool, &slug, Method::DELETE).await?
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_user(pool: &PgPool, username: &str, password: &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 =
|
||||
select(exists(users::table.filter(users::username.eq(username)))).get_result(&mut conn)?;
|
||||
if exists {
|
||||
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)
|
||||
.values(&new_user)
|
||||
.execute(&mut conn)?;
|
||||
|
||||
println!("created user '{}' (id: {})", username, new_user.id);
|
||||
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")?;
|
||||
|
||||
let users_list: Vec<User> = users::table.order(users::username.asc()).load(&mut conn)?;
|
||||
if users_list.is_empty() {
|
||||
println!("No users found.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for user in users_list {
|
||||
let memberships: Vec<(Uuid, String, 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()))
|
||||
.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(", "));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn delete_user(pool: &PgPool, username: &str) -> Result<()> {
|
||||
let mut conn = pool.get().context("failed to get database connection")?;
|
||||
|
||||
let user: User = users::table
|
||||
.filter(users::username.eq(username))
|
||||
.first(&mut conn)
|
||||
.optional()?
|
||||
.ok_or_else(|| anyhow!("user '{}' not found", username))?;
|
||||
|
||||
diesel::delete(user_memberships::table.filter(user_memberships::user_id.eq(user.id)))
|
||||
.execute(&mut conn)?;
|
||||
diesel::delete(users::table.filter(users::id.eq(user.id))).execute(&mut conn)?;
|
||||
|
||||
println!("deleted user '{}'", username);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_tenant(
|
||||
pool: &PgPool,
|
||||
slug: &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 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)?;
|
||||
|
||||
println!(
|
||||
"created tenant '{}' with id {}, storage_root '{}', quickwit_index '{}'",
|
||||
slug, id, storage_root, quickwit_index
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn delete_tenant(pool: &PgPool, slug: &str) -> Result<()> {
|
||||
let mut conn = pool.get().context("failed to get database connection")?;
|
||||
|
||||
let tenant: Tenant = tenants::table
|
||||
.filter(tenants::slug.eq(slug))
|
||||
.first(&mut conn)
|
||||
.optional()?
|
||||
.ok_or_else(|| anyhow!("tenant '{}' not found", slug))?;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
diesel::delete(tenants::table.filter(tenants::id.eq(tenant.id))).execute(&mut conn)?;
|
||||
println!("deleted tenant '{}'", slug);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn add_user_to_tenant(pool: &PgPool, username: &str, slug: &str, role: Option<&str>) -> Result<()> {
|
||||
let mut conn = pool.get().context("failed to get database connection")?;
|
||||
|
||||
let user: User = users::table
|
||||
.filter(users::username.eq(username))
|
||||
.first(&mut conn)
|
||||
.optional()?
|
||||
.ok_or_else(|| anyhow!("user '{}' not found", username))?;
|
||||
|
||||
let tenant: Tenant = tenants::table
|
||||
.filter(tenants::slug.eq(slug))
|
||||
.first(&mut conn)
|
||||
.optional()?
|
||||
.ok_or_else(|| anyhow!("tenant '{}' not found", slug))?;
|
||||
|
||||
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))
|
||||
.execute(&mut conn)?;
|
||||
|
||||
println!(
|
||||
"added user '{}' to tenant '{}' with role '{}'",
|
||||
username, slug, membership.role
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_user_from_tenant(pool: &PgPool, username: &str, slug: &str) -> Result<()> {
|
||||
let mut conn = pool.get().context("failed to get database connection")?;
|
||||
|
||||
let user: User = users::table
|
||||
.filter(users::username.eq(username))
|
||||
.first(&mut conn)
|
||||
.optional()?
|
||||
.ok_or_else(|| anyhow!("user '{}' not found", username))?;
|
||||
|
||||
let tenant: Tenant = tenants::table
|
||||
.filter(tenants::slug.eq(slug))
|
||||
.first(&mut conn)
|
||||
.optional()?
|
||||
.ok_or_else(|| anyhow!("tenant '{}' not found", slug))?;
|
||||
|
||||
let removed = diesel::delete(
|
||||
user_memberships::table
|
||||
.filter(user_memberships::user_id.eq(user.id))
|
||||
.filter(user_memberships::tenant_id.eq(tenant.id)),
|
||||
)
|
||||
.execute(&mut conn)?;
|
||||
|
||||
if removed == 0 {
|
||||
println!("user '{}' was not a member of tenant '{}'", username, slug);
|
||||
} else {
|
||||
println!("removed user '{}' from tenant '{}'", username, slug);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reanalyze_documents(pool: &PgPool, slug: &str) -> Result<()> {
|
||||
let mut conn = pool.get().context("failed to get database connection")?;
|
||||
|
||||
let tenant: Tenant = tenants::table
|
||||
.filter(tenants::slug.eq(slug))
|
||||
.first(&mut conn)
|
||||
.optional()?
|
||||
.ok_or_else(|| anyhow!("tenant '{}' not found", slug))?;
|
||||
|
||||
let targets: Vec<(Uuid, Uuid)> = documents::table
|
||||
.filter(documents::tenant_id.eq(tenant.id))
|
||||
.filter(documents::deleted_at.is_null())
|
||||
.select((documents::id, documents::current_version_id))
|
||||
.load(&mut conn)?;
|
||||
|
||||
if targets.is_empty() {
|
||||
println!("tenant '{}' has no active documents", slug);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut queued = 0usize;
|
||||
for (document_id, version_id) in targets {
|
||||
enqueue_job(
|
||||
&mut conn,
|
||||
tenant.id,
|
||||
JOB_ANALYZE_DOCUMENT,
|
||||
serde_json::json!({
|
||||
"document_id": document_id,
|
||||
"document_version_id": version_id,
|
||||
"force": true,
|
||||
}),
|
||||
None,
|
||||
)
|
||||
.map_err(|err| anyhow!("failed to enqueue analyze job: {}", err))?;
|
||||
queued += 1;
|
||||
}
|
||||
|
||||
println!(
|
||||
"queued {} documents for re-analysis in tenant '{}'",
|
||||
queued, slug
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn list_tenants(pool: &PgPool) -> Result<()> {
|
||||
let mut conn = pool.get().context("failed to get database connection")?;
|
||||
let tenants: Vec<Tenant> = tenants::table
|
||||
.order(tenants::slug.asc())
|
||||
.load(&mut conn)
|
||||
.context("failed to load tenants")?;
|
||||
|
||||
if tenants.is_empty() {
|
||||
println!("No tenants found.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for tenant in tenants {
|
||||
println!("{} ({})", tenant.slug, tenant.id);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_assets_for_tenant(
|
||||
config: &AppConfig,
|
||||
pool: &PgPool,
|
||||
tenant_slug: &str,
|
||||
) -> Result<()> {
|
||||
let s3_client = s3::build_client(config).await?;
|
||||
let storage: Arc<dyn ObjectStorage> =
|
||||
Arc::new(S3Storage::new(s3_client, config.s3_bucket.clone()));
|
||||
|
||||
let mut conn = pool.get().context("failed to get database connection")?;
|
||||
let tenant: Tenant = tenants::table
|
||||
.filter(tenants::slug.eq(tenant_slug))
|
||||
.first(&mut conn)
|
||||
.optional()
|
||||
.context("failed to load tenant")?
|
||||
.ok_or_else(|| anyhow!("tenant '{}' not found", tenant_slug))?;
|
||||
|
||||
let tenant_storage = TenantStorage::new(Arc::clone(&storage), &tenant)
|
||||
.with_context(|| format!("missing storage root for tenant {}", tenant.slug))?;
|
||||
|
||||
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))?;
|
||||
|
||||
if assets.is_empty() {
|
||||
println!("Tenant {}: no assets", tenant.slug);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!(
|
||||
"Tenant {} ({}): deleting {} assets…",
|
||||
tenant.slug,
|
||||
tenant.id,
|
||||
assets.len()
|
||||
);
|
||||
|
||||
let asset_ids: Vec<Uuid> = assets.iter().map(|asset| asset.id).collect();
|
||||
|
||||
let objects: Vec<DocumentAssetObject> = document_asset_objects::table
|
||||
.filter(document_asset_objects::tenant_id.eq(tenant.id))
|
||||
.filter(document_asset_objects::asset_id.eq_any(&asset_ids))
|
||||
.load(&mut conn)
|
||||
.with_context(|| format!("failed to load asset objects for tenant {}", tenant.slug))?;
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
diesel::delete(
|
||||
document_asset_objects::table
|
||||
.filter(document_asset_objects::tenant_id.eq(tenant.id))
|
||||
.filter(document_asset_objects::asset_id.eq_any(&asset_ids)),
|
||||
)
|
||||
.execute(&mut conn)
|
||||
.with_context(|| format!("failed to remove asset objects for tenant {}", tenant.slug))?;
|
||||
|
||||
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))?;
|
||||
|
||||
println!("Tenant {}: asset records deleted.", tenant.slug);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn quickwit_index(
|
||||
config: &AppConfig,
|
||||
pool: &PgPool,
|
||||
slug: &str,
|
||||
method: Method,
|
||||
) -> Result<()> {
|
||||
let endpoint = config
|
||||
.quickwit_endpoint
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow!("quickwit endpoint not configured"))?;
|
||||
|
||||
let mut conn = pool.get().context("failed to get database connection")?;
|
||||
let tenant: Tenant = tenants::table
|
||||
.filter(tenants::slug.eq(slug))
|
||||
.first(&mut conn)
|
||||
.optional()
|
||||
.context("failed to query tenants")?
|
||||
.ok_or_else(|| anyhow!("tenant '{}' not found", slug))?;
|
||||
|
||||
let client = Client::new();
|
||||
let index_id = format!("documents-{}", tenant.id);
|
||||
let base_endpoint = endpoint.trim_end_matches('/');
|
||||
|
||||
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()
|
||||
.await
|
||||
.context("failed to send create index request")?;
|
||||
|
||||
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")?;
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Method::DELETE => {
|
||||
let response = client
|
||||
.delete(format!("{}/api/v1/indexes/{}", base_endpoint, index_id))
|
||||
.send()
|
||||
.await
|
||||
.context("failed to send delete index request")?;
|
||||
|
||||
match response.status() {
|
||||
status if status.is_success() || status == StatusCode::NOT_FOUND => {
|
||||
diesel::update(tenants::table.filter(tenants::id.eq(tenant.id)))
|
||||
.set(tenants::quickwit_index.eq::<Option<String>>(None))
|
||||
.execute(&mut conn)
|
||||
.context("failed to clear tenant quickwit_index")?;
|
||||
|
||||
println!("Tenant '{}' quickwit index cleared.", tenant.slug);
|
||||
}
|
||||
status => {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
bail!(
|
||||
"quickwit delete index failed with status {}: {}",
|
||||
status,
|
||||
body
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
use std::env;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use diesel::prelude::*;
|
||||
|
||||
use backend::{
|
||||
config::AppConfig,
|
||||
db,
|
||||
models::DocumentAsset,
|
||||
s3,
|
||||
schema::document_assets,
|
||||
storage::{ObjectStorage, S3Storage},
|
||||
};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let mut args = env::args().skip(1);
|
||||
match args.next().as_deref() {
|
||||
Some("delete-assets") => delete_all_assets().await?,
|
||||
Some(cmd) => {
|
||||
eprintln!("Unknown command: {cmd}\nUsage: maintenance delete-assets");
|
||||
std::process::exit(1);
|
||||
}
|
||||
None => {
|
||||
eprintln!("Usage: maintenance delete-assets");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_all_assets() -> Result<()> {
|
||||
let config = AppConfig::from_env()?;
|
||||
tracing::info!(
|
||||
component = "maintenance",
|
||||
database_url = %config.redacted_database_url(),
|
||||
pool_size = config.database_max_pool_size,
|
||||
s3_bucket = %config.s3_bucket,
|
||||
"loaded backend configuration"
|
||||
);
|
||||
let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?;
|
||||
|
||||
let s3_client = s3::build_client(&config).await?;
|
||||
let storage = S3Storage::new(s3_client, config.s3_bucket.clone());
|
||||
|
||||
let mut conn = pool.get().context("failed to get database connection")?;
|
||||
|
||||
let assets: Vec<DocumentAsset> = document_assets::table
|
||||
.load(&mut conn)
|
||||
.context("failed to load document assets")?;
|
||||
|
||||
if assets.is_empty() {
|
||||
println!("No assets found.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("Deleting {} assets…", assets.len());
|
||||
|
||||
for asset in &assets {
|
||||
if let Err(err) = storage.delete_object(&asset.s3_key).await {
|
||||
eprintln!(
|
||||
"Failed to delete object {} from storage: {err}",
|
||||
asset.s3_key
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
diesel::delete(document_assets::table)
|
||||
.execute(&mut conn)
|
||||
.context("failed to remove asset records")?;
|
||||
|
||||
println!("Asset records deleted.");
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
use backend::openapi::ApiDoc;
|
||||
use utoipa::OpenApi;
|
||||
|
||||
fn main() {
|
||||
let spec = ApiDoc::openapi();
|
||||
let json = serde_json::to_string_pretty(&spec).expect("serialize openapi");
|
||||
println!("{}", json);
|
||||
}
|
||||
@@ -1,24 +1,47 @@
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::net::TcpListener;
|
||||
use tower::make::Shared;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
use backend::{routes::webdav, utils::bootstrap::init_component};
|
||||
use backend::auth::jwt::JwtService;
|
||||
use backend::config::AppConfig;
|
||||
use backend::db;
|
||||
use backend::routes::webdav;
|
||||
use backend::s3::build_client;
|
||||
use backend::state::AppState;
|
||||
use backend::storage::S3Storage;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let state = init_component("webdav", None).await?;
|
||||
let webdav_host = state.config.webdav_host.clone();
|
||||
let webdav_port = state.config.webdav_port;
|
||||
dotenv::dotenv().ok();
|
||||
init_tracing();
|
||||
|
||||
let config = AppConfig::from_env()?;
|
||||
tracing::info!(
|
||||
component = "webdav",
|
||||
webdav_host = %webdav_host,
|
||||
webdav_port,
|
||||
"starting webdav server"
|
||||
database_url = %config.redacted_database_url(),
|
||||
pool_size = config.database_max_pool_size,
|
||||
server_host = %config.server_host,
|
||||
server_port = config.server_port,
|
||||
webdav_host = %config.webdav_host,
|
||||
webdav_port = config.webdav_port,
|
||||
quickwit_enabled = config.quickwit_endpoint.is_some(),
|
||||
s3_bucket = %config.s3_bucket,
|
||||
"loaded backend configuration"
|
||||
);
|
||||
let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?;
|
||||
let s3_client = build_client(&config).await?;
|
||||
let storage = Arc::new(S3Storage::new(s3_client, config.s3_bucket.clone()));
|
||||
let jwt = JwtService::from_config(&config)?;
|
||||
|
||||
let listen_addr: SocketAddr = format!("{}:{}", webdav_host, webdav_port).parse()?;
|
||||
let router = webdav::create_router().with_state(state.as_ref().clone());
|
||||
let state = AppState::new(pool, config, storage, jwt);
|
||||
let listen_addr: SocketAddr = {
|
||||
let config = state.config.clone();
|
||||
format!("{}:{}", config.webdav_host, config.webdav_port).parse()?
|
||||
};
|
||||
let router = webdav::create_router().with_state(state);
|
||||
|
||||
let listener = TcpListener::bind(listen_addr).await?;
|
||||
tracing::info!("listening for WebDAV on {}", listen_addr);
|
||||
@@ -26,3 +49,12 @@ async fn main() -> anyhow::Result<()> {
|
||||
axum::serve(listener, Shared::new(router)).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn init_tracing() {
|
||||
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(filter)
|
||||
.with_target(false)
|
||||
.compact()
|
||||
.init();
|
||||
}
|
||||
|
||||
@@ -1,13 +1,33 @@
|
||||
use std::time::Duration;
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use tokio::signal;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
use backend::{default_handlers, utils::bootstrap::init_component, Worker};
|
||||
use backend::{
|
||||
auth::jwt::JwtService, config::AppConfig, db, default_handlers, s3::build_client,
|
||||
state::AppState, storage::S3Storage, Worker,
|
||||
};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let state = init_component("worker", Some(1)).await?;
|
||||
tracing::info!(component = "worker", "starting worker process");
|
||||
dotenv::dotenv().ok();
|
||||
init_tracing();
|
||||
|
||||
let config = AppConfig::from_env()?;
|
||||
tracing::info!(
|
||||
component = "worker",
|
||||
database_url = %config.redacted_database_url(),
|
||||
pool_size = 1,
|
||||
quickwit_enabled = config.quickwit_endpoint.is_some(),
|
||||
s3_bucket = %config.s3_bucket,
|
||||
"loaded backend configuration"
|
||||
);
|
||||
let pool = db::init_pool_with_size(&config.database_url, 1)?;
|
||||
let s3_client = build_client(&config).await?;
|
||||
let storage = Arc::new(S3Storage::new(s3_client, config.s3_bucket.clone()));
|
||||
let jwt = JwtService::from_config(&config)?;
|
||||
|
||||
let state = Arc::new(AppState::new(pool, config, storage, jwt));
|
||||
let worker = Worker::new(state, default_handlers(), Duration::from_secs(2));
|
||||
|
||||
tokio::select! {
|
||||
@@ -19,3 +39,12 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn init_tracing() {
|
||||
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(filter)
|
||||
.with_target(false)
|
||||
.compact()
|
||||
.init();
|
||||
}
|
||||
|
||||
+76
-117
@@ -1,82 +1,110 @@
|
||||
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, Deserialize)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AppConfig {
|
||||
pub database_url: String,
|
||||
#[serde(default = "default_database_max_pool_size")]
|
||||
pub database_max_pool_size: u32,
|
||||
#[serde(default = "default_server_host")]
|
||||
pub server_host: String,
|
||||
#[serde(default = "default_server_port")]
|
||||
pub server_port: u16,
|
||||
#[serde(default = "default_webdav_host")]
|
||||
pub webdav_host: String,
|
||||
#[serde(default = "default_webdav_port")]
|
||||
pub webdav_port: u16,
|
||||
pub jwt_secret: String,
|
||||
#[serde(default = "default_jwt_issuer")]
|
||||
pub jwt_issuer: String,
|
||||
#[serde(default = "default_jwt_audience")]
|
||||
pub jwt_audience: String,
|
||||
#[serde(default = "default_jwt_expiry_minutes")]
|
||||
pub jwt_expiry_minutes: i64,
|
||||
#[serde(default = "default_download_token_audience")]
|
||||
pub download_token_audience: String,
|
||||
#[serde(default = "default_download_token_expiry_minutes")]
|
||||
pub download_token_expiry_minutes: i64,
|
||||
#[serde(default = "default_refresh_token_expiry_days")]
|
||||
pub refresh_token_expiry_days: i64,
|
||||
#[serde(
|
||||
default = "default_refresh_cookie_secure",
|
||||
deserialize_with = "deserialize_bool_from_anything"
|
||||
)]
|
||||
pub refresh_cookie_secure: bool,
|
||||
#[serde(default)]
|
||||
pub refresh_cookie_domain: Option<String>,
|
||||
#[serde(default)]
|
||||
pub cors_allowed_origin: Option<String>,
|
||||
#[serde(default)]
|
||||
pub aws_endpoint_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub aws_access_key_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub aws_secret_access_key: Option<String>,
|
||||
#[serde(default = "default_aws_region")]
|
||||
pub aws_region: String,
|
||||
pub s3_bucket: String,
|
||||
#[serde(default)]
|
||||
pub quickwit_endpoint: Option<String>,
|
||||
#[serde(default)]
|
||||
pub quickwit_index: Option<String>,
|
||||
#[serde(default = "default_tenant_slug")]
|
||||
pub default_tenant_slug: String,
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
pub fn load_and_log(component: &str) -> Result<Self> {
|
||||
dotenv::dotenv().ok();
|
||||
let config = Self::from_env()?;
|
||||
tracing::info!(
|
||||
component,
|
||||
database_url = %config.redacted_database_url(),
|
||||
pool_size = config.database_max_pool_size,
|
||||
quickwit_enabled = config.quickwit_endpoint.is_some(),
|
||||
s3_bucket = %config.s3_bucket,
|
||||
"loaded backend configuration"
|
||||
);
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub fn from_env() -> Result<Self> {
|
||||
let config: AppConfig = envy::from_env()
|
||||
.context("failed to parse application configuration from environment")?;
|
||||
Ok(config.normalize())
|
||||
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();
|
||||
|
||||
Ok(Self {
|
||||
database_url,
|
||||
database_max_pool_size,
|
||||
server_host,
|
||||
server_port,
|
||||
webdav_host,
|
||||
webdav_port,
|
||||
jwt_secret,
|
||||
jwt_issuer,
|
||||
jwt_audience,
|
||||
jwt_expiry_minutes,
|
||||
download_token_audience,
|
||||
download_token_expiry_minutes,
|
||||
refresh_token_expiry_days,
|
||||
refresh_cookie_secure,
|
||||
refresh_cookie_domain,
|
||||
cors_allowed_origin,
|
||||
aws_endpoint_url,
|
||||
aws_access_key_id,
|
||||
aws_secret_access_key,
|
||||
aws_region,
|
||||
s3_bucket,
|
||||
quickwit_endpoint,
|
||||
quickwit_index,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn redacted_database_url(&self) -> String {
|
||||
@@ -84,80 +112,11 @@ impl AppConfig {
|
||||
}
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
fn normalize(mut self) -> Self {
|
||||
if self.webdav_host.is_empty() {
|
||||
self.webdav_host = self.server_host.clone();
|
||||
}
|
||||
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_tenant_slug() -> String {
|
||||
"admin".to_string()
|
||||
}
|
||||
|
||||
fn redact_database_url(raw: &str) -> String {
|
||||
match Url::parse(raw) {
|
||||
Ok(mut parsed) => {
|
||||
if parsed.password().is_some() {
|
||||
let _ = parsed.set_password(Some("*****"));
|
||||
parsed.to_string()
|
||||
} else {
|
||||
raw.to_string()
|
||||
}
|
||||
let _ = parsed.set_password(Some("*****"));
|
||||
parsed.to_string()
|
||||
}
|
||||
Err(_) => "***".to_string(),
|
||||
}
|
||||
|
||||
@@ -1,276 +0,0 @@
|
||||
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| AppError::internal(format!("failed to generate download token: {err}")))
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
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")
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
pub mod asset;
|
||||
pub mod correspondents;
|
||||
pub mod folders;
|
||||
pub mod metadata;
|
||||
pub mod search;
|
||||
pub mod tags;
|
||||
@@ -1,166 +0,0 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use reqwest::Client;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use tracing::{debug, error};
|
||||
use uuid::Uuid;
|
||||
|
||||
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 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>,
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -12,7 +12,6 @@ pub type AppResult<T> = Result<T, AppError>;
|
||||
pub struct AppError {
|
||||
status: StatusCode,
|
||||
message: String,
|
||||
code: Option<String>,
|
||||
}
|
||||
|
||||
impl AppError {
|
||||
@@ -20,7 +19,6 @@ impl AppError {
|
||||
Self {
|
||||
status,
|
||||
message: message.into(),
|
||||
code: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,10 +26,6 @@ impl AppError {
|
||||
Self::new(StatusCode::BAD_REQUEST, message)
|
||||
}
|
||||
|
||||
pub fn conflict(message: impl Into<String>) -> Self {
|
||||
Self::new(StatusCode::CONFLICT, message)
|
||||
}
|
||||
|
||||
pub fn unauthorized() -> Self {
|
||||
Self::new(StatusCode::UNAUTHORIZED, "unauthorized")
|
||||
}
|
||||
@@ -43,11 +37,6 @@ impl AppError {
|
||||
pub fn internal<E: Display>(error: E) -> Self {
|
||||
Self::new(StatusCode::INTERNAL_SERVER_ERROR, error.to_string())
|
||||
}
|
||||
|
||||
pub fn with_code(mut self, code: impl Into<String>) -> Self {
|
||||
self.code = Some(code.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
@@ -55,7 +44,6 @@ impl IntoResponse for AppError {
|
||||
let status = self.status;
|
||||
let body = Json(ErrorResponse {
|
||||
error: self.message,
|
||||
code: self.code,
|
||||
});
|
||||
(status, body).into_response()
|
||||
}
|
||||
@@ -64,8 +52,6 @@ impl IntoResponse for AppError {
|
||||
#[derive(Serialize)]
|
||||
struct ErrorResponse {
|
||||
error: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
code: Option<String>,
|
||||
}
|
||||
|
||||
impl From<diesel::result::Error> for AppError {
|
||||
|
||||
@@ -30,7 +30,6 @@ pub type JobQueueResult<T> = Result<T, JobQueueError>;
|
||||
|
||||
pub fn enqueue_job(
|
||||
conn: &mut PgConnection,
|
||||
tenant_id: Uuid,
|
||||
job_type: &str,
|
||||
payload: Value,
|
||||
run_after: Option<NaiveDateTime>,
|
||||
@@ -41,7 +40,6 @@ pub fn enqueue_job(
|
||||
payload,
|
||||
status: STATUS_QUEUED.to_string(),
|
||||
run_after: run_after.unwrap_or_else(|| Utc::now().naive_utc()),
|
||||
tenant_id,
|
||||
};
|
||||
|
||||
diesel::insert_into(jobs::table)
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
pub mod auth;
|
||||
pub mod config;
|
||||
pub mod db;
|
||||
pub mod documents;
|
||||
pub mod error;
|
||||
pub mod jobs;
|
||||
pub mod models;
|
||||
pub mod openapi;
|
||||
pub mod routes;
|
||||
pub mod s3;
|
||||
pub mod schema;
|
||||
pub mod state;
|
||||
pub mod storage;
|
||||
pub mod tenants;
|
||||
pub mod utils;
|
||||
pub mod workers;
|
||||
pub use workers::{default_handlers, Worker};
|
||||
|
||||
+38
-9
@@ -1,28 +1,57 @@
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::net::TcpListener;
|
||||
use tower::make::Shared;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
use backend::{routes, utils::bootstrap::init_component};
|
||||
use backend::auth::jwt::JwtService;
|
||||
use backend::config::AppConfig;
|
||||
use backend::db;
|
||||
use backend::routes;
|
||||
use backend::s3::build_client;
|
||||
use backend::state::AppState;
|
||||
use backend::storage::S3Storage;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let state = init_component("api", None).await?;
|
||||
let server_host = state.config.server_host.clone();
|
||||
let server_port = state.config.server_port;
|
||||
dotenv::dotenv().ok();
|
||||
init_tracing();
|
||||
|
||||
let config = AppConfig::from_env()?;
|
||||
tracing::info!(
|
||||
component = "api",
|
||||
server_host = %server_host,
|
||||
server_port,
|
||||
"starting api server"
|
||||
database_url = %config.redacted_database_url(),
|
||||
pool_size = config.database_max_pool_size,
|
||||
server_host = %config.server_host,
|
||||
server_port = config.server_port,
|
||||
quickwit_enabled = config.quickwit_endpoint.is_some(),
|
||||
s3_bucket = %config.s3_bucket,
|
||||
"loaded backend configuration"
|
||||
);
|
||||
let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?;
|
||||
let s3_client = build_client(&config).await?;
|
||||
let storage = Arc::new(S3Storage::new(s3_client, config.s3_bucket.clone()));
|
||||
let jwt = JwtService::from_config(&config)?;
|
||||
|
||||
let router = routes::create_router(state.as_ref().clone());
|
||||
let state = AppState::new(pool, config, storage, jwt);
|
||||
|
||||
let addr: SocketAddr = format!("{}:{}", server_host, server_port).parse()?;
|
||||
let router = routes::create_router(state.clone());
|
||||
|
||||
let addr: SocketAddr =
|
||||
format!("{}:{}", state.config.server_host, state.config.server_port).parse()?;
|
||||
let listener = TcpListener::bind(addr).await?;
|
||||
tracing::info!("listening on {}", addr);
|
||||
|
||||
axum::serve(listener, Shared::new(router)).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn init_tracing() {
|
||||
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(filter)
|
||||
.with_target(false)
|
||||
.compact()
|
||||
.init();
|
||||
}
|
||||
|
||||
+9
-111
@@ -4,48 +4,13 @@ use uuid::Uuid;
|
||||
|
||||
use crate::schema::*;
|
||||
|
||||
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
||||
#[diesel(table_name = user_memberships)]
|
||||
#[diesel(belongs_to(User, foreign_key = user_id))]
|
||||
#[diesel(belongs_to(Tenant, foreign_key = tenant_id))]
|
||||
pub struct UserMembership {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub role: String,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Insertable)]
|
||||
#[diesel(table_name = user_memberships)]
|
||||
pub struct NewUserMembership {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Queryable, Identifiable)]
|
||||
#[diesel(table_name = tenants)]
|
||||
#[diesel(primary_key(id))]
|
||||
pub struct Tenant {
|
||||
pub id: Uuid,
|
||||
pub slug: String,
|
||||
pub storage_root: Option<String>,
|
||||
pub quickwit_index: Option<String>,
|
||||
pub status: String,
|
||||
pub config: serde_json::Value,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Queryable, Identifiable)]
|
||||
#[diesel(table_name = users)]
|
||||
pub struct User {
|
||||
pub id: Uuid,
|
||||
pub username: String,
|
||||
pub password_hash: String,
|
||||
pub role: String,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
}
|
||||
@@ -56,35 +21,7 @@ pub struct NewUser {
|
||||
pub id: Uuid,
|
||||
pub username: String,
|
||||
pub password_hash: String,
|
||||
}
|
||||
|
||||
#[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>,
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Queryable, Identifiable)]
|
||||
@@ -95,7 +32,6 @@ pub struct Folder {
|
||||
pub parent_id: Option<Uuid>,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Insertable)]
|
||||
@@ -104,7 +40,6 @@ pub struct NewFolder {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub parent_id: Option<Uuid>,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
||||
@@ -123,7 +58,6 @@ pub struct Document {
|
||||
pub issued_at: Option<NaiveDateTime>,
|
||||
pub title: String,
|
||||
pub current_version_id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Insertable)]
|
||||
@@ -138,7 +72,6 @@ pub struct NewDocument {
|
||||
pub metadata: serde_json::Value,
|
||||
pub issued_at: Option<NaiveDateTime>,
|
||||
pub title: String,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
||||
@@ -152,8 +85,8 @@ pub struct DocumentVersion {
|
||||
pub size_bytes: i64,
|
||||
pub checksum: String,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub operations_summary: serde_json::Value,
|
||||
pub metadata: serde_json::Value,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Insertable)]
|
||||
@@ -165,8 +98,8 @@ pub struct NewDocumentVersion {
|
||||
pub s3_key: String,
|
||||
pub size_bytes: i64,
|
||||
pub checksum: String,
|
||||
pub operations_summary: serde_json::Value,
|
||||
pub metadata: serde_json::Value,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
||||
@@ -176,11 +109,10 @@ pub struct DocumentAsset {
|
||||
pub id: Uuid,
|
||||
pub document_version_id: Uuid,
|
||||
pub asset_type: String,
|
||||
pub s3_key: String,
|
||||
pub mime_type: String,
|
||||
pub metadata: serde_json::Value,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub cardinality: Option<i32>,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Insertable)]
|
||||
@@ -189,33 +121,9 @@ pub struct NewDocumentAsset {
|
||||
pub id: Uuid,
|
||||
pub document_version_id: Uuid,
|
||||
pub asset_type: String,
|
||||
pub s3_key: String,
|
||||
pub mime_type: String,
|
||||
pub metadata: serde_json::Value,
|
||||
pub cardinality: Option<i32>,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
||||
#[diesel(table_name = document_asset_objects)]
|
||||
#[diesel(belongs_to(DocumentAsset, foreign_key = asset_id))]
|
||||
pub struct DocumentAssetObject {
|
||||
pub id: Uuid,
|
||||
pub asset_id: Uuid,
|
||||
pub ordinal: i32,
|
||||
pub s3_key: String,
|
||||
pub metadata: serde_json::Value,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Insertable)]
|
||||
#[diesel(table_name = document_asset_objects)]
|
||||
pub struct NewDocumentAssetObject {
|
||||
pub id: Uuid,
|
||||
pub asset_id: Uuid,
|
||||
pub ordinal: i32,
|
||||
pub s3_key: String,
|
||||
pub metadata: serde_json::Value,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Queryable, Identifiable)]
|
||||
@@ -230,7 +138,6 @@ pub struct Job {
|
||||
pub last_error: Option<String>,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Insertable)]
|
||||
@@ -241,7 +148,6 @@ pub struct NewJob {
|
||||
pub payload: serde_json::Value,
|
||||
pub status: String,
|
||||
pub run_after: NaiveDateTime,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Queryable, Identifiable)]
|
||||
@@ -251,7 +157,6 @@ pub struct Tag {
|
||||
pub label: String,
|
||||
pub color: Option<String>,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Insertable)]
|
||||
@@ -260,7 +165,6 @@ pub struct NewTag {
|
||||
pub id: Uuid,
|
||||
pub label: String,
|
||||
pub color: Option<String>,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
@@ -274,7 +178,6 @@ pub struct DocumentTag {
|
||||
pub tag_id: Uuid,
|
||||
pub assigned_at: NaiveDateTime,
|
||||
pub assigned_by: Option<Uuid>,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Insertable)]
|
||||
@@ -283,7 +186,6 @@ pub struct NewDocumentTag {
|
||||
pub document_id: Uuid,
|
||||
pub tag_id: Uuid,
|
||||
pub assigned_by: Option<Uuid>,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Queryable, Identifiable)]
|
||||
@@ -294,7 +196,6 @@ pub struct Correspondent {
|
||||
pub metadata: serde_json::Value,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Insertable)]
|
||||
@@ -303,20 +204,19 @@ pub struct NewCorrespondent {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub metadata: serde_json::Value,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Queryable, Associations)]
|
||||
#[diesel(table_name = document_correspondents)]
|
||||
#[diesel(belongs_to(Document))]
|
||||
#[diesel(belongs_to(Correspondent))]
|
||||
#[diesel(primary_key(document_id, correspondent_id))]
|
||||
#[diesel(primary_key(document_id, correspondent_id, role))]
|
||||
pub struct DocumentCorrespondent {
|
||||
pub document_id: Uuid,
|
||||
pub correspondent_id: Uuid,
|
||||
pub role: String,
|
||||
pub assigned_at: NaiveDateTime,
|
||||
pub assigned_by: Option<Uuid>,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Insertable)]
|
||||
@@ -324,8 +224,8 @@ pub struct DocumentCorrespondent {
|
||||
pub struct NewDocumentCorrespondent {
|
||||
pub document_id: Uuid,
|
||||
pub correspondent_id: Uuid,
|
||||
pub role: String,
|
||||
pub assigned_by: Option<Uuid>,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
||||
@@ -340,7 +240,6 @@ pub struct RefreshToken {
|
||||
pub revoked_at: Option<NaiveDateTime>,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Insertable)]
|
||||
@@ -351,5 +250,4 @@ pub struct NewRefreshToken {
|
||||
pub token_hash: String,
|
||||
pub issued_at: NaiveDateTime,
|
||||
pub expires_at: NaiveDateTime,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+75
-213
@@ -1,15 +1,11 @@
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::{header::SET_COOKIE, HeaderMap, HeaderValue, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use axum_extra::{
|
||||
headers::{authorization::Bearer, Authorization, Cookie},
|
||||
typed_header::TypedHeader,
|
||||
};
|
||||
use axum_extra::{headers::Cookie, typed_header::TypedHeader};
|
||||
use chrono::{Duration as ChronoDuration, Utc};
|
||||
use diesel::{pg::PgConnection, prelude::*};
|
||||
use diesel::prelude::*;
|
||||
use rand::{rngs::OsRng, RngCore};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
@@ -18,11 +14,8 @@ use uuid::Uuid;
|
||||
use crate::{
|
||||
auth::{password, AuthenticatedUser},
|
||||
error::{AppError, AppResult},
|
||||
models::{NewRefreshToken, RefreshToken, Tenant, User, UserMembership},
|
||||
schema::{
|
||||
refresh_tokens, tenants::dsl as tenant_dsl, user_memberships::dsl as memberships_dsl,
|
||||
users::dsl,
|
||||
},
|
||||
models::{NewRefreshToken, RefreshToken, User},
|
||||
schema::{refresh_tokens, users::dsl},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
@@ -34,8 +27,6 @@ const REFRESH_COOKIE_NAME: &str = "refresh_token";
|
||||
pub struct LoginRequest {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
#[serde(default)]
|
||||
pub preferred_tenant_slug: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -43,46 +34,17 @@ pub struct LoginResponse {
|
||||
pub access_token: String,
|
||||
pub token_type: String,
|
||||
pub expires_in: i64,
|
||||
pub tenant: TenantSnippet,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct TenantSnippet {
|
||||
pub id: Uuid,
|
||||
pub slug: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct TenantSelectionResponse {
|
||||
pub access_token: String,
|
||||
pub tenants: Vec<TenantSnippet>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct TenantListResponse {
|
||||
pub tenants: Vec<TenantSnippet>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct TenantSelectionRequest {
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
pub async fn login(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<LoginRequest>,
|
||||
) -> AppResult<Response> {
|
||||
let mut conn = state.db_unscoped()?;
|
||||
) -> AppResult<(HeaderMap, Json<LoginResponse>)> {
|
||||
let mut conn = state.db()?;
|
||||
|
||||
let user: Option<User> = dsl::users
|
||||
let user: User = dsl::users
|
||||
.filter(dsl::username.eq(&payload.username))
|
||||
.first(&mut conn)
|
||||
.optional()?;
|
||||
|
||||
let user = match user {
|
||||
Some(user) => user,
|
||||
None => return Err(AppError::unauthorized()),
|
||||
};
|
||||
.first(&mut conn)?;
|
||||
|
||||
let valid = password::verify_password(&payload.password, &user.password_hash)
|
||||
.map_err(|_| AppError::unauthorized())?;
|
||||
@@ -91,67 +53,55 @@ pub async fn login(
|
||||
return Err(AppError::unauthorized());
|
||||
}
|
||||
|
||||
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
|
||||
.as_ref()
|
||||
.map(|slug| slug.trim().to_string())
|
||||
.filter(|slug| !slug.is_empty());
|
||||
|
||||
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 access_token = state
|
||||
.jwt
|
||||
.generate_tenant_selector_token(user.id)
|
||||
.generate_token(user.id, &user.username, &user.role)
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
let tenants = memberships
|
||||
.into_iter()
|
||||
.map(|(_, tenant)| TenantSnippet {
|
||||
id: tenant.id,
|
||||
slug: tenant.slug,
|
||||
})
|
||||
.collect();
|
||||
let now = Utc::now();
|
||||
let refresh_value = generate_refresh_token();
|
||||
let refresh_hash = hash_refresh_token(&refresh_value);
|
||||
let refresh_expires_at = now + ChronoDuration::days(state.config.refresh_token_expiry_days);
|
||||
|
||||
let response = Json(TenantSelectionResponse {
|
||||
access_token: selection_token,
|
||||
tenants,
|
||||
})
|
||||
.into_response();
|
||||
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(),
|
||||
};
|
||||
|
||||
Ok(response)
|
||||
diesel::insert_into(refresh_tokens::table)
|
||||
.values(&new_refresh)
|
||||
.execute(&mut conn)?;
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
SET_COOKIE,
|
||||
build_refresh_cookie(&state, &refresh_value, refresh_expires_at),
|
||||
);
|
||||
|
||||
Ok((
|
||||
headers,
|
||||
Json(LoginResponse {
|
||||
access_token,
|
||||
token_type: "Bearer".to_string(),
|
||||
expires_in: state.config.jwt_expiry_minutes * 60,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn refresh(
|
||||
State(state): State<AppState>,
|
||||
jar: Option<TypedHeader<Cookie>>,
|
||||
) -> AppResult<Response> {
|
||||
) -> AppResult<(HeaderMap, Json<LoginResponse>)> {
|
||||
let cookies = jar.ok_or_else(AppError::unauthorized)?;
|
||||
let refresh_value = cookies
|
||||
.get(REFRESH_COOKIE_NAME)
|
||||
.ok_or_else(AppError::unauthorized)?;
|
||||
|
||||
let hashed = hash_refresh_token(refresh_value);
|
||||
let mut conn = state.db_unscoped()?;
|
||||
let mut conn = state.db()?;
|
||||
let now = Utc::now();
|
||||
let now_naive = now.naive_utc();
|
||||
|
||||
@@ -178,43 +128,41 @@ pub async fn refresh(
|
||||
.first(&mut conn)
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
issue_session(&state, &mut conn, &user, token.tenant_id)
|
||||
}
|
||||
|
||||
pub async fn select_tenant(
|
||||
State(state): State<AppState>,
|
||||
TypedHeader(Authorization(bearer)): TypedHeader<Authorization<Bearer>>,
|
||||
Json(payload): Json<TenantSelectionRequest>,
|
||||
) -> AppResult<Response> {
|
||||
let user_id = match state.jwt.verify_tenant_selector_token(bearer.token()) {
|
||||
Ok(claims) => claims.sub,
|
||||
Err(_) => state
|
||||
.jwt
|
||||
.verify_token(bearer.token())
|
||||
.map(|claims| claims.sub)
|
||||
.map_err(|_| AppError::unauthorized())?,
|
||||
};
|
||||
|
||||
let mut conn = state.db_unscoped()?;
|
||||
|
||||
let membership_exists = memberships_dsl::user_memberships
|
||||
.filter(memberships_dsl::user_id.eq(user_id))
|
||||
.filter(memberships_dsl::tenant_id.eq(payload.tenant_id))
|
||||
.inner_join(tenant_dsl::tenants)
|
||||
.select(memberships_dsl::id)
|
||||
.first::<Uuid>(&mut conn)
|
||||
.optional()?;
|
||||
|
||||
if membership_exists.is_none() {
|
||||
return Err(AppError::unauthorized());
|
||||
}
|
||||
|
||||
let user: User = dsl::users
|
||||
.find(user_id)
|
||||
.first(&mut conn)
|
||||
let access_token = state
|
||||
.jwt
|
||||
.generate_token(user.id, &user.username, &user.role)
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
issue_session(&state, &mut conn, &user, payload.tenant_id)
|
||||
let new_refresh_value = generate_refresh_token();
|
||||
let new_refresh_hash = hash_refresh_token(&new_refresh_value);
|
||||
let new_refresh_expires = now + ChronoDuration::days(state.config.refresh_token_expiry_days);
|
||||
|
||||
let new_refresh = NewRefreshToken {
|
||||
id: Uuid::new_v4(),
|
||||
user_id: user.id,
|
||||
token_hash: new_refresh_hash,
|
||||
issued_at: now_naive,
|
||||
expires_at: new_refresh_expires.naive_utc(),
|
||||
};
|
||||
|
||||
diesel::insert_into(refresh_tokens::table)
|
||||
.values(&new_refresh)
|
||||
.execute(&mut conn)?;
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
SET_COOKIE,
|
||||
build_refresh_cookie(&state, &new_refresh_value, new_refresh_expires),
|
||||
);
|
||||
|
||||
Ok((
|
||||
headers,
|
||||
Json(LoginResponse {
|
||||
access_token,
|
||||
token_type: "Bearer".to_string(),
|
||||
expires_in: state.config.jwt_expiry_minutes * 60,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn logout(
|
||||
@@ -222,7 +170,7 @@ pub async fn logout(
|
||||
user: AuthenticatedUser,
|
||||
jar: Option<TypedHeader<Cookie>>,
|
||||
) -> AppResult<(HeaderMap, StatusCode)> {
|
||||
let mut conn = state.db_unscoped()?;
|
||||
let mut conn = state.db()?;
|
||||
let now = Utc::now().naive_utc();
|
||||
let mut rows_affected = 0;
|
||||
|
||||
@@ -266,92 +214,6 @@ 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::slug))
|
||||
.load::<(Uuid, String)>(&mut conn)?
|
||||
.into_iter()
|
||||
.map(|(id, slug)| TenantSnippet { id, slug })
|
||||
.collect();
|
||||
|
||||
Ok(Json(TenantListResponse { tenants }))
|
||||
}
|
||||
|
||||
fn issue_session(
|
||||
state: &AppState,
|
||||
conn: &mut PgConnection,
|
||||
user: &User,
|
||||
tenant_id: Uuid,
|
||||
) -> AppResult<Response> {
|
||||
let now = Utc::now();
|
||||
let access_token = state
|
||||
.jwt
|
||||
.generate_token(user.id, tenant_id, &user.username)
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
let tenant_slug: String = tenant_dsl::tenants
|
||||
.find(tenant_id)
|
||||
.select(tenant_dsl::slug)
|
||||
.first(conn)
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
let refresh_value = generate_refresh_token();
|
||||
let refresh_hash = hash_refresh_token(&refresh_value);
|
||||
let refresh_expires_at = now + ChronoDuration::days(state.config.refresh_token_expiry_days);
|
||||
|
||||
let new_refresh = NewRefreshToken {
|
||||
id: Uuid::new_v4(),
|
||||
user_id: user.id,
|
||||
token_hash: refresh_hash,
|
||||
issued_at: now.naive_utc(),
|
||||
expires_at: refresh_expires_at.naive_utc(),
|
||||
tenant_id,
|
||||
};
|
||||
|
||||
diesel::insert_into(refresh_tokens::table)
|
||||
.values(&new_refresh)
|
||||
.execute(conn)?;
|
||||
|
||||
let mut response = Json(LoginResponse {
|
||||
access_token,
|
||||
token_type: "Bearer".to_string(),
|
||||
expires_in: state.config.jwt_expiry_minutes * 60,
|
||||
tenant: TenantSnippet {
|
||||
id: tenant_id,
|
||||
slug: tenant_slug,
|
||||
},
|
||||
})
|
||||
.into_response();
|
||||
|
||||
response.headers_mut().insert(
|
||||
SET_COOKIE,
|
||||
build_refresh_cookie(state, &refresh_value, refresh_expires_at),
|
||||
);
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn hash_refresh_token(token: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(token.as_bytes());
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
use axum::{extract::Path, http::StatusCode, Json};
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
};
|
||||
use chrono::Utc;
|
||||
use diesel::{dsl::count_star, prelude::*, result::DatabaseErrorKind, PgConnection};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -8,19 +13,19 @@ use serde_json::Value;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
auth::TenantScopedConn,
|
||||
error::{AppError, AppResult},
|
||||
models::{Correspondent, NewCorrespondent},
|
||||
schema::{correspondents, document_correspondents},
|
||||
utils::{
|
||||
db::{no_content, EnsureEntity, IntoJsonResponse},
|
||||
time::to_iso,
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
use super::documents::to_iso;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct CorrespondentUsage {
|
||||
pub total: i64,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub by_role: BTreeMap<String, i64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -54,43 +59,45 @@ struct CorrespondentChangeset<'a> {
|
||||
}
|
||||
|
||||
pub async fn list_correspondents(
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<Vec<CorrespondentSummary>>> {
|
||||
let mut conn = state.db()?;
|
||||
|
||||
let correspondents_list: Vec<Correspondent> = correspondents::table
|
||||
.filter(correspondents::tenant_id.eq(tenant_id))
|
||||
.order(correspondents::name.asc())
|
||||
.load(&mut conn)?;
|
||||
|
||||
let usage_rows: Vec<(Uuid, i64)> = document_correspondents::table
|
||||
.filter(document_correspondents::tenant_id.eq(tenant_id))
|
||||
.group_by(document_correspondents::correspondent_id)
|
||||
.select((document_correspondents::correspondent_id, count_star()))
|
||||
let usage_rows: Vec<(Uuid, String, i64)> = document_correspondents::table
|
||||
.group_by((
|
||||
document_correspondents::correspondent_id,
|
||||
document_correspondents::role,
|
||||
))
|
||||
.select((
|
||||
document_correspondents::correspondent_id,
|
||||
document_correspondents::role,
|
||||
count_star(),
|
||||
))
|
||||
.load(&mut conn)?;
|
||||
|
||||
let mut usage_map: HashMap<Uuid, i64> = HashMap::new();
|
||||
for (correspondent_id, count) in usage_rows {
|
||||
usage_map.insert(correspondent_id, count);
|
||||
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 response = Vec::with_capacity(correspondents_list.len());
|
||||
for correspondent in correspondents_list {
|
||||
let total = usage_map.remove(&correspondent.id).unwrap_or(0);
|
||||
response.push(build_summary(correspondent, total));
|
||||
let role_counts = usage_map.remove(&correspondent.id).unwrap_or_default();
|
||||
response.push(build_summary(correspondent, role_counts));
|
||||
}
|
||||
|
||||
response.into_json()
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
||||
pub async fn create_correspondent(
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<CreateCorrespondentRequest>,
|
||||
) -> AppResult<Json<CorrespondentSummary>> {
|
||||
let name = payload.name.trim();
|
||||
@@ -104,9 +111,9 @@ pub async fn create_correspondent(
|
||||
id: new_id,
|
||||
name: name.to_string(),
|
||||
metadata: metadata_value,
|
||||
tenant_id,
|
||||
};
|
||||
|
||||
let mut conn = state.db()?;
|
||||
match diesel::insert_into(correspondents::table)
|
||||
.values(&new_correspondent)
|
||||
.execute(&mut conn)
|
||||
@@ -118,29 +125,19 @@ pub async fn create_correspondent(
|
||||
Err(err) => return Err(AppError::from(err)),
|
||||
}
|
||||
|
||||
let correspondent: Correspondent = correspondents::table
|
||||
.find(new_id)
|
||||
.filter(correspondents::tenant_id.eq(tenant_id))
|
||||
.first(&mut conn)
|
||||
.one()?;
|
||||
|
||||
build_summary(correspondent, 0).into_json()
|
||||
let correspondent: Correspondent = correspondents::table.find(new_id).first(&mut conn)?;
|
||||
Ok(Json(build_summary(correspondent, BTreeMap::new())))
|
||||
}
|
||||
|
||||
pub async fn update_correspondent(
|
||||
State(state): State<AppState>,
|
||||
Path(correspondent_id): Path<Uuid>,
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
Json(payload): Json<UpdateCorrespondentRequest>,
|
||||
) -> AppResult<Json<CorrespondentSummary>> {
|
||||
let mut conn = state.db()?;
|
||||
let existing: Correspondent = correspondents::table
|
||||
.find(correspondent_id)
|
||||
.filter(correspondents::tenant_id.eq(tenant_id))
|
||||
.first(&mut conn)
|
||||
.one()?;
|
||||
.first(&mut conn)?;
|
||||
|
||||
let mut new_name: Option<String> = None;
|
||||
if let Some(ref candidate) = payload.name {
|
||||
@@ -152,7 +149,6 @@ pub async fn update_correspondent(
|
||||
let duplicate = correspondents::table
|
||||
.filter(correspondents::name.eq(trimmed))
|
||||
.filter(correspondents::id.ne(correspondent_id))
|
||||
.filter(correspondents::tenant_id.eq(tenant_id))
|
||||
.first::<Correspondent>(&mut conn)
|
||||
.optional()?;
|
||||
if duplicate.is_some() {
|
||||
@@ -171,8 +167,8 @@ pub async fn update_correspondent(
|
||||
}
|
||||
|
||||
if new_name.is_none() && new_metadata.is_none() {
|
||||
let usage = load_usage_for_correspondent(&mut conn, tenant_id, correspondent_id)?;
|
||||
return build_summary(existing.clone(), usage).into_json();
|
||||
let usage = load_usage_for_correspondent(&mut conn, correspondent_id)?;
|
||||
return Ok(Json(build_summary(existing.clone(), usage)));
|
||||
}
|
||||
|
||||
let mut changeset = CorrespondentChangeset::default();
|
||||
@@ -184,33 +180,24 @@ pub async fn update_correspondent(
|
||||
}
|
||||
|
||||
let now = Utc::now().naive_utc();
|
||||
diesel::update(
|
||||
correspondents::table
|
||||
.find(correspondent_id)
|
||||
.filter(correspondents::tenant_id.eq(tenant_id)),
|
||||
)
|
||||
.set((&changeset, correspondents::updated_at.eq(now)))
|
||||
.execute(&mut conn)?;
|
||||
diesel::update(correspondents::table.find(correspondent_id))
|
||||
.set((&changeset, correspondents::updated_at.eq(now)))
|
||||
.execute(&mut conn)?;
|
||||
|
||||
let updated: Correspondent = correspondents::table
|
||||
.find(correspondent_id)
|
||||
.filter(correspondents::tenant_id.eq(tenant_id))
|
||||
.first(&mut conn)
|
||||
.one()?;
|
||||
let usage = load_usage_for_correspondent(&mut conn, tenant_id, correspondent_id)?;
|
||||
build_summary(updated, usage).into_json()
|
||||
.first(&mut conn)?;
|
||||
let usage = load_usage_for_correspondent(&mut conn, correspondent_id)?;
|
||||
Ok(Json(build_summary(updated, usage)))
|
||||
}
|
||||
|
||||
pub async fn delete_correspondent(
|
||||
State(state): State<AppState>,
|
||||
Path(correspondent_id): Path<Uuid>,
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
) -> AppResult<StatusCode> {
|
||||
) -> AppResult<impl IntoResponse> {
|
||||
let mut conn = state.db()?;
|
||||
|
||||
let usage: i64 = document_correspondents::table
|
||||
.filter(document_correspondents::tenant_id.eq(tenant_id))
|
||||
.filter(document_correspondents::correspondent_id.eq(correspondent_id))
|
||||
.select(count_star())
|
||||
.first(&mut conn)?;
|
||||
@@ -221,26 +208,29 @@ pub async fn delete_correspondent(
|
||||
));
|
||||
}
|
||||
|
||||
let deleted = diesel::delete(
|
||||
correspondents::table
|
||||
.filter(correspondents::id.eq(correspondent_id))
|
||||
.filter(correspondents::tenant_id.eq(tenant_id)),
|
||||
)
|
||||
.execute(&mut conn)?;
|
||||
let deleted =
|
||||
diesel::delete(correspondents::table.find(correspondent_id)).execute(&mut conn)?;
|
||||
if deleted == 0 {
|
||||
return Err(AppError::not_found());
|
||||
}
|
||||
no_content()
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
fn build_summary(correspondent: Correspondent, total: i64) -> CorrespondentSummary {
|
||||
fn build_summary(
|
||||
correspondent: Correspondent,
|
||||
role_counts: BTreeMap<String, i64>,
|
||||
) -> CorrespondentSummary {
|
||||
let total = role_counts.values().copied().sum();
|
||||
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 },
|
||||
usage: CorrespondentUsage {
|
||||
total,
|
||||
by_role: role_counts,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,14 +243,17 @@ fn normalize_metadata(input: Option<Value>) -> Value {
|
||||
|
||||
fn load_usage_for_correspondent(
|
||||
conn: &mut PgConnection,
|
||||
tenant_id: Uuid,
|
||||
correspondent_id: Uuid,
|
||||
) -> AppResult<i64> {
|
||||
let total: i64 = document_correspondents::table
|
||||
) -> AppResult<BTreeMap<String, i64>> {
|
||||
let rows: Vec<(String, i64)> = document_correspondents::table
|
||||
.filter(document_correspondents::correspondent_id.eq(correspondent_id))
|
||||
.filter(document_correspondents::tenant_id.eq(tenant_id))
|
||||
.select(count_star())
|
||||
.get_result(conn)?;
|
||||
.group_by(document_correspondents::role)
|
||||
.select((document_correspondents::role, count_star()))
|
||||
.load(conn)?;
|
||||
|
||||
Ok(total)
|
||||
let mut map = BTreeMap::new();
|
||||
for (role, count) in rows {
|
||||
map.insert(role, count);
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
+976
-1078
File diff suppressed because it is too large
Load Diff
+73
-224
@@ -4,25 +4,19 @@ use axum::{
|
||||
};
|
||||
use diesel::{dsl::exists, prelude::*, PgConnection};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::models::{Document, Folder, NewFolder};
|
||||
use crate::schema::{documents, folders};
|
||||
use crate::state::AppState;
|
||||
use crate::{
|
||||
auth::TenantScopedConn,
|
||||
auth::AuthenticatedUser,
|
||||
error::{AppError, AppResult},
|
||||
};
|
||||
|
||||
use super::documents::{to_document_response, DocumentResponse};
|
||||
use crate::documents::{
|
||||
asset::load_primary_assets, correspondents::load_correspondents_for_documents,
|
||||
tags::load_tags_for_documents,
|
||||
};
|
||||
use crate::utils::{
|
||||
json::{classify_nullable, NullableValue},
|
||||
time::to_iso,
|
||||
use super::documents::{
|
||||
load_correspondents_for_documents, load_primary_assets, load_tags_for_documents,
|
||||
to_document_response, to_iso, DocumentResponse,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -37,6 +31,13 @@ pub struct EnsureFolderPathRequest {
|
||||
pub segments: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateFolderRequest {
|
||||
#[serde(default)]
|
||||
pub parent_id: Option<Option<Uuid>>,
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct FolderResponse {
|
||||
pub folder: FolderInfo,
|
||||
@@ -68,36 +69,16 @@ pub struct FolderInfo {
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
pub async fn get_folder(
|
||||
Path(folder_id): Path<Uuid>,
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
) -> AppResult<Json<FolderResponse>> {
|
||||
let folder: Folder = folders::table
|
||||
.find(folder_id)
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.first(&mut conn)?;
|
||||
|
||||
Ok(Json(FolderResponse {
|
||||
folder: folder_to_info(folder),
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn ensure_folder_path(
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<EnsureFolderPathRequest>,
|
||||
) -> AppResult<Json<FolderResponse>> {
|
||||
if payload.segments.is_empty() {
|
||||
return Err(AppError::bad_request("segments must not be empty"));
|
||||
}
|
||||
|
||||
let mut conn = state.db()?;
|
||||
|
||||
let target_folder = conn.transaction::<Folder, AppError, _>(|conn| {
|
||||
let mut current_parent = payload.parent_id;
|
||||
let mut last_folder: Option<Folder> = None;
|
||||
@@ -110,14 +91,12 @@ pub async fn ensure_folder_path(
|
||||
|
||||
let existing: Option<Folder> = if let Some(parent_id) = current_parent {
|
||||
folders::table
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.filter(folders::parent_id.eq(Some(parent_id)))
|
||||
.filter(folders::name.eq(name))
|
||||
.first(conn)
|
||||
.optional()?
|
||||
} else {
|
||||
folders::table
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.filter(folders::parent_id.is_null())
|
||||
.filter(folders::name.eq(name))
|
||||
.first(conn)
|
||||
@@ -131,35 +110,15 @@ pub async fn ensure_folder_path(
|
||||
id: Uuid::new_v4(),
|
||||
name: name.to_string(),
|
||||
parent_id: current_parent,
|
||||
tenant_id,
|
||||
};
|
||||
|
||||
let inserted_id: Option<Uuid> = diesel::insert_into(folders::table)
|
||||
diesel::insert_into(folders::table)
|
||||
.values(&new_folder)
|
||||
.on_conflict_do_nothing()
|
||||
.returning(folders::id)
|
||||
.get_result(conn)
|
||||
.optional()?;
|
||||
.execute(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)?
|
||||
}
|
||||
folders::table.find(new_folder.id).first(conn)?
|
||||
};
|
||||
|
||||
current_parent = Some(folder.id);
|
||||
last_folder = Some(folder);
|
||||
}
|
||||
@@ -173,72 +132,26 @@ pub async fn ensure_folder_path(
|
||||
}
|
||||
|
||||
pub async fn create_folder(
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<CreateFolderRequest>,
|
||||
) -> AppResult<Json<FolderResponse>> {
|
||||
if payload.name.trim().is_empty() {
|
||||
return Err(AppError::bad_request("name must not be empty"));
|
||||
}
|
||||
|
||||
let name = payload.name.trim();
|
||||
let mut conn = state.db()?;
|
||||
|
||||
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()?
|
||||
let new_folder = NewFolder {
|
||||
id: Uuid::new_v4(),
|
||||
name: payload.name.trim().to_string(),
|
||||
parent_id: payload.parent_id,
|
||||
};
|
||||
|
||||
let folder: Folder = if let Some(folder) = existing {
|
||||
folder
|
||||
} else {
|
||||
let new_folder = NewFolder {
|
||||
id: Uuid::new_v4(),
|
||||
name: name.to_string(),
|
||||
parent_id: payload.parent_id,
|
||||
tenant_id,
|
||||
};
|
||||
|
||||
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)?
|
||||
} 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)?
|
||||
} else {
|
||||
folders::table
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.filter(folders::parent_id.is_null())
|
||||
.filter(folders::name.eq(name))
|
||||
.first(&mut conn)?
|
||||
}
|
||||
};
|
||||
diesel::insert_into(folders::table)
|
||||
.values(&new_folder)
|
||||
.execute(&mut conn)?;
|
||||
|
||||
let folder: Folder = folders::table.find(new_folder.id).first(&mut conn)?;
|
||||
Ok(Json(FolderResponse {
|
||||
folder: folder_to_info(folder),
|
||||
}))
|
||||
@@ -248,13 +161,10 @@ pub async fn list_folder_contents(
|
||||
State(state): State<AppState>,
|
||||
Path(folder_identifier): Path<String>,
|
||||
Query(query): Query<FolderContentsQuery>,
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
user_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
user: AuthenticatedUser,
|
||||
) -> AppResult<Json<FolderContentsResponse>> {
|
||||
let mut conn = state.db()?;
|
||||
|
||||
let folder_id = if folder_identifier.eq_ignore_ascii_case("root") {
|
||||
None
|
||||
} else {
|
||||
@@ -266,10 +176,7 @@ pub async fn list_folder_contents(
|
||||
|
||||
let folder = match folder_id {
|
||||
Some(id) => Some(folder_to_info(
|
||||
folders::table
|
||||
.find(id)
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.first::<Folder>(&mut conn)?,
|
||||
folders::table.find(id).first::<Folder>(&mut conn)?,
|
||||
)),
|
||||
None => None,
|
||||
};
|
||||
@@ -277,13 +184,11 @@ pub async fn list_folder_contents(
|
||||
let child_folders: Vec<Folder> = if let Some(parent_id) = folder_id {
|
||||
folders::table
|
||||
.filter(folders::parent_id.eq(parent_id))
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.order(folders::name.asc())
|
||||
.load(&mut conn)?
|
||||
} else {
|
||||
folders::table
|
||||
.filter(folders::parent_id.is_null())
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.order(folders::name.asc())
|
||||
.load(&mut conn)?
|
||||
};
|
||||
@@ -292,7 +197,6 @@ pub async fn list_folder_contents(
|
||||
let documents = if query.include_documents {
|
||||
let docs_query = documents::table
|
||||
.filter(documents::deleted_at.is_null())
|
||||
.filter(documents::tenant_id.eq(tenant_id))
|
||||
.order(documents::uploaded_at.desc());
|
||||
|
||||
let docs: Vec<Document> = if let Some(current_folder) = folder_id {
|
||||
@@ -310,7 +214,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)?;
|
||||
let primary_versions = load_primary_assets(&state, &docs).await?;
|
||||
|
||||
let mut documents = Vec::with_capacity(doc_ids.len());
|
||||
for doc in docs {
|
||||
@@ -319,7 +223,7 @@ pub async fn list_folder_contents(
|
||||
let current_version = primary_versions.get(&doc.id).cloned();
|
||||
documents.push(to_document_response(
|
||||
&state,
|
||||
user_id,
|
||||
user.user_id,
|
||||
doc,
|
||||
tags,
|
||||
correspondents,
|
||||
@@ -340,23 +244,16 @@ pub async fn list_folder_contents(
|
||||
}
|
||||
|
||||
pub async fn delete_folder(
|
||||
State(state): State<AppState>,
|
||||
Path(folder_id): Path<Uuid>,
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
) -> AppResult<StatusCode> {
|
||||
let mut conn = state.db()?;
|
||||
|
||||
conn.transaction::<_, AppError, _>(|conn| {
|
||||
folders::table
|
||||
.find(folder_id)
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.first::<Folder>(conn)?;
|
||||
folders::table.find(folder_id).first::<Folder>(conn)?;
|
||||
|
||||
let has_child_folders: bool = diesel::select(exists(
|
||||
folders::table
|
||||
.filter(folders::parent_id.eq(Some(folder_id)))
|
||||
.filter(folders::tenant_id.eq(tenant_id)),
|
||||
folders::table.filter(folders::parent_id.eq(Some(folder_id))),
|
||||
))
|
||||
.get_result(conn)?;
|
||||
|
||||
@@ -369,7 +266,6 @@ pub async fn delete_folder(
|
||||
let has_documents: bool = diesel::select(exists(
|
||||
documents::table
|
||||
.filter(documents::folder_id.eq(Some(folder_id)))
|
||||
.filter(documents::tenant_id.eq(tenant_id))
|
||||
.filter(documents::deleted_at.is_null()),
|
||||
))
|
||||
.get_result(conn)?;
|
||||
@@ -380,12 +276,7 @@ pub async fn delete_folder(
|
||||
));
|
||||
}
|
||||
|
||||
diesel::delete(
|
||||
folders::table
|
||||
.filter(folders::id.eq(folder_id))
|
||||
.filter(folders::tenant_id.eq(tenant_id)),
|
||||
)
|
||||
.execute(conn)?;
|
||||
diesel::delete(folders::table.find(folder_id)).execute(conn)?;
|
||||
|
||||
Ok(())
|
||||
})?;
|
||||
@@ -394,84 +285,50 @@ pub async fn delete_folder(
|
||||
}
|
||||
|
||||
pub async fn update_folder(
|
||||
State(state): State<AppState>,
|
||||
Path(folder_id): Path<Uuid>,
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
Json(body): Json<Value>,
|
||||
Json(payload): Json<UpdateFolderRequest>,
|
||||
) -> AppResult<StatusCode> {
|
||||
if !body.is_object() {
|
||||
return Err(AppError::bad_request("request body must be a JSON object"));
|
||||
}
|
||||
|
||||
let parent_class = classify_nullable(body.get("parent_id")).map_err(AppError::bad_request)?;
|
||||
let name_class = classify_nullable(body.get("name")).map_err(AppError::bad_request)?;
|
||||
let mut conn = state.db()?;
|
||||
|
||||
conn.transaction::<(), AppError, _>(|conn| {
|
||||
let folder: Folder = folders::table
|
||||
.find(folder_id)
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.first(conn)?;
|
||||
let folder: Folder = folders::table.find(folder_id).first(conn)?;
|
||||
|
||||
let mut next_parent = folder.parent_id;
|
||||
let mut parent_changed = false;
|
||||
match parent_class {
|
||||
NullableValue::Omitted => {}
|
||||
NullableValue::Null => {
|
||||
if folder.parent_id.is_some() {
|
||||
parent_changed = true;
|
||||
}
|
||||
next_parent = None;
|
||||
|
||||
if let Some(parent_request) = payload.parent_id {
|
||||
if parent_request == Some(folder_id) {
|
||||
return Err(AppError::bad_request("folder cannot be its own parent"));
|
||||
}
|
||||
NullableValue::String(value) => {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(AppError::bad_request("parent_id must not be empty"));
|
||||
}
|
||||
let parent_id = Uuid::parse_str(trimmed)
|
||||
.map_err(|_| AppError::bad_request("parent_id must be a valid UUID or null"))?;
|
||||
if parent_id == folder_id {
|
||||
return Err(AppError::bad_request("folder cannot be its own parent"));
|
||||
}
|
||||
|
||||
let _parent: Folder = folders::table
|
||||
.find(parent_id)
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.first(conn)?;
|
||||
if let Some(parent_id) = parent_request {
|
||||
let _parent: Folder = folders::table.find(parent_id).first(conn)?;
|
||||
|
||||
if folder.parent_id != Some(parent_id) {
|
||||
let descendant_ids = gather_descendant_folder_ids(conn, tenant_id, folder_id)?;
|
||||
if descendant_ids.contains(&parent_id) {
|
||||
return Err(AppError::bad_request(
|
||||
"cannot move folder into itself or a descendant",
|
||||
));
|
||||
}
|
||||
parent_changed = true;
|
||||
let descendant_ids = gather_descendant_folder_ids(conn, folder_id)?;
|
||||
if descendant_ids.contains(&parent_id) {
|
||||
return Err(AppError::bad_request(
|
||||
"cannot move folder into itself or a descendant",
|
||||
));
|
||||
}
|
||||
|
||||
next_parent = Some(parent_id);
|
||||
}
|
||||
|
||||
parent_changed = parent_request != folder.parent_id;
|
||||
next_parent = parent_request;
|
||||
}
|
||||
|
||||
let mut new_name = folder.name.clone();
|
||||
let mut name_changed = false;
|
||||
match name_class {
|
||||
NullableValue::Omitted => {}
|
||||
NullableValue::Null => {
|
||||
return Err(AppError::bad_request("name cannot be null"));
|
||||
}
|
||||
NullableValue::String(value) => {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(AppError::bad_request("name must not be empty"));
|
||||
}
|
||||
|
||||
if trimmed != folder.name {
|
||||
new_name = trimmed.to_string();
|
||||
name_changed = true;
|
||||
}
|
||||
if let Some(name) = payload.name {
|
||||
let trimmed = name.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(AppError::bad_request("name must not be empty"));
|
||||
}
|
||||
|
||||
if trimmed != folder.name {
|
||||
new_name = trimmed.to_string();
|
||||
name_changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -484,7 +341,6 @@ pub async fn update_folder(
|
||||
.filter(folders::parent_id.eq(Some(parent_id)))
|
||||
.filter(folders::name.eq(&new_name))
|
||||
.filter(folders::id.ne(folder_id))
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.first::<Folder>(conn)
|
||||
.optional()?
|
||||
} else {
|
||||
@@ -492,7 +348,6 @@ pub async fn update_folder(
|
||||
.filter(folders::parent_id.is_null())
|
||||
.filter(folders::name.eq(&new_name))
|
||||
.filter(folders::id.ne(folder_id))
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.first::<Folder>(conn)
|
||||
.optional()?
|
||||
};
|
||||
@@ -503,16 +358,12 @@ pub async fn update_folder(
|
||||
));
|
||||
}
|
||||
|
||||
diesel::update(
|
||||
folders::table
|
||||
.find(folder_id)
|
||||
.filter(folders::tenant_id.eq(tenant_id)),
|
||||
)
|
||||
.set((
|
||||
folders::parent_id.eq(next_parent),
|
||||
folders::name.eq(&new_name),
|
||||
))
|
||||
.execute(conn)?;
|
||||
diesel::update(folders::table.find(folder_id))
|
||||
.set((
|
||||
folders::parent_id.eq(next_parent),
|
||||
folders::name.eq(&new_name),
|
||||
))
|
||||
.execute(conn)?;
|
||||
|
||||
Ok(())
|
||||
})?;
|
||||
@@ -532,7 +383,6 @@ fn folder_to_info(folder: Folder) -> FolderInfo {
|
||||
|
||||
pub(super) fn gather_descendant_folder_ids(
|
||||
conn: &mut PgConnection,
|
||||
tenant_id: Uuid,
|
||||
folder_id: Uuid,
|
||||
) -> AppResult<Vec<Uuid>> {
|
||||
let mut ids = vec![folder_id];
|
||||
@@ -541,7 +391,6 @@ pub(super) fn gather_descendant_folder_ids(
|
||||
while let Some(current) = queue.pop() {
|
||||
let child_ids: Vec<Uuid> = folders::table
|
||||
.filter(folders::parent_id.eq(Some(current)))
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.select(folders::id)
|
||||
.load(conn)?;
|
||||
queue.extend(child_ids.iter().copied());
|
||||
|
||||
@@ -2,25 +2,18 @@ use axum::http::HeaderValue;
|
||||
use axum::{
|
||||
extract::DefaultBodyLimit,
|
||||
middleware,
|
||||
response::Json,
|
||||
routing::{delete, get, patch, post},
|
||||
Router,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tower_http::{
|
||||
cors::{AllowOrigin, CorsLayer},
|
||||
trace::{DefaultMakeSpan, DefaultOnFailure, DefaultOnResponse, TraceLayer},
|
||||
};
|
||||
use utoipa::OpenApi;
|
||||
use tower_http::cors::{AllowOrigin, CorsLayer};
|
||||
|
||||
use crate::{auth::AuthenticatedUser, openapi::ApiDoc, state::AppState};
|
||||
use crate::{auth::AuthenticatedUser, state::AppState};
|
||||
|
||||
pub mod auth;
|
||||
pub mod correspondents;
|
||||
pub mod documents;
|
||||
pub mod folders;
|
||||
pub mod health;
|
||||
pub mod profile;
|
||||
pub mod tags;
|
||||
pub mod webdav;
|
||||
|
||||
@@ -57,16 +50,14 @@ pub fn create_router(state: AppState) -> Router<()> {
|
||||
.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("/me", get(auth::me));
|
||||
|
||||
let documents_routes = Router::new()
|
||||
.route("/check", get(documents::check_document))
|
||||
.route(
|
||||
"/",
|
||||
get(documents::list_documents).post(documents::upload_document),
|
||||
)
|
||||
.route("/reanalyze", post(documents::reanalyze_all_documents))
|
||||
.route("/bulk/move", post(documents::bulk_move_documents))
|
||||
.route("/bulk/tags", post(documents::bulk_update_tags))
|
||||
.route(
|
||||
@@ -83,17 +74,13 @@ pub fn create_router(state: AppState) -> Router<()> {
|
||||
.delete(documents::delete_document)
|
||||
.patch(documents::update_document),
|
||||
)
|
||||
.route("/:id/download", get(documents::download_document))
|
||||
.route("/:id/assets/:asset_id", get(documents::get_document_asset))
|
||||
.route(
|
||||
"/:id/assets",
|
||||
get(documents::list_document_assets).post(documents::request_document_assets),
|
||||
)
|
||||
.route("/:id/folder", patch(documents::move_document))
|
||||
.route("/:id/versions", get(documents::list_document_versions))
|
||||
.route(
|
||||
"/:id/versions/:version_id",
|
||||
get(documents::get_document_version),
|
||||
)
|
||||
.route("/:id/restore", post(documents::restore_document))
|
||||
.route("/:id/tags", post(documents::assign_tags))
|
||||
.route("/:id/tags/:tag_id", delete(documents::remove_tag))
|
||||
.route(
|
||||
@@ -111,9 +98,10 @@ pub fn create_router(state: AppState) -> Router<()> {
|
||||
let folders_routes = Router::new()
|
||||
.route("/", post(folders::create_folder))
|
||||
.route("/path", post(folders::ensure_folder_path))
|
||||
.route("/:id", get(folders::get_folder))
|
||||
.route("/:id", delete(folders::delete_folder))
|
||||
.route("/:id", patch(folders::update_folder))
|
||||
.route(
|
||||
"/:id",
|
||||
delete(folders::delete_folder).patch(folders::update_folder),
|
||||
)
|
||||
.route("/:id/contents", get(folders::list_folder_contents));
|
||||
|
||||
let tags_routes = Router::new()
|
||||
@@ -131,50 +119,20 @@ 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));
|
||||
|
||||
let protected_state = state.clone();
|
||||
let assets_routes = Router::new().route("/:asset_id", get(documents::get_document_asset));
|
||||
|
||||
let protected_routes = Router::new()
|
||||
.nest("/api/documents", documents_routes)
|
||||
.nest("/api/folders", folders_routes)
|
||||
.nest("/api/tags", tags_routes)
|
||||
.nest("/api/correspondents", correspondents_routes)
|
||||
.nest("/api/profile", profile_routes)
|
||||
.nest("/api/assets", assets_routes)
|
||||
.layer(middleware::from_extractor_with_state::<AuthenticatedUser, _>(protected_state));
|
||||
|
||||
let openapi_arc = Arc::new(ApiDoc::openapi());
|
||||
let docs_route = Router::new().route(
|
||||
"/api/docs/openapi.json",
|
||||
get({
|
||||
let spec = openapi_arc.clone();
|
||||
move || {
|
||||
let spec = spec.clone();
|
||||
async move { Json((*spec).clone()) }
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
Router::new()
|
||||
.merge(download_routes)
|
||||
.merge(protected_routes)
|
||||
.merge(docs_route)
|
||||
.nest("/api/auth", auth_routes)
|
||||
.route("/api/health", get(health::health_check))
|
||||
.with_state(state)
|
||||
.layer(cors)
|
||||
.layer(DefaultBodyLimit::max(1024 * 1024 * 512))
|
||||
.layer(
|
||||
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)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
use axum::{extract::Path, http::StatusCode, Json};
|
||||
use chrono::{DateTime, NaiveDateTime};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::{
|
||||
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::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>,
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
+34
-77
@@ -1,16 +1,19 @@
|
||||
use crate::utils::json::{classify_nullable, NullableValue};
|
||||
use axum::{extract::Path, http::StatusCode, Json};
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use diesel::{dsl::count_star, prelude::*};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::TenantScopedConn;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::models::{NewTag, Tag};
|
||||
use crate::schema::{document_tags, tags};
|
||||
use crate::utils::db::{no_content, EnsureEntity, IntoJsonResponse};
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateTagRequest {
|
||||
@@ -33,27 +36,19 @@ pub struct TagCatalogEntry {
|
||||
pub usage_count: i64,
|
||||
}
|
||||
|
||||
pub async fn list_tags(
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
) -> AppResult<Json<Vec<TagCatalogEntry>>> {
|
||||
let tag_list: Vec<Tag> = tags::table
|
||||
.filter(tags::tenant_id.eq(tenant_id))
|
||||
.order(tags::label.asc())
|
||||
.load(&mut conn)?;
|
||||
pub async fn list_tags(State(state): State<AppState>) -> AppResult<Json<Vec<TagCatalogEntry>>> {
|
||||
let mut conn = state.db()?;
|
||||
|
||||
let tag_list: Vec<Tag> = tags::table.order(tags::label.asc()).load(&mut conn)?;
|
||||
|
||||
let usage_rows: Vec<(Uuid, i64)> = document_tags::table
|
||||
.filter(document_tags::tenant_id.eq(tenant_id))
|
||||
.group_by(document_tags::tag_id)
|
||||
.select((document_tags::tag_id, count_star()))
|
||||
.load(&mut conn)?;
|
||||
|
||||
let usage_map: HashMap<Uuid, i64> = usage_rows.into_iter().collect();
|
||||
|
||||
let response: Vec<TagCatalogEntry> = tag_list
|
||||
let response = tag_list
|
||||
.into_iter()
|
||||
.map(|tag| TagCatalogEntry {
|
||||
id: tag.id,
|
||||
@@ -63,26 +58,22 @@ pub async fn list_tags(
|
||||
})
|
||||
.collect();
|
||||
|
||||
response.into_json()
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
||||
pub async fn create_tag(
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<CreateTagRequest>,
|
||||
) -> AppResult<Json<TagCatalogEntry>> {
|
||||
if payload.label.trim().is_empty() {
|
||||
return Err(AppError::bad_request("label must not be empty"));
|
||||
}
|
||||
|
||||
let mut conn = state.db()?;
|
||||
let new_tag = NewTag {
|
||||
id: Uuid::new_v4(),
|
||||
label: payload.label.trim().to_string(),
|
||||
color: payload.color,
|
||||
tenant_id,
|
||||
};
|
||||
|
||||
match diesel::insert_into(tags::table)
|
||||
@@ -99,35 +90,22 @@ pub async fn create_tag(
|
||||
Err(err) => return Err(AppError::from(err)),
|
||||
}
|
||||
|
||||
let tag: Tag = tags::table
|
||||
.find(new_tag.id)
|
||||
.filter(tags::tenant_id.eq(tenant_id))
|
||||
.first(&mut conn)
|
||||
.one()?;
|
||||
|
||||
TagCatalogEntry {
|
||||
let tag: Tag = tags::table.find(new_tag.id).first(&mut conn)?;
|
||||
Ok(Json(TagCatalogEntry {
|
||||
id: tag.id,
|
||||
label: tag.label,
|
||||
color: tag.color,
|
||||
usage_count: 0,
|
||||
}
|
||||
.into_json()
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn update_tag(
|
||||
State(state): State<AppState>,
|
||||
Path(tag_id): Path<Uuid>,
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<Json<TagCatalogEntry>> {
|
||||
let existing: Tag = tags::table
|
||||
.find(tag_id)
|
||||
.filter(tags::tenant_id.eq(tenant_id))
|
||||
.first(&mut conn)
|
||||
.one()?;
|
||||
let mut conn = state.db()?;
|
||||
let existing: Tag = tags::table.find(tag_id).first(&mut conn)?;
|
||||
let label_class = classify_nullable(body.get("label")).map_err(AppError::bad_request)?;
|
||||
let color_class = classify_nullable(body.get("color")).map_err(AppError::bad_request)?;
|
||||
|
||||
@@ -138,13 +116,12 @@ pub async fn update_tag(
|
||||
.filter(document_tags::tag_id.eq(tag_id))
|
||||
.select(count_star())
|
||||
.first(&mut conn)?;
|
||||
return TagCatalogEntry {
|
||||
return Ok(Json(TagCatalogEntry {
|
||||
id: existing.id,
|
||||
label: existing.label.clone(),
|
||||
color: existing.color.clone(),
|
||||
usage_count,
|
||||
}
|
||||
.into_json();
|
||||
}));
|
||||
}
|
||||
|
||||
let mut new_label: Option<String> = None;
|
||||
@@ -163,7 +140,6 @@ pub async fn update_tag(
|
||||
let duplicate = tags::table
|
||||
.filter(tags::label.eq(trimmed))
|
||||
.filter(tags::id.ne(tag_id))
|
||||
.filter(tags::tenant_id.eq(tenant_id))
|
||||
.first::<Tag>(&mut conn)
|
||||
.optional()?;
|
||||
if duplicate.is_some() {
|
||||
@@ -198,7 +174,6 @@ pub async fn update_tag(
|
||||
if !label_changed && !color_changed {
|
||||
let usage_count: i64 = document_tags::table
|
||||
.filter(document_tags::tag_id.eq(tag_id))
|
||||
.filter(document_tags::tenant_id.eq(tenant_id))
|
||||
.select(count_star())
|
||||
.first(&mut conn)?;
|
||||
return Ok(Json(TagCatalogEntry {
|
||||
@@ -216,45 +191,32 @@ pub async fn update_tag(
|
||||
.map(|opt| opt.as_ref().map(|value| value.as_str())),
|
||||
};
|
||||
|
||||
diesel::update(
|
||||
tags::table
|
||||
.find(tag_id)
|
||||
.filter(tags::tenant_id.eq(tenant_id)),
|
||||
)
|
||||
.set(&changeset)
|
||||
.execute(&mut conn)?;
|
||||
diesel::update(tags::table.find(tag_id))
|
||||
.set(&changeset)
|
||||
.execute(&mut conn)?;
|
||||
|
||||
let updated: Tag = tags::table
|
||||
.find(tag_id)
|
||||
.filter(tags::tenant_id.eq(tenant_id))
|
||||
.first(&mut conn)
|
||||
.one()?;
|
||||
let updated: Tag = tags::table.find(tag_id).first(&mut conn)?;
|
||||
let usage_count: i64 = document_tags::table
|
||||
.filter(document_tags::tag_id.eq(tag_id))
|
||||
.filter(document_tags::tenant_id.eq(tenant_id))
|
||||
.select(count_star())
|
||||
.first(&mut conn)?;
|
||||
|
||||
TagCatalogEntry {
|
||||
Ok(Json(TagCatalogEntry {
|
||||
id: updated.id,
|
||||
label: updated.label,
|
||||
color: updated.color,
|
||||
usage_count,
|
||||
}
|
||||
.into_json()
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn delete_tag(
|
||||
State(state): State<AppState>,
|
||||
Path(tag_id): Path<Uuid>,
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
) -> AppResult<StatusCode> {
|
||||
) -> AppResult<impl axum::response::IntoResponse> {
|
||||
let mut conn = state.db()?;
|
||||
|
||||
let usage: i64 = document_tags::table
|
||||
.filter(document_tags::tag_id.eq(tag_id))
|
||||
.filter(document_tags::tenant_id.eq(tenant_id))
|
||||
.select(count_star())
|
||||
.first(&mut conn)?;
|
||||
|
||||
@@ -264,15 +226,10 @@ pub async fn delete_tag(
|
||||
));
|
||||
}
|
||||
|
||||
let deleted = diesel::delete(
|
||||
tags::table
|
||||
.find(tag_id)
|
||||
.filter(tags::tenant_id.eq(tenant_id)),
|
||||
)
|
||||
.execute(&mut conn)?;
|
||||
let deleted = diesel::delete(tags::table.find(tag_id)).execute(&mut conn)?;
|
||||
if deleted == 0 {
|
||||
return Err(AppError::not_found());
|
||||
}
|
||||
|
||||
no_content()
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
+129
-144
@@ -8,7 +8,6 @@ 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};
|
||||
@@ -16,23 +15,20 @@ use quick_xml::events::{BytesDecl, BytesEnd, BytesStart, BytesText, Event};
|
||||
use quick_xml::Writer;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::webdav_tokens::{find_active_token_by_secret, touch_webdav_token};
|
||||
use crate::auth::password;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::models::{Document, DocumentVersion, Folder, User};
|
||||
use crate::schema::{
|
||||
document_versions::dsl as document_versions_dsl, documents::dsl as documents_dsl,
|
||||
folders::dsl as folders_dsl, tenants::dsl as tenant_dsl,
|
||||
user_memberships::dsl as memberships_dsl, users::dsl as users_dsl,
|
||||
folders::dsl as folders_dsl, users::dsl as users_dsl,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
use crate::utils::{http::inline_content_disposition, time::to_http_date};
|
||||
|
||||
const REALM: &str = "Papercrate WebDAV";
|
||||
const DOWNLOAD_URL_TTL_SECONDS: u64 = 300;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct WebDavContext {
|
||||
tenant_id: Uuid,
|
||||
struct WebDavUser {
|
||||
_user_id: Uuid,
|
||||
_username: String,
|
||||
}
|
||||
@@ -72,7 +68,7 @@ async fn handle_propfind(
|
||||
path: &str,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Response, AppError> {
|
||||
let context = match authenticate(state, &headers)? {
|
||||
let _user = match authenticate(state, &headers)? {
|
||||
Some(user) => user,
|
||||
None => return Ok(unauthorized_response()),
|
||||
};
|
||||
@@ -83,29 +79,25 @@ async fn handle_propfind(
|
||||
};
|
||||
|
||||
let segments = parse_segments(path)?;
|
||||
let resolution = match resolve_path(state, &segments)? {
|
||||
Some(resolved) => resolved,
|
||||
None => return Ok(not_found_response()),
|
||||
};
|
||||
|
||||
let tenant_id = context.tenant_id;
|
||||
|
||||
let resources = if segments.is_empty() {
|
||||
let contents = fetch_folder_contents(state, tenant_id, None)?;
|
||||
build_resources_for_folder(None, &[], &contents, depth)
|
||||
} else {
|
||||
let resolution = match resolve_path(state, tenant_id, &segments)? {
|
||||
Some(resolved) => resolved,
|
||||
None => return Ok(not_found_response()),
|
||||
};
|
||||
|
||||
match resolution {
|
||||
ResolvedPath::Folder { folder, chain } => {
|
||||
let contents = fetch_folder_contents(state, tenant_id, Some(folder.id))?;
|
||||
build_resources_for_folder(Some(&folder), &chain, &contents, depth)
|
||||
}
|
||||
ResolvedPath::Document {
|
||||
document,
|
||||
version,
|
||||
chain,
|
||||
} => build_resources_for_document(&chain, &document, &version),
|
||||
let resources = match resolution {
|
||||
ResolvedPath::Root => {
|
||||
let contents = fetch_folder_contents(state, None)?;
|
||||
build_resources_for_folder(None, &[], &contents, depth)
|
||||
}
|
||||
ResolvedPath::Folder { folder, chain } => {
|
||||
let contents = fetch_folder_contents(state, Some(folder.id))?;
|
||||
build_resources_for_folder(Some(&folder), &chain, &contents, depth)
|
||||
}
|
||||
ResolvedPath::Document {
|
||||
document,
|
||||
version,
|
||||
chain,
|
||||
} => build_resources_for_document(&chain, &document, &version),
|
||||
};
|
||||
|
||||
let body = render_multistatus(&resources)
|
||||
@@ -126,18 +118,13 @@ async fn handle_get_or_head(
|
||||
headers: HeaderMap,
|
||||
method: Method,
|
||||
) -> Result<Response, AppError> {
|
||||
let context = match authenticate(state, &headers)? {
|
||||
let _user = match authenticate(state, &headers)? {
|
||||
Some(user) => user,
|
||||
None => return Ok(unauthorized_response()),
|
||||
};
|
||||
|
||||
let tenant_id = context.tenant_id;
|
||||
let segments = parse_segments(path)?;
|
||||
if segments.is_empty() {
|
||||
return Ok(method_not_allowed());
|
||||
}
|
||||
|
||||
let resolution = match resolve_path(state, tenant_id, &segments)? {
|
||||
let resolution = match resolve_path(state, &segments)? {
|
||||
Some(resolved) => resolved,
|
||||
None => return Ok(not_found_response()),
|
||||
};
|
||||
@@ -232,29 +219,21 @@ fn parse_segments(path: &str) -> AppResult<Vec<String>> {
|
||||
|
||||
fn fetch_folder_contents(
|
||||
state: &AppState,
|
||||
tenant_id: Uuid,
|
||||
folder_id: Option<Uuid>,
|
||||
) -> AppResult<WebDavFolderContents> {
|
||||
let mut conn = state.db_for_tenant(tenant_id)?;
|
||||
let mut conn = state.db()?;
|
||||
|
||||
let folder = match folder_id {
|
||||
Some(id) => Some(
|
||||
folders_dsl::folders
|
||||
.filter(folders_dsl::tenant_id.eq(tenant_id))
|
||||
.find(id)
|
||||
.first::<Folder>(&mut conn)?,
|
||||
),
|
||||
Some(id) => Some(folders_dsl::folders.find(id).first::<Folder>(&mut conn)?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let subfolders: Vec<Folder> = match folder_id {
|
||||
Some(id) => folders_dsl::folders
|
||||
.filter(folders_dsl::tenant_id.eq(tenant_id))
|
||||
.filter(folders_dsl::parent_id.eq(Some(id)))
|
||||
.order(folders_dsl::name.asc())
|
||||
.load(&mut conn)?,
|
||||
None => folders_dsl::folders
|
||||
.filter(folders_dsl::tenant_id.eq(tenant_id))
|
||||
.filter(folders_dsl::parent_id.is_null())
|
||||
.order(folders_dsl::name.asc())
|
||||
.load(&mut conn)?,
|
||||
@@ -262,7 +241,6 @@ fn fetch_folder_contents(
|
||||
|
||||
let mut docs_query = documents_dsl::documents
|
||||
.filter(documents_dsl::deleted_at.is_null())
|
||||
.filter(documents_dsl::tenant_id.eq(tenant_id))
|
||||
.into_boxed();
|
||||
|
||||
docs_query = match folder_id {
|
||||
@@ -312,9 +290,8 @@ async fn stream_document(
|
||||
) -> Result<Response, AppError> {
|
||||
let range_header = headers.get(header::RANGE).cloned();
|
||||
|
||||
let storage = state.storage_for_tenant(document.tenant_id)?;
|
||||
|
||||
let url = storage
|
||||
let url = state
|
||||
.storage
|
||||
.presign_get_object(
|
||||
&version.s3_key,
|
||||
Duration::from_secs(DOWNLOAD_URL_TTL_SECONDS),
|
||||
@@ -361,7 +338,7 @@ async fn stream_document(
|
||||
|
||||
builder = builder.header("Accept-Ranges", "bytes");
|
||||
|
||||
if let Some(disposition) = inline_content_disposition(&document.filename) {
|
||||
if let Some(disposition) = content_disposition(&document.filename) {
|
||||
builder = builder.header(header::CONTENT_DISPOSITION, disposition);
|
||||
}
|
||||
|
||||
@@ -383,7 +360,7 @@ async fn stream_document(
|
||||
.map_err(|err| AppError::internal(format!("failed to build response: {err}")))
|
||||
}
|
||||
|
||||
fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavContext>, AppError> {
|
||||
fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavUser>, AppError> {
|
||||
tracing::debug!("webdav authenticate invoked");
|
||||
let authorization = match headers.get(header::AUTHORIZATION) {
|
||||
Some(value) => match value.to_str() {
|
||||
@@ -422,13 +399,13 @@ fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavCo
|
||||
}
|
||||
};
|
||||
|
||||
let (username, secret) = match credential_str.split_once(':') {
|
||||
Some((username, secret)) if !username.is_empty() => (username, secret),
|
||||
let (username, password) = match credential_str.split_once(':') {
|
||||
Some((username, password)) if !username.is_empty() => (username, password),
|
||||
_ => return Ok(None),
|
||||
};
|
||||
|
||||
tracing::debug!(%username, "attempting webdav login");
|
||||
let mut conn = state.db_unscoped()?;
|
||||
let mut conn = state.db()?;
|
||||
|
||||
let user: User = match users_dsl::users
|
||||
.filter(users_dsl::username.eq(username))
|
||||
@@ -442,44 +419,16 @@ fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavCo
|
||||
Err(err) => return Err(AppError::from(err)),
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
let valid = password::verify_password(password, &user.password_hash)
|
||||
.map_err(|_| AppError::internal("failed to verify password"))?;
|
||||
|
||||
let tenant_row = memberships_dsl::user_memberships
|
||||
.inner_join(tenant_dsl::tenants)
|
||||
.filter(memberships_dsl::user_id.eq(user.id))
|
||||
.filter(memberships_dsl::tenant_id.eq(token.tenant_id))
|
||||
.select((tenant_dsl::id, tenant_dsl::slug))
|
||||
.first::<(Uuid, String)>(&mut conn)
|
||||
.optional()?;
|
||||
if !valid {
|
||||
tracing::warn!(%username, "webdav password invalid");
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let (tenant_id, _slug) = match tenant_row {
|
||||
Some(row) => row,
|
||||
None => {
|
||||
tracing::warn!(
|
||||
%username,
|
||||
tenant_id = %token.tenant_id,
|
||||
"webdav token tenant membership missing"
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
|
||||
touch_webdav_token(&mut conn, token.id)?;
|
||||
|
||||
tracing::debug!(
|
||||
%username,
|
||||
tenant_id = %tenant_id,
|
||||
token_id = %token.id,
|
||||
"webdav token login success"
|
||||
);
|
||||
Ok(Some(WebDavContext {
|
||||
tenant_id,
|
||||
tracing::debug!(%username, "webdav login success");
|
||||
Ok(Some(WebDavUser {
|
||||
_user_id: user.id,
|
||||
_username: user.username,
|
||||
}))
|
||||
@@ -495,10 +444,10 @@ fn build_resources_for_folder(
|
||||
|
||||
let display_name = folder
|
||||
.map(|folder| folder.name.clone())
|
||||
.unwrap_or_else(|| chain.last().cloned().unwrap_or_else(|| "/".to_string()));
|
||||
.unwrap_or_else(|| "/".to_string());
|
||||
|
||||
let href = build_href(chain, true);
|
||||
let last_modified = folder.map(|folder| to_http_date(folder.updated_at));
|
||||
let last_modified = folder.map(|folder| format_http_date(folder.updated_at));
|
||||
|
||||
resources.push(DavResource {
|
||||
href,
|
||||
@@ -522,7 +471,7 @@ fn build_resources_for_folder(
|
||||
is_collection: true,
|
||||
content_length: None,
|
||||
content_type: None,
|
||||
last_modified: Some(to_http_date(subfolder.updated_at)),
|
||||
last_modified: Some(format_http_date(subfolder.updated_at)),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -560,7 +509,7 @@ fn document_to_resource(
|
||||
is_collection: false,
|
||||
content_length: Some(version.size_bytes),
|
||||
content_type: document.content_type.clone(),
|
||||
last_modified: Some(to_http_date(document.updated_at)),
|
||||
last_modified: Some(format_http_date(document.updated_at)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -641,6 +590,32 @@ fn render_multistatus(resources: &[DavResource]) -> Result<Vec<u8>, quick_xml::E
|
||||
Ok(writer.into_inner())
|
||||
}
|
||||
|
||||
fn format_http_date(value: chrono::NaiveDateTime) -> String {
|
||||
let datetime = chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(value, chrono::Utc);
|
||||
datetime.format("%a, %d %b %Y %H:%M:%S GMT").to_string()
|
||||
}
|
||||
|
||||
fn content_disposition(filename: &str) -> Option<String> {
|
||||
if filename.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let sanitized: String = filename
|
||||
.chars()
|
||||
.map(|ch| match ch {
|
||||
'"' | '\\' => '_',
|
||||
_ => ch,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let encoded =
|
||||
percent_encoding::utf8_percent_encode(&sanitized, percent_encoding::NON_ALPHANUMERIC);
|
||||
Some(format!(
|
||||
"inline; filename=\"{}\"; filename*=UTF-8''{}",
|
||||
sanitized, encoded
|
||||
))
|
||||
}
|
||||
|
||||
struct WebDavFolderContents {
|
||||
_folder: Option<Folder>,
|
||||
subfolders: Vec<Folder>,
|
||||
@@ -660,7 +635,9 @@ struct DavResource {
|
||||
content_type: Option<String>,
|
||||
last_modified: Option<String>,
|
||||
}
|
||||
|
||||
enum ResolvedPath {
|
||||
Root,
|
||||
Folder {
|
||||
folder: Folder,
|
||||
chain: Vec<String>,
|
||||
@@ -672,12 +649,12 @@ enum ResolvedPath {
|
||||
},
|
||||
}
|
||||
|
||||
fn resolve_path(
|
||||
state: &AppState,
|
||||
tenant_id: Uuid,
|
||||
segments: &[String],
|
||||
) -> AppResult<Option<ResolvedPath>> {
|
||||
let mut conn = state.db_for_tenant(tenant_id)?;
|
||||
fn resolve_path(state: &AppState, segments: &[String]) -> AppResult<Option<ResolvedPath>> {
|
||||
if segments.is_empty() {
|
||||
return Ok(Some(ResolvedPath::Root));
|
||||
}
|
||||
|
||||
let mut conn = state.db()?;
|
||||
let mut parent_id: Option<Uuid> = None;
|
||||
let mut chain: Vec<String> = Vec::new();
|
||||
let mut current_folder: Option<Folder> = None;
|
||||
@@ -685,19 +662,24 @@ fn resolve_path(
|
||||
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_id, parent_id, segment)? {
|
||||
chain.push(folder.name.clone());
|
||||
if is_last {
|
||||
return Ok(Some(ResolvedPath::Folder { folder, chain }));
|
||||
match find_folder_by_name(&mut conn, parent_id, segment)? {
|
||||
Some(folder) => {
|
||||
if is_last {
|
||||
chain.push(folder.name.clone());
|
||||
return Ok(Some(ResolvedPath::Folder { folder, chain }));
|
||||
}
|
||||
|
||||
parent_id = Some(folder.id);
|
||||
chain.push(folder.name.clone());
|
||||
current_folder = Some(folder);
|
||||
continue;
|
||||
}
|
||||
parent_id = Some(folder.id);
|
||||
current_folder = Some(folder);
|
||||
continue;
|
||||
None => {}
|
||||
}
|
||||
|
||||
if is_last {
|
||||
if let Some((document, version)) =
|
||||
find_document_by_filename(&mut conn, tenant_id, parent_id, segment)?
|
||||
find_document_by_filename(&mut conn, parent_id, segment)?
|
||||
{
|
||||
chain.push(document.filename.clone());
|
||||
return Ok(Some(ResolvedPath::Document {
|
||||
@@ -709,20 +691,26 @@ fn resolve_path(
|
||||
}
|
||||
|
||||
if let Ok(uuid) = Uuid::parse_str(segment) {
|
||||
if let Some(folder) = find_folder_by_id(&mut conn, tenant_id, uuid)? {
|
||||
if let Some(folder) = folders_dsl::folders
|
||||
.find(uuid)
|
||||
.first::<Folder>(&mut conn)
|
||||
.optional()?
|
||||
{
|
||||
if folder.parent_id != parent_id {
|
||||
return Ok(None);
|
||||
}
|
||||
chain.push(folder.name.clone());
|
||||
if is_last {
|
||||
if !is_last {
|
||||
parent_id = Some(folder.id);
|
||||
chain.push(folder.name.clone());
|
||||
current_folder = Some(folder);
|
||||
continue;
|
||||
} else {
|
||||
chain.push(folder.name.clone());
|
||||
return Ok(Some(ResolvedPath::Folder { folder, chain }));
|
||||
}
|
||||
parent_id = Some(folder.id);
|
||||
current_folder = Some(folder);
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some((document, version)) = find_document_by_id(&mut conn, tenant_id, uuid)? {
|
||||
if let Some((document, version)) = find_document_by_id(&mut conn, uuid)? {
|
||||
if document.folder_id != parent_id {
|
||||
return Ok(None);
|
||||
}
|
||||
@@ -735,6 +723,19 @@ fn resolve_path(
|
||||
}
|
||||
}
|
||||
|
||||
if is_last {
|
||||
if let Some((document, version)) =
|
||||
find_document_by_filename(&mut conn, parent_id, segment)?
|
||||
{
|
||||
chain.push(document.filename.clone());
|
||||
return Ok(Some(ResolvedPath::Document {
|
||||
document,
|
||||
version,
|
||||
chain,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
@@ -743,46 +744,32 @@ fn resolve_path(
|
||||
|
||||
fn find_folder_by_name(
|
||||
conn: &mut PgConnection,
|
||||
tenant_id: Uuid,
|
||||
parent_id: Option<Uuid>,
|
||||
name: &str,
|
||||
) -> AppResult<Option<Folder>> {
|
||||
let mut query = folders_dsl::folders
|
||||
.filter(folders_dsl::tenant_id.eq(tenant_id))
|
||||
.into_boxed();
|
||||
|
||||
query = match parent_id {
|
||||
Some(parent) => query.filter(folders_dsl::parent_id.eq(Some(parent))),
|
||||
None => query.filter(folders_dsl::parent_id.is_null()),
|
||||
let result = match parent_id {
|
||||
Some(parent) => folders_dsl::folders
|
||||
.filter(folders_dsl::parent_id.eq(Some(parent)))
|
||||
.filter(folders_dsl::name.eq(name))
|
||||
.first::<Folder>(conn)
|
||||
.optional()?,
|
||||
None => folders_dsl::folders
|
||||
.filter(folders_dsl::parent_id.is_null())
|
||||
.filter(folders_dsl::name.eq(name))
|
||||
.first::<Folder>(conn)
|
||||
.optional()?,
|
||||
};
|
||||
|
||||
Ok(query
|
||||
.filter(folders_dsl::name.eq(name))
|
||||
.first::<Folder>(conn)
|
||||
.optional()?)
|
||||
}
|
||||
|
||||
fn find_folder_by_id(
|
||||
conn: &mut PgConnection,
|
||||
tenant_id: Uuid,
|
||||
folder_id: Uuid,
|
||||
) -> AppResult<Option<Folder>> {
|
||||
Ok(folders_dsl::folders
|
||||
.filter(folders_dsl::tenant_id.eq(tenant_id))
|
||||
.find(folder_id)
|
||||
.first::<Folder>(conn)
|
||||
.optional()?)
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn find_document_by_filename(
|
||||
conn: &mut PgConnection,
|
||||
tenant_id: Uuid,
|
||||
parent_id: Option<Uuid>,
|
||||
filename: &str,
|
||||
) -> AppResult<Option<(Document, DocumentVersion)>> {
|
||||
let mut query = documents_dsl::documents
|
||||
.filter(documents_dsl::deleted_at.is_null())
|
||||
.filter(documents_dsl::tenant_id.eq(tenant_id))
|
||||
.filter(documents_dsl::filename.eq(filename))
|
||||
.into_boxed();
|
||||
|
||||
@@ -803,12 +790,10 @@ fn find_document_by_filename(
|
||||
|
||||
fn find_document_by_id(
|
||||
conn: &mut PgConnection,
|
||||
tenant_id: Uuid,
|
||||
document_id: Uuid,
|
||||
) -> AppResult<Option<(Document, DocumentVersion)>> {
|
||||
if let Some(document) = documents_dsl::documents
|
||||
.filter(documents_dsl::deleted_at.is_null())
|
||||
.filter(documents_dsl::tenant_id.eq(tenant_id))
|
||||
.find(document_id)
|
||||
.first::<Document>(conn)
|
||||
.optional()?
|
||||
|
||||
+7
-82
@@ -8,18 +8,6 @@ diesel::table! {
|
||||
metadata -> Jsonb,
|
||||
created_at -> Timestamptz,
|
||||
updated_at -> Timestamptz,
|
||||
tenant_id -> Uuid,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
document_asset_objects (id) {
|
||||
id -> Uuid,
|
||||
asset_id -> Uuid,
|
||||
ordinal -> Int4,
|
||||
s3_key -> Text,
|
||||
metadata -> Jsonb,
|
||||
tenant_id -> Uuid,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,21 +16,21 @@ diesel::table! {
|
||||
id -> Uuid,
|
||||
document_version_id -> Uuid,
|
||||
asset_type -> Text,
|
||||
s3_key -> Text,
|
||||
mime_type -> Text,
|
||||
metadata -> Jsonb,
|
||||
created_at -> Timestamptz,
|
||||
cardinality -> Nullable<Int4>,
|
||||
tenant_id -> Uuid,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
document_correspondents (document_id, correspondent_id) {
|
||||
document_correspondents (document_id, correspondent_id, role) {
|
||||
document_id -> Uuid,
|
||||
correspondent_id -> Uuid,
|
||||
#[max_length = 32]
|
||||
role -> Varchar,
|
||||
assigned_at -> Timestamptz,
|
||||
assigned_by -> Nullable<Uuid>,
|
||||
tenant_id -> Uuid,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +40,6 @@ diesel::table! {
|
||||
tag_id -> Uuid,
|
||||
assigned_at -> Timestamptz,
|
||||
assigned_by -> Nullable<Uuid>,
|
||||
tenant_id -> Uuid,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,8 +54,8 @@ diesel::table! {
|
||||
#[max_length = 64]
|
||||
checksum -> Varchar,
|
||||
created_at -> Timestamptz,
|
||||
operations_summary -> Jsonb,
|
||||
metadata -> Jsonb,
|
||||
tenant_id -> Uuid,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,7 +77,6 @@ diesel::table! {
|
||||
#[max_length = 255]
|
||||
title -> Varchar,
|
||||
current_version_id -> Uuid,
|
||||
tenant_id -> Uuid,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,7 +88,6 @@ diesel::table! {
|
||||
parent_id -> Nullable<Uuid>,
|
||||
created_at -> Timestamptz,
|
||||
updated_at -> Timestamptz,
|
||||
tenant_id -> Uuid,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,7 +102,6 @@ diesel::table! {
|
||||
last_error -> Nullable<Text>,
|
||||
created_at -> Timestamptz,
|
||||
updated_at -> Timestamptz,
|
||||
tenant_id -> Uuid,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,7 +115,6 @@ diesel::table! {
|
||||
revoked_at -> Nullable<Timestamptz>,
|
||||
created_at -> Timestamptz,
|
||||
updated_at -> Timestamptz,
|
||||
tenant_id -> Uuid,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,31 +126,6 @@ diesel::table! {
|
||||
#[max_length = 7]
|
||||
color -> Nullable<Varchar>,
|
||||
created_at -> Timestamptz,
|
||||
tenant_id -> Uuid,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
tenants (id) {
|
||||
id -> Uuid,
|
||||
slug -> Text,
|
||||
storage_root -> Nullable<Text>,
|
||||
quickwit_index -> Nullable<Text>,
|
||||
status -> Text,
|
||||
config -> Jsonb,
|
||||
created_at -> Timestamptz,
|
||||
updated_at -> Timestamptz,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
user_memberships (id) {
|
||||
id -> Uuid,
|
||||
user_id -> Uuid,
|
||||
tenant_id -> Uuid,
|
||||
role -> Text,
|
||||
created_at -> Timestamptz,
|
||||
updated_at -> Timestamptz,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,55 +136,25 @@ diesel::table! {
|
||||
username -> Varchar,
|
||||
#[max_length = 255]
|
||||
password_hash -> Varchar,
|
||||
#[max_length = 16]
|
||||
role -> Varchar,
|
||||
created_at -> Timestamptz,
|
||||
updated_at -> Timestamptz,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
webdav_tokens (id) {
|
||||
id -> Uuid,
|
||||
user_id -> Uuid,
|
||||
tenant_id -> Uuid,
|
||||
token_prefix -> Text,
|
||||
token_hash -> Text,
|
||||
label -> Nullable<Text>,
|
||||
created_at -> Timestamptz,
|
||||
last_used_at -> Nullable<Timestamptz>,
|
||||
expires_at -> Nullable<Timestamptz>,
|
||||
revoked_at -> Nullable<Timestamptz>,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::joinable!(correspondents -> tenants (tenant_id));
|
||||
diesel::joinable!(document_asset_objects -> document_assets (asset_id));
|
||||
diesel::joinable!(document_asset_objects -> tenants (tenant_id));
|
||||
diesel::joinable!(document_assets -> document_versions (document_version_id));
|
||||
diesel::joinable!(document_assets -> tenants (tenant_id));
|
||||
diesel::joinable!(document_correspondents -> correspondents (correspondent_id));
|
||||
diesel::joinable!(document_correspondents -> documents (document_id));
|
||||
diesel::joinable!(document_correspondents -> tenants (tenant_id));
|
||||
diesel::joinable!(document_correspondents -> users (assigned_by));
|
||||
diesel::joinable!(document_tags -> documents (document_id));
|
||||
diesel::joinable!(document_tags -> tags (tag_id));
|
||||
diesel::joinable!(document_tags -> tenants (tenant_id));
|
||||
diesel::joinable!(document_tags -> users (assigned_by));
|
||||
diesel::joinable!(document_versions -> tenants (tenant_id));
|
||||
diesel::joinable!(documents -> folders (folder_id));
|
||||
diesel::joinable!(documents -> tenants (tenant_id));
|
||||
diesel::joinable!(folders -> tenants (tenant_id));
|
||||
diesel::joinable!(jobs -> tenants (tenant_id));
|
||||
diesel::joinable!(refresh_tokens -> tenants (tenant_id));
|
||||
diesel::joinable!(refresh_tokens -> users (user_id));
|
||||
diesel::joinable!(tags -> tenants (tenant_id));
|
||||
diesel::joinable!(user_memberships -> tenants (tenant_id));
|
||||
diesel::joinable!(user_memberships -> users (user_id));
|
||||
diesel::joinable!(webdav_tokens -> tenants (tenant_id));
|
||||
diesel::joinable!(webdav_tokens -> users (user_id));
|
||||
|
||||
diesel::allow_tables_to_appear_in_same_query!(
|
||||
correspondents,
|
||||
document_asset_objects,
|
||||
document_assets,
|
||||
document_correspondents,
|
||||
document_tags,
|
||||
@@ -236,8 +164,5 @@ diesel::allow_tables_to_appear_in_same_query!(
|
||||
jobs,
|
||||
refresh_tokens,
|
||||
tags,
|
||||
tenants,
|
||||
user_memberships,
|
||||
users,
|
||||
webdav_tokens,
|
||||
);
|
||||
|
||||
+5
-41
@@ -4,79 +4,43 @@ use diesel::{
|
||||
pg::PgConnection,
|
||||
r2d2::{ConnectionManager, PooledConnection},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
auth::jwt::JwtService,
|
||||
config::AppConfig,
|
||||
db::PgPool,
|
||||
error::{AppError, AppResult},
|
||||
storage::{ObjectStorage, TenantStorage},
|
||||
tenants::{apply_tenant_guc, TenantService},
|
||||
storage::ObjectStorage,
|
||||
};
|
||||
|
||||
pub type PgPooledConnection = PooledConnection<ConnectionManager<PgConnection>>;
|
||||
type PgPooledConnection = PooledConnection<ConnectionManager<PgConnection>>;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub pool: PgPool,
|
||||
pub config: Arc<AppConfig>,
|
||||
storage: Arc<dyn ObjectStorage>,
|
||||
pub storage: Arc<dyn ObjectStorage>,
|
||||
pub jwt: JwtService,
|
||||
pub tenants: TenantService,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub async fn initialize(
|
||||
config: AppConfig,
|
||||
pool_size_override: Option<u32>,
|
||||
) -> anyhow::Result<Self> {
|
||||
let pool_size = pool_size_override.unwrap_or(config.database_max_pool_size);
|
||||
let pool = crate::db::init_pool_with_size(&config.database_url, pool_size)?;
|
||||
let s3_client = crate::s3::build_client(&config).await?;
|
||||
let storage = Arc::new(crate::storage::S3Storage::new(
|
||||
s3_client,
|
||||
config.s3_bucket.clone(),
|
||||
));
|
||||
let jwt = crate::auth::jwt::JwtService::from_config(&config)?;
|
||||
|
||||
Ok(Self::new(pool, config, storage, jwt))
|
||||
}
|
||||
|
||||
pub fn new(
|
||||
pool: PgPool,
|
||||
config: AppConfig,
|
||||
storage: Arc<dyn ObjectStorage>,
|
||||
jwt: JwtService,
|
||||
) -> Self {
|
||||
let config = Arc::new(config);
|
||||
let tenants = TenantService::new(pool.clone());
|
||||
|
||||
Self {
|
||||
pool,
|
||||
config,
|
||||
config: Arc::new(config),
|
||||
storage,
|
||||
jwt,
|
||||
tenants,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn db_for_tenant(&self, tenant_id: Uuid) -> AppResult<PgPooledConnection> {
|
||||
debug_assert!(!tenant_id.is_nil(), "nil tenant_id passed to db_for_tenant");
|
||||
let mut conn = self.db_unscoped()?;
|
||||
apply_tenant_guc(&mut conn, tenant_id)?;
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
pub(crate) fn db_unscoped(&self) -> AppResult<PgPooledConnection> {
|
||||
pub fn db(&self) -> AppResult<PgPooledConnection> {
|
||||
self.pool
|
||||
.get()
|
||||
.map_err(|err| AppError::internal(format!("database pool error: {err}")))
|
||||
}
|
||||
|
||||
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}")))
|
||||
}
|
||||
}
|
||||
|
||||
+1
-54
@@ -1,15 +1,11 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use anyhow::{Context, Result};
|
||||
use async_trait::async_trait;
|
||||
use aws_sdk_s3::presigning::PresigningConfig;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::Client as S3Client;
|
||||
|
||||
use crate::models::Tenant;
|
||||
|
||||
#[async_trait]
|
||||
pub trait ObjectStorage: Send + Sync + 'static {
|
||||
async fn put_object(
|
||||
@@ -123,52 +119,3 @@ impl ObjectStorage for S3Storage {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
#[derive(Clone)]
|
||||
pub struct TenantStorage {
|
||||
inner: Arc<dyn ObjectStorage>,
|
||||
root: String,
|
||||
}
|
||||
|
||||
impl TenantStorage {
|
||||
pub fn new(inner: Arc<dyn ObjectStorage>, tenant: &Tenant) -> Result<Self> {
|
||||
let root = tenant
|
||||
.storage_root
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow!("tenant {} missing storage_root", tenant.id))?
|
||||
.to_owned();
|
||||
|
||||
Ok(Self { inner, root })
|
||||
}
|
||||
|
||||
fn qualify(&self, key: &str) -> String {
|
||||
format!("{}{}", self.root, key)
|
||||
}
|
||||
|
||||
pub async fn put_object(
|
||||
&self,
|
||||
key: &str,
|
||||
bytes: Vec<u8>,
|
||||
content_type: Option<String>,
|
||||
content_disposition: Option<String>,
|
||||
) -> Result<()> {
|
||||
let qualified = self.qualify(key);
|
||||
self.inner
|
||||
.put_object(&qualified, bytes, content_type, content_disposition)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn presign_get_object(&self, key: &str, expires_in: Duration) -> Result<String> {
|
||||
let qualified = self.qualify(key);
|
||||
self.inner.presign_get_object(&qualified, expires_in).await
|
||||
}
|
||||
|
||||
pub async fn get_object(&self, key: &str) -> Result<Vec<u8>> {
|
||||
let qualified = self.qualify(key);
|
||||
self.inner.get_object(&qualified).await
|
||||
}
|
||||
|
||||
pub async fn delete_object(&self, key: &str) -> Result<()> {
|
||||
let qualified = self.qualify(key);
|
||||
self.inner.delete_object(&qualified).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
use axum::{async_trait, extract::FromRequestParts, http::request::Parts};
|
||||
use diesel::{pg::PgConnection, prelude::*, sql_types::Text};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
db::PgPool,
|
||||
error::{AppError, AppResult},
|
||||
models::Tenant,
|
||||
schema::tenants::dsl,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
pub struct TenantRepository;
|
||||
|
||||
impl TenantRepository {
|
||||
pub fn get_by_id(conn: &mut PgConnection, tenant_id: Uuid) -> AppResult<Tenant> {
|
||||
dsl::tenants.find(tenant_id).first(conn).map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn get_by_slug(conn: &mut PgConnection, slug: &str) -> AppResult<Tenant> {
|
||||
dsl::tenants
|
||||
.filter(dsl::slug.eq(slug))
|
||||
.first(conn)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TenantService {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl TenantService {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
pub fn get_by_id(&self, tenant_id: Uuid) -> AppResult<Tenant> {
|
||||
let tenant = self.load(|conn| TenantRepository::get_by_id(conn, tenant_id))?;
|
||||
Ok(tenant)
|
||||
}
|
||||
|
||||
pub fn get_by_slug(&self, slug: &str) -> AppResult<Tenant> {
|
||||
let slug_owned = slug.to_owned();
|
||||
let tenant = self.load(|conn| TenantRepository::get_by_slug(conn, &slug_owned))?;
|
||||
Ok(tenant)
|
||||
}
|
||||
|
||||
pub fn tenant_id_for_slug(&self, slug: &str) -> AppResult<Uuid> {
|
||||
Ok(self.get_by_slug(slug)?.id)
|
||||
}
|
||||
|
||||
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 tenant = loader(&mut conn)?;
|
||||
Ok(tenant)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply_tenant_guc(conn: &mut PgConnection, tenant_id: Uuid) -> AppResult<()> {
|
||||
diesel::sql_query("SELECT set_config('papercrate.tenant_id', $1, true)")
|
||||
.bind::<Text, _>(tenant_id.to_string())
|
||||
.execute(conn)
|
||||
.map(|_| ())
|
||||
.map_err(AppError::from)
|
||||
}
|
||||
|
||||
pub struct TenantContext {
|
||||
pub tenant: Tenant,
|
||||
}
|
||||
|
||||
#[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 })
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::{config::AppConfig, state::AppState, utils::tracing::init_tracing};
|
||||
|
||||
/// Initialize tracing, load configuration, and build the shared `AppState`.
|
||||
/// Optionally override the connection pool size for lightweight components.
|
||||
pub async fn init_component(name: &str, pool_override: Option<u32>) -> Result<Arc<AppState>> {
|
||||
init_tracing("info");
|
||||
let config = AppConfig::load_and_log(name)?;
|
||||
let state = AppState::initialize(config, pool_override).await?;
|
||||
Ok(Arc::new(state))
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
use diesel::{pg::PgConnection, result::Error as DieselError};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
error::{AppError, AppResult},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
pub trait EnsureEntity<T> {
|
||||
fn one(self) -> AppResult<T>;
|
||||
fn maybe(self) -> AppResult<Option<T>>;
|
||||
}
|
||||
|
||||
impl<T> EnsureEntity<T> for Result<T, DieselError> {
|
||||
fn one(self) -> AppResult<T> {
|
||||
self.map_err(AppError::from)
|
||||
}
|
||||
|
||||
fn maybe(self) -> AppResult<Option<T>> {
|
||||
match self {
|
||||
Ok(value) => Ok(Some(value)),
|
||||
Err(DieselError::NotFound) => Ok(None),
|
||||
Err(err) => Err(AppError::from(err)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn with_tenant_conn<F, T>(&self, tenant_id: Uuid, f: F) -> AppResult<T>
|
||||
where
|
||||
F: FnOnce(&mut PgConnection) -> AppResult<T>,
|
||||
{
|
||||
let mut conn = self.db_for_tenant(tenant_id)?;
|
||||
f(&mut conn)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_bulk_ids(ids: &mut Vec<Uuid>, label: &str) -> AppResult<()> {
|
||||
if ids.is_empty() {
|
||||
return Err(AppError::bad_request(format!("{label} must not be empty")));
|
||||
}
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub trait IntoJsonResponse<T> {
|
||||
fn into_json(self) -> AppResult<axum::Json<T>>;
|
||||
}
|
||||
|
||||
impl<T> IntoJsonResponse<T> for T {
|
||||
fn into_json(self) -> AppResult<axum::Json<T>> {
|
||||
Ok(axum::Json(self))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn no_content() -> AppResult<axum::http::StatusCode> {
|
||||
Ok(axum::http::StatusCode::NO_CONTENT)
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
|
||||
|
||||
/// Build an inline `Content-Disposition` header value for a given filename.
|
||||
pub fn inline_content_disposition(filename: &str) -> Option<String> {
|
||||
if filename.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let sanitized: String = filename
|
||||
.chars()
|
||||
.map(|ch| match ch {
|
||||
'"' | '\\' => '_',
|
||||
_ => ch,
|
||||
})
|
||||
.collect();
|
||||
let encoded = utf8_percent_encode(&sanitized, NON_ALPHANUMERIC);
|
||||
|
||||
Some(format!(
|
||||
"inline; filename=\"{}\"; filename*=UTF-8''{}",
|
||||
sanitized, encoded
|
||||
))
|
||||
}
|
||||
@@ -1,8 +1 @@
|
||||
pub mod bootstrap;
|
||||
pub mod db;
|
||||
pub mod http;
|
||||
pub mod json;
|
||||
pub mod storage_paths;
|
||||
pub mod time;
|
||||
pub mod tracing;
|
||||
pub mod validation;
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
//! Document storage path helpers.
|
||||
//!
|
||||
//! NOTE: The path layout produced here is part of the durable storage contract.
|
||||
//! External systems (presigned URLs, lifecycle jobs, migrations) expect the
|
||||
//! `documents/{document_id}/...` structure to remain stable. Coordinate before
|
||||
//! changing any of these helpers to avoid breaking compatibility with existing
|
||||
//! objects.
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
const DOCUMENTS_PREFIX: &str = "documents";
|
||||
|
||||
/// Returns the root prefix for all objects belonging to a document.
|
||||
pub fn document_prefix(document_id: Uuid) -> String {
|
||||
format!("{DOCUMENTS_PREFIX}/{document_id}")
|
||||
}
|
||||
|
||||
/// Returns the prefix for a specific document version (without the object id).
|
||||
pub fn document_version_prefix(document_id: Uuid, version_number: i32) -> String {
|
||||
format!("{}/v{}", document_prefix(document_id), version_number)
|
||||
}
|
||||
|
||||
/// Returns the storage key for a stored document version blob.
|
||||
pub fn document_version_object_key(
|
||||
document_id: Uuid,
|
||||
version_number: i32,
|
||||
version_id: Uuid,
|
||||
) -> String {
|
||||
format!(
|
||||
"{}/{}",
|
||||
document_version_prefix(document_id, version_number),
|
||||
version_id
|
||||
)
|
||||
}
|
||||
|
||||
fn document_asset_prefix(document_id: Uuid, version_number: i32) -> String {
|
||||
format!(
|
||||
"{}/assets",
|
||||
document_version_prefix(document_id, version_number)
|
||||
)
|
||||
}
|
||||
|
||||
fn document_asset_type_prefix(document_id: Uuid, version_number: i32, asset_type: &str) -> String {
|
||||
format!(
|
||||
"{}/{}",
|
||||
document_asset_prefix(document_id, version_number),
|
||||
asset_type
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns the storage prefix under which the asset objects for a type/id pair live.
|
||||
pub fn document_asset_object_prefix(
|
||||
document_id: Uuid,
|
||||
version_number: i32,
|
||||
asset_type: &str,
|
||||
asset_id: Uuid,
|
||||
) -> String {
|
||||
format!(
|
||||
"{}/{}",
|
||||
document_asset_type_prefix(document_id, version_number, asset_type),
|
||||
asset_id
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns the full storage key for a specific asset object (ordinal).
|
||||
pub fn document_asset_object_key(
|
||||
document_id: Uuid,
|
||||
version_number: i32,
|
||||
asset_type: &str,
|
||||
asset_id: Uuid,
|
||||
ordinal: i32,
|
||||
) -> String {
|
||||
format!(
|
||||
"{}/{}",
|
||||
document_asset_object_prefix(document_id, version_number, asset_type, asset_id),
|
||||
ordinal
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn generates_expected_paths() {
|
||||
let document_id = Uuid::nil();
|
||||
let version_id = Uuid::nil();
|
||||
let asset_id = Uuid::nil();
|
||||
|
||||
assert_eq!(
|
||||
document_prefix(document_id),
|
||||
format!("documents/{document_id}")
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
document_version_prefix(document_id, 3),
|
||||
format!("documents/{document_id}/v3")
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
document_version_object_key(document_id, 3, version_id),
|
||||
format!("documents/{document_id}/v3/{version_id}")
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
document_asset_object_prefix(document_id, 3, "preview", asset_id),
|
||||
format!("documents/{document_id}/v3/assets/preview/{asset_id}")
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
document_asset_object_key(document_id, 3, "preview", asset_id, 2),
|
||||
format!("documents/{document_id}/v3/assets/preview/{asset_id}/2")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
use chrono::{DateTime, NaiveDateTime, Utc};
|
||||
|
||||
/// Format a timestamp as RFC3339 using UTC.
|
||||
pub fn to_iso(dt: NaiveDateTime) -> String {
|
||||
DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc).to_rfc3339()
|
||||
}
|
||||
|
||||
/// Format a timestamp for HTTP headers (RFC 7231 date).
|
||||
pub fn to_http_date(dt: NaiveDateTime) -> String {
|
||||
DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc)
|
||||
.format("%a, %d %b %Y %H:%M:%S GMT")
|
||||
.to_string()
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
/// Initialize tracing with an optional default level.
|
||||
///
|
||||
/// Falls back to `default_level` when `RUST_LOG` is not provided.
|
||||
pub fn init_tracing(default_level: &str) {
|
||||
let filter =
|
||||
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default_level));
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(filter)
|
||||
.with_target(false)
|
||||
.compact()
|
||||
.init();
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
use crate::error::{AppError, AppResult};
|
||||
|
||||
/// Ensure an entity exists, returning a bad request error when it does not.
|
||||
pub fn ensure_exists(exists: bool, entity: &str) -> AppResult<()> {
|
||||
if exists {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AppError::bad_request(format!("{entity} does not exist")))
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
use serde_json::{json, Map, Value};
|
||||
use tokio::task;
|
||||
use tracing::{error, warn};
|
||||
use uuid::Uuid;
|
||||
@@ -14,7 +14,6 @@ use crate::{
|
||||
models::{Document, DocumentAsset, DocumentVersion},
|
||||
schema::{document_assets, document_versions, documents},
|
||||
state::AppState,
|
||||
storage::TenantStorage,
|
||||
};
|
||||
|
||||
use super::{JobExecution, JobHandler};
|
||||
@@ -41,12 +40,7 @@ impl JobHandler for AnalyzeDocumentJob {
|
||||
JOB_ANALYZE_DOCUMENT
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
&self,
|
||||
state: Arc<AppState>,
|
||||
job: crate::models::Job,
|
||||
_storage: TenantStorage,
|
||||
) -> JobExecution {
|
||||
async fn handle(&self, state: Arc<AppState>, job: crate::models::Job) -> JobExecution {
|
||||
let payload: AnalyzePayload = match serde_json::from_value(job.payload.clone()) {
|
||||
Ok(payload) => payload,
|
||||
Err(err) => {
|
||||
@@ -57,9 +51,7 @@ impl JobHandler for AnalyzeDocumentJob {
|
||||
};
|
||||
|
||||
let state_clone = state.clone();
|
||||
let tenant_id = job.tenant_id;
|
||||
match task::spawn_blocking(move || analyze_document(state_clone, tenant_id, payload)).await
|
||||
{
|
||||
match task::spawn_blocking(move || analyze_document(state_clone, payload)).await {
|
||||
Ok(Ok(execution)) => execution,
|
||||
Ok(Err(err)) => {
|
||||
warn!(job_id = %job.id, error = %err, "analyze job will retry");
|
||||
@@ -79,14 +71,8 @@ impl JobHandler for AnalyzeDocumentJob {
|
||||
}
|
||||
}
|
||||
|
||||
fn analyze_document(
|
||||
state: Arc<AppState>,
|
||||
tenant_id: Uuid,
|
||||
payload: AnalyzePayload,
|
||||
) -> Result<JobExecution, String> {
|
||||
let mut conn = state
|
||||
.db_for_tenant(tenant_id)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
fn analyze_document(state: Arc<AppState>, payload: AnalyzePayload) -> Result<JobExecution, String> {
|
||||
let mut conn = state.db().map_err(|err| format!("{err:?}"))?;
|
||||
|
||||
let version: DocumentVersion = document_versions::table
|
||||
.find(payload.document_version_id)
|
||||
@@ -102,25 +88,47 @@ fn analyze_document(
|
||||
.first(&mut conn)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
|
||||
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
|
||||
.filter(document_assets::document_version_id.eq(payload.document_version_id))
|
||||
.filter(document_assets::asset_type.eq(OCR_TEXT_ASSET_TYPE))
|
||||
.filter(document_assets::tenant_id.eq(tenant_id))
|
||||
.first(&mut conn)
|
||||
.optional()
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
|
||||
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,
|
||||
tenant_id,
|
||||
JOB_GENERATE_THUMBNAILS,
|
||||
json!({
|
||||
"document_id": payload.document_id,
|
||||
@@ -138,7 +146,6 @@ fn analyze_document(
|
||||
if ocr_supported && !skip_ocr {
|
||||
let enqueue_result = enqueue_job(
|
||||
&mut conn,
|
||||
tenant_id,
|
||||
JOB_GENERATE_OCR_TEXT,
|
||||
json!({
|
||||
"document_id": payload.document_id,
|
||||
|
||||
@@ -12,10 +12,9 @@ use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
jobs::JOB_INDEX_DOCUMENT_TEXT,
|
||||
models::{Document, DocumentVersion},
|
||||
schema::{document_asset_objects, document_assets, document_versions, documents},
|
||||
models::{Document, DocumentAsset, DocumentVersion},
|
||||
schema::{document_assets, document_versions, documents},
|
||||
state::AppState,
|
||||
storage::TenantStorage,
|
||||
};
|
||||
|
||||
use super::{ocr::OCR_TEXT_ASSET_TYPE, JobExecution, JobHandler};
|
||||
@@ -40,12 +39,7 @@ impl JobHandler for IndexDocumentTextJob {
|
||||
JOB_INDEX_DOCUMENT_TEXT
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
&self,
|
||||
state: Arc<AppState>,
|
||||
job: crate::models::Job,
|
||||
storage: TenantStorage,
|
||||
) -> JobExecution {
|
||||
async fn handle(&self, state: Arc<AppState>, job: crate::models::Job) -> JobExecution {
|
||||
let payload: IndexPayload = match serde_json::from_value(job.payload.clone()) {
|
||||
Ok(payload) => payload,
|
||||
Err(err) => {
|
||||
@@ -58,29 +52,16 @@ impl JobHandler for IndexDocumentTextJob {
|
||||
let quickwit_endpoint = match &state.config.quickwit_endpoint {
|
||||
Some(endpoint) => endpoint.clone(),
|
||||
None => {
|
||||
return JobExecution::Failed {
|
||||
error: "quickwit endpoint missing".into(),
|
||||
};
|
||||
warn!("quickwit endpoint missing; skipping indexing");
|
||||
return JobExecution::Success;
|
||||
}
|
||||
};
|
||||
|
||||
let tenant = match state.tenants.get_by_id(job.tenant_id) {
|
||||
Ok(tenant) => tenant,
|
||||
Err(err) => {
|
||||
warn!(job_id = %job.id, error = ?err, "failed to load tenant for indexing");
|
||||
return JobExecution::Retry {
|
||||
delay: Duration::from_secs(30),
|
||||
error: format!("failed to load tenant: {err:?}"),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let quickwit_index = match tenant.quickwit_index.clone() {
|
||||
Some(index) => index,
|
||||
let quickwit_index = match &state.config.quickwit_index {
|
||||
Some(index) => index.clone(),
|
||||
None => {
|
||||
return JobExecution::Failed {
|
||||
error: "tenant quickwit index not configured".into(),
|
||||
};
|
||||
warn!("quickwit index missing; skipping indexing");
|
||||
return JobExecution::Success;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -106,15 +87,15 @@ impl JobHandler for IndexDocumentTextJob {
|
||||
}
|
||||
};
|
||||
|
||||
if context.text_s3_key.is_none() {
|
||||
if context.text_asset.is_none() {
|
||||
warn!(job_id = %job.id, "missing OCR text asset; failing indexing job");
|
||||
return JobExecution::Failed {
|
||||
error: "missing OCR text asset".into(),
|
||||
};
|
||||
}
|
||||
|
||||
let s3_key = context.text_s3_key.unwrap();
|
||||
let text = match storage.get_object(&s3_key).await {
|
||||
let asset = context.text_asset.unwrap();
|
||||
let text = match state.storage.get_object(&asset.s3_key).await {
|
||||
Ok(bytes) => match String::from_utf8(bytes) {
|
||||
Ok(text) => text,
|
||||
Err(err) => {
|
||||
@@ -148,7 +129,6 @@ impl JobHandler for IndexDocumentTextJob {
|
||||
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()
|
||||
});
|
||||
@@ -189,15 +169,15 @@ impl JobHandler for IndexDocumentTextJob {
|
||||
struct IndexContext {
|
||||
document: Document,
|
||||
version: DocumentVersion,
|
||||
text_s3_key: Option<String>,
|
||||
text_asset: Option<DocumentAsset>,
|
||||
}
|
||||
|
||||
fn load_context(state: Arc<AppState>, payload: &IndexPayload) -> Result<IndexContext, String> {
|
||||
let mut base_conn = state.db_unscoped().map_err(|err| format!("{err:?}"))?;
|
||||
let mut conn = state.db().map_err(|err| format!("{err:?}"))?;
|
||||
|
||||
let version: DocumentVersion = document_versions::table
|
||||
.find(payload.document_version_id)
|
||||
.first(&mut base_conn)
|
||||
.first(&mut conn)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
|
||||
if version.document_id != payload.document_id {
|
||||
@@ -206,26 +186,12 @@ fn load_context(state: Arc<AppState>, payload: &IndexPayload) -> Result<IndexCon
|
||||
|
||||
let document: Document = documents::table
|
||||
.find(payload.document_id)
|
||||
.first(&mut base_conn)
|
||||
.first(&mut conn)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
|
||||
let tenant_id = document.tenant_id;
|
||||
drop(base_conn);
|
||||
|
||||
let mut conn = state
|
||||
.db_for_tenant(tenant_id)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
|
||||
let text_s3_key: Option<String> = document_asset_objects::table
|
||||
.inner_join(
|
||||
document_assets::table.on(document_asset_objects::asset_id.eq(document_assets::id)),
|
||||
)
|
||||
let text_asset: Option<DocumentAsset> = document_assets::table
|
||||
.filter(document_assets::document_version_id.eq(payload.document_version_id))
|
||||
.filter(document_assets::asset_type.eq(OCR_TEXT_ASSET_TYPE))
|
||||
.filter(document_assets::tenant_id.eq(tenant_id))
|
||||
.filter(document_asset_objects::ordinal.eq(1))
|
||||
.filter(document_asset_objects::tenant_id.eq(tenant_id))
|
||||
.select(document_asset_objects::s3_key)
|
||||
.first(&mut conn)
|
||||
.optional()
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
@@ -233,6 +199,6 @@ fn load_context(state: Arc<AppState>, payload: &IndexPayload) -> Result<IndexCon
|
||||
Ok(IndexContext {
|
||||
document,
|
||||
version,
|
||||
text_s3_key,
|
||||
text_asset,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ use crate::{
|
||||
jobs::{mark_job_failed, mark_job_succeeded, reserve_job, retry_job_after, JobQueueError},
|
||||
models::Job,
|
||||
state::AppState,
|
||||
storage::TenantStorage,
|
||||
};
|
||||
|
||||
pub mod analyze;
|
||||
@@ -26,7 +25,7 @@ pub enum JobExecution {
|
||||
#[async_trait]
|
||||
pub trait JobHandler: Send + Sync {
|
||||
fn job_type(&self) -> &'static str;
|
||||
async fn handle(&self, state: Arc<AppState>, job: Job, storage: TenantStorage) -> JobExecution;
|
||||
async fn handle(&self, state: Arc<AppState>, job: Job) -> JobExecution;
|
||||
}
|
||||
|
||||
pub struct Worker {
|
||||
@@ -72,7 +71,7 @@ impl Worker {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let mut conn = match self.state.db_unscoped() {
|
||||
let mut conn = match self.state.db() {
|
||||
Ok(conn) => conn,
|
||||
Err(err) => {
|
||||
error!(?err, "failed to obtain database connection in worker");
|
||||
@@ -85,22 +84,10 @@ impl Worker {
|
||||
|
||||
if let Some(job) = job_opt {
|
||||
if let Some(handler) = self.handlers.get(job.job_type.as_str()) {
|
||||
let execution = match self.state.storage_for_tenant(job.tenant_id) {
|
||||
Ok(storage) => {
|
||||
handler
|
||||
.handle(self.state.clone(), job.clone(), storage)
|
||||
.await
|
||||
}
|
||||
Err(err) => {
|
||||
error!(job_id = %job.id, error = ?err, "failed to load tenant storage for job");
|
||||
JobExecution::Failed {
|
||||
error: format!("tenant storage unavailable: {err:?}"),
|
||||
}
|
||||
}
|
||||
};
|
||||
match execution {
|
||||
let result = handler.handle(self.state.clone(), job.clone()).await;
|
||||
match result {
|
||||
JobExecution::Success => {
|
||||
if let Ok(mut conn) = self.state.db_unscoped() {
|
||||
if let Ok(mut conn) = self.state.db() {
|
||||
mark_job_succeeded(&mut conn, job.id)?;
|
||||
info!(job_id = %job.id, job_type = %job.job_type, "job completed successfully");
|
||||
} else {
|
||||
@@ -109,7 +96,7 @@ impl Worker {
|
||||
}
|
||||
JobExecution::Retry { delay, error } => {
|
||||
warn!(job_id = %job.id, job_type = %job.job_type, %error, "job will retry");
|
||||
if let Ok(mut conn) = self.state.db_unscoped() {
|
||||
if let Ok(mut conn) = self.state.db() {
|
||||
retry_job_after(&mut conn, job.id, delay, &error)?;
|
||||
} else {
|
||||
error!("failed to requeue job for retry due to pool error");
|
||||
@@ -117,7 +104,7 @@ impl Worker {
|
||||
}
|
||||
JobExecution::Failed { error } => {
|
||||
error!(job_id = %job.id, job_type = %job.job_type, %error, "job failed");
|
||||
if let Ok(mut conn) = self.state.db_unscoped() {
|
||||
if let Ok(mut conn) = self.state.db() {
|
||||
mark_job_failed(&mut conn, job.id, &error)?;
|
||||
} else {
|
||||
error!("failed to mark job failed due to pool error");
|
||||
@@ -126,7 +113,7 @@ impl Worker {
|
||||
}
|
||||
} else {
|
||||
error!(job_type = %job.job_type, "no handler registered for job type");
|
||||
if let Ok(mut conn) = self.state.db_unscoped() {
|
||||
if let Ok(mut conn) = self.state.db() {
|
||||
mark_job_failed(&mut conn, job.id, "no handler registered")?;
|
||||
} else {
|
||||
error!("failed to mark job failed for missing handler due to pool error");
|
||||
|
||||
+30
-137
@@ -18,16 +18,10 @@ 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,
|
||||
NewDocumentAssetObject,
|
||||
},
|
||||
schema::{document_asset_objects, document_assets, document_versions, documents},
|
||||
models::{Document, DocumentAsset, DocumentVersion, NewDocumentAsset},
|
||||
schema::{document_assets, document_versions, documents},
|
||||
state::AppState,
|
||||
storage::TenantStorage,
|
||||
utils::storage_paths::document_asset_object_prefix,
|
||||
};
|
||||
|
||||
use super::{JobExecution, JobHandler};
|
||||
@@ -57,12 +51,7 @@ impl JobHandler for GenerateOcrTextJob {
|
||||
JOB_GENERATE_OCR_TEXT
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
&self,
|
||||
state: Arc<AppState>,
|
||||
job: crate::models::Job,
|
||||
storage: TenantStorage,
|
||||
) -> JobExecution {
|
||||
async fn handle(&self, state: Arc<AppState>, job: crate::models::Job) -> JobExecution {
|
||||
let payload: OcrPayload = match serde_json::from_value(job.payload.clone()) {
|
||||
Ok(payload) => payload,
|
||||
Err(err) => {
|
||||
@@ -99,7 +88,7 @@ impl JobHandler for GenerateOcrTextJob {
|
||||
return JobExecution::Success;
|
||||
}
|
||||
|
||||
let bytes = match storage.get_object(&context.version.s3_key).await {
|
||||
let bytes = match state.storage.get_object(&context.version.s3_key).await {
|
||||
Ok(bytes) => bytes,
|
||||
Err(err) => {
|
||||
warn!(job_id = %job.id, error = %err, "failed to fetch document for ocr");
|
||||
@@ -134,41 +123,19 @@ impl JobHandler for GenerateOcrTextJob {
|
||||
};
|
||||
};
|
||||
|
||||
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 asset_id = context
|
||||
.existing_asset
|
||||
.as_ref()
|
||||
.map(|asset| asset.id)
|
||||
.unwrap_or_else(Uuid::new_v4);
|
||||
|
||||
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();
|
||||
|
||||
let s3_key = document_asset_object_prefix(
|
||||
context.document.id,
|
||||
context.version.version_number,
|
||||
OCR_TEXT_ASSET_TYPE,
|
||||
asset_id,
|
||||
let s3_key = format!(
|
||||
"documents/{}/v{}/assets/{}/{}",
|
||||
context.document.id, context.version.version_number, OCR_TEXT_ASSET_TYPE, asset_id
|
||||
);
|
||||
|
||||
if let Err(err) = storage
|
||||
if let Err(err) = state
|
||||
.storage
|
||||
.put_object(
|
||||
&s3_key,
|
||||
generation.text.into_bytes(),
|
||||
@@ -191,8 +158,11 @@ impl JobHandler for GenerateOcrTextJob {
|
||||
.await
|
||||
{
|
||||
Ok(Ok(())) => {
|
||||
if let Err(err) = enqueue_index_job(&state, &payload) {
|
||||
warn!(job_id = %job.id, error = %err, "failed to enqueue index job");
|
||||
if state.config.quickwit_endpoint.is_some() && state.config.quickwit_index.is_some()
|
||||
{
|
||||
if let Err(err) = enqueue_index_job(&state, &payload) {
|
||||
warn!(job_id = %job.id, error = %err, "failed to enqueue index job");
|
||||
}
|
||||
}
|
||||
JobExecution::Success
|
||||
}
|
||||
@@ -223,7 +193,6 @@ struct OcrContext {
|
||||
document: Document,
|
||||
version: DocumentVersion,
|
||||
existing_asset: Option<DocumentAsset>,
|
||||
existing_objects: Vec<DocumentAssetObject>,
|
||||
skip: bool,
|
||||
}
|
||||
|
||||
@@ -233,11 +202,11 @@ struct OcrGeneration {
|
||||
}
|
||||
|
||||
fn load_ocr_context(state: Arc<AppState>, payload: &OcrPayload) -> Result<OcrContext, String> {
|
||||
let mut base_conn = state.db_unscoped().map_err(|err| format!("{err:?}"))?;
|
||||
let mut conn = state.db().map_err(|err| format!("{err:?}"))?;
|
||||
|
||||
let version: DocumentVersion = document_versions::table
|
||||
.find(payload.document_version_id)
|
||||
.first(&mut base_conn)
|
||||
.first(&mut conn)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
|
||||
if version.document_id != payload.document_id {
|
||||
@@ -246,53 +215,32 @@ fn load_ocr_context(state: Arc<AppState>, payload: &OcrPayload) -> Result<OcrCon
|
||||
|
||||
let document: Document = documents::table
|
||||
.find(payload.document_id)
|
||||
.first(&mut base_conn)
|
||||
.first(&mut conn)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
|
||||
let tenant_id = document.tenant_id;
|
||||
drop(base_conn);
|
||||
|
||||
let mut conn = state
|
||||
.db_for_tenant(tenant_id)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
|
||||
let existing_asset: Option<DocumentAsset> = document_assets::table
|
||||
let existing: Option<DocumentAsset> = document_assets::table
|
||||
.filter(document_assets::document_version_id.eq(payload.document_version_id))
|
||||
.filter(document_assets::asset_type.eq(OCR_TEXT_ASSET_TYPE))
|
||||
.filter(document_assets::tenant_id.eq(tenant_id))
|
||||
.first(&mut conn)
|
||||
.optional()
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
|
||||
let existing_objects: Vec<DocumentAssetObject> = if let Some(asset) = &existing_asset {
|
||||
document_asset_objects::table
|
||||
.filter(document_asset_objects::asset_id.eq(asset.id))
|
||||
.filter(document_asset_objects::tenant_id.eq(tenant_id))
|
||||
.order(document_asset_objects::ordinal.asc())
|
||||
.load(&mut conn)
|
||||
.map_err(|err| format!("{err:?}"))?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let is_pdf = document_is_pdf(&document);
|
||||
if !is_pdf {
|
||||
return Ok(OcrContext {
|
||||
document,
|
||||
version,
|
||||
existing_asset: existing_asset,
|
||||
existing_objects,
|
||||
existing_asset: existing,
|
||||
skip: true,
|
||||
});
|
||||
}
|
||||
|
||||
let skip = existing_asset.is_some() && !payload.force;
|
||||
let skip = existing.is_some() && !payload.force;
|
||||
|
||||
Ok(OcrContext {
|
||||
document,
|
||||
version,
|
||||
existing_asset,
|
||||
existing_objects,
|
||||
existing_asset: existing,
|
||||
skip,
|
||||
})
|
||||
}
|
||||
@@ -421,28 +369,18 @@ fn persist_ocr_metadata(
|
||||
s3_key: &str,
|
||||
source: &'static str,
|
||||
) -> Result<(), String> {
|
||||
let tenant_id = context.document.tenant_id;
|
||||
let mut conn = state
|
||||
.db_for_tenant(tenant_id)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
|
||||
if let Some(existing_asset) = &context.existing_asset {
|
||||
diesel::delete(document_assets::table.filter(document_assets::id.eq(existing_asset.id)))
|
||||
.execute(&mut conn)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
}
|
||||
let mut conn = state.db().map_err(|err| format!("{err:?}"))?;
|
||||
|
||||
let new_asset = NewDocumentAsset {
|
||||
id: asset_id,
|
||||
document_version_id: context.version.id,
|
||||
asset_type: OCR_TEXT_ASSET_TYPE.to_string(),
|
||||
s3_key: s3_key.to_string(),
|
||||
mime_type: "text/plain".to_string(),
|
||||
metadata: json!({
|
||||
"generated_at": Utc::now().to_rfc3339(),
|
||||
"source": source,
|
||||
}),
|
||||
cardinality: Some(1),
|
||||
tenant_id,
|
||||
};
|
||||
|
||||
diesel::insert_into(document_assets::table)
|
||||
@@ -453,43 +391,9 @@ fn persist_ocr_metadata(
|
||||
))
|
||||
.do_update()
|
||||
.set((
|
||||
document_assets::s3_key.eq(excluded(document_assets::s3_key)),
|
||||
document_assets::mime_type.eq(excluded(document_assets::mime_type)),
|
||||
document_assets::metadata.eq(excluded(document_assets::metadata)),
|
||||
document_assets::cardinality.eq(excluded(document_assets::cardinality)),
|
||||
))
|
||||
.execute(&mut conn)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
|
||||
let existing_object_id: Option<Uuid> = document_asset_objects::table
|
||||
.filter(document_asset_objects::asset_id.eq(asset_id))
|
||||
.filter(document_asset_objects::ordinal.eq(1))
|
||||
.filter(document_asset_objects::tenant_id.eq(tenant_id))
|
||||
.select(document_asset_objects::id)
|
||||
.first(&mut conn)
|
||||
.optional()
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
|
||||
let object_id = existing_object_id.unwrap_or_else(Uuid::new_v4);
|
||||
|
||||
let new_object = NewDocumentAssetObject {
|
||||
id: object_id,
|
||||
asset_id,
|
||||
ordinal: 1,
|
||||
s3_key: s3_key.to_string(),
|
||||
metadata: json!({}),
|
||||
tenant_id,
|
||||
};
|
||||
|
||||
diesel::insert_into(document_asset_objects::table)
|
||||
.values(&new_object)
|
||||
.on_conflict((
|
||||
document_asset_objects::asset_id,
|
||||
document_asset_objects::ordinal,
|
||||
))
|
||||
.do_update()
|
||||
.set((
|
||||
document_asset_objects::s3_key.eq(excluded(document_asset_objects::s3_key)),
|
||||
document_asset_objects::metadata.eq(excluded(document_asset_objects::metadata)),
|
||||
))
|
||||
.execute(&mut conn)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
@@ -498,20 +402,9 @@ fn persist_ocr_metadata(
|
||||
}
|
||||
|
||||
fn enqueue_index_job(state: &AppState, payload: &OcrPayload) -> Result<(), String> {
|
||||
let mut base_conn = state.db_unscoped().map_err(|err| format!("{err:?}"))?;
|
||||
let tenant_id: Uuid = documents::table
|
||||
.find(payload.document_id)
|
||||
.select(documents::tenant_id)
|
||||
.first(&mut base_conn)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
drop(base_conn);
|
||||
|
||||
let mut conn = state
|
||||
.db_for_tenant(tenant_id)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
let mut conn = state.db().map_err(|err| format!("{err:?}"))?;
|
||||
enqueue_job(
|
||||
&mut conn,
|
||||
tenant_id,
|
||||
JOB_INDEX_DOCUMENT_TEXT,
|
||||
json!({
|
||||
"document_id": payload.document_id,
|
||||
|
||||
+124
-400
@@ -12,16 +12,10 @@ use tracing::{error, info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
documents::asset::delete_asset,
|
||||
jobs::JOB_GENERATE_THUMBNAILS,
|
||||
models::{
|
||||
Document, DocumentAsset, DocumentAssetObject, DocumentVersion, NewDocumentAsset,
|
||||
NewDocumentAssetObject,
|
||||
},
|
||||
schema::{document_asset_objects, document_assets, document_versions, documents},
|
||||
models::{Document, DocumentAsset, DocumentVersion, NewDocumentAsset},
|
||||
schema::{document_assets, document_versions, documents},
|
||||
state::AppState,
|
||||
storage::TenantStorage,
|
||||
utils::storage_paths::document_asset_object_key,
|
||||
};
|
||||
|
||||
use super::{analyze::determine_thumbnail_support, JobExecution, JobHandler};
|
||||
@@ -55,12 +49,7 @@ impl JobHandler for GenerateThumbnailsJob {
|
||||
JOB_GENERATE_THUMBNAILS
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
&self,
|
||||
state: Arc<AppState>,
|
||||
job: crate::models::Job,
|
||||
storage: TenantStorage,
|
||||
) -> JobExecution {
|
||||
async fn handle(&self, state: Arc<AppState>, job: crate::models::Job) -> JobExecution {
|
||||
let payload: ThumbnailPayload = match serde_json::from_value(job.payload.clone()) {
|
||||
Ok(p) => p,
|
||||
Err(err) => {
|
||||
@@ -96,7 +85,7 @@ impl JobHandler for GenerateThumbnailsJob {
|
||||
return JobExecution::Success;
|
||||
}
|
||||
|
||||
let bytes = match storage.get_object(&initial.version.s3_key).await {
|
||||
let bytes = match state.storage.get_object(&initial.version.s3_key).await {
|
||||
Ok(bytes) => bytes,
|
||||
Err(err) => {
|
||||
warn!(job_id = %job.id, error = %err, "thumbnail fetch failed; will retry");
|
||||
@@ -153,188 +142,86 @@ impl JobHandler for GenerateThumbnailsJob {
|
||||
}
|
||||
}
|
||||
|
||||
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!(
|
||||
job_id = %job.id,
|
||||
error = %err,
|
||||
s3_key = %object.s3_key,
|
||||
"failed to delete existing preview object"
|
||||
);
|
||||
}
|
||||
}
|
||||
let thumbnail_asset_id = initial
|
||||
.existing_thumbnail
|
||||
.as_ref()
|
||||
.map(|asset| asset.id)
|
||||
.unwrap_or_else(Uuid::new_v4);
|
||||
let thumbnail_s3_key = format!(
|
||||
"documents/{}/v{}/assets/{}/{}",
|
||||
initial.document.id,
|
||||
initial.version.version_number,
|
||||
THUMBNAIL_ASSET_TYPE,
|
||||
thumbnail_asset_id
|
||||
);
|
||||
|
||||
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)
|
||||
})
|
||||
let preview_asset_id = initial
|
||||
.existing_preview
|
||||
.as_ref()
|
||||
.map(|asset| asset.id)
|
||||
.unwrap_or_else(Uuid::new_v4);
|
||||
let preview_s3_key = format!(
|
||||
"documents/{}/v{}/assets/{}/{}",
|
||||
initial.document.id,
|
||||
initial.version.version_number,
|
||||
PREVIEW_ASSET_TYPE,
|
||||
preview_asset_id
|
||||
);
|
||||
|
||||
if let Err(err) = state
|
||||
.storage
|
||||
.put_object(
|
||||
&preview_s3_key,
|
||||
generation.preview.image_bytes.clone(),
|
||||
Some("image/png".into()),
|
||||
None,
|
||||
)
|
||||
.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"
|
||||
);
|
||||
}
|
||||
}
|
||||
{
|
||||
warn!(job_id = %job.id, error = %err, "failed to upload preview; retrying");
|
||||
return JobExecution::Retry {
|
||||
delay: Duration::from_secs(30),
|
||||
error: err.to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
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!(
|
||||
job_id = %job.id,
|
||||
error = %err,
|
||||
s3_key = %object.s3_key,
|
||||
"failed to delete existing thumbnail object"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
if let Err(err) = state
|
||||
.storage
|
||||
.put_object(
|
||||
&thumbnail_s3_key,
|
||||
generation.thumbnail.image_bytes.clone(),
|
||||
Some("image/png".into()),
|
||||
None,
|
||||
)
|
||||
.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"
|
||||
);
|
||||
}
|
||||
}
|
||||
{
|
||||
warn!(job_id = %job.id, error = %err, "failed to upload thumbnail; retrying");
|
||||
return JobExecution::Retry {
|
||||
delay: Duration::from_secs(30),
|
||||
error: err.to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
let preview_asset_id = Uuid::new_v4();
|
||||
|
||||
let thumbnail_asset_id = Uuid::new_v4();
|
||||
|
||||
let mut preview_objects: Vec<AssetObjectPersistence> =
|
||||
Vec::with_capacity(generation.preview.objects.len());
|
||||
for (index, image) in generation.preview.objects.iter().enumerate() {
|
||||
if index + 1 > i32::MAX as usize {
|
||||
return JobExecution::Failed {
|
||||
error: "too many preview objects".to_string(),
|
||||
};
|
||||
}
|
||||
let ordinal = (index + 1) as i32;
|
||||
let s3_key = document_asset_object_key(
|
||||
initial.document.id,
|
||||
initial.version.version_number,
|
||||
PREVIEW_ASSET_TYPE,
|
||||
preview_asset_id,
|
||||
ordinal,
|
||||
);
|
||||
|
||||
if let Err(err) = storage
|
||||
.put_object(
|
||||
&s3_key,
|
||||
image.image_bytes.clone(),
|
||||
Some("image/png".into()),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!(job_id = %job.id, error = %err, ordinal, "failed to upload preview; retrying");
|
||||
return JobExecution::Retry {
|
||||
delay: Duration::from_secs(30),
|
||||
error: err.to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
preview_objects.push(AssetObjectPersistence {
|
||||
ordinal,
|
||||
s3_key,
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
});
|
||||
}
|
||||
|
||||
let mut thumbnail_objects: Vec<AssetObjectPersistence> =
|
||||
Vec::with_capacity(generation.thumbnail.objects.len());
|
||||
for (index, image) in generation.thumbnail.objects.iter().enumerate() {
|
||||
if index + 1 > i32::MAX as usize {
|
||||
return JobExecution::Failed {
|
||||
error: "too many thumbnail objects".to_string(),
|
||||
};
|
||||
}
|
||||
let ordinal = (index + 1) as i32;
|
||||
let s3_key = document_asset_object_key(
|
||||
initial.document.id,
|
||||
initial.version.version_number,
|
||||
THUMBNAIL_ASSET_TYPE,
|
||||
thumbnail_asset_id,
|
||||
ordinal,
|
||||
);
|
||||
|
||||
if let Err(err) = storage
|
||||
.put_object(
|
||||
&s3_key,
|
||||
image.image_bytes.clone(),
|
||||
Some("image/png".into()),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!(job_id = %job.id, error = %err, ordinal, "failed to upload thumbnail; retrying");
|
||||
return JobExecution::Retry {
|
||||
delay: Duration::from_secs(30),
|
||||
error: err.to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
thumbnail_objects.push(AssetObjectPersistence {
|
||||
ordinal,
|
||||
s3_key,
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
});
|
||||
}
|
||||
|
||||
let asset_persistences = vec![
|
||||
AssetPersistence {
|
||||
asset_type: PREVIEW_ASSET_TYPE,
|
||||
asset_id: preview_asset_id,
|
||||
objects: preview_objects,
|
||||
},
|
||||
AssetPersistence {
|
||||
asset_type: THUMBNAIL_ASSET_TYPE,
|
||||
asset_id: thumbnail_asset_id,
|
||||
objects: thumbnail_objects,
|
||||
},
|
||||
];
|
||||
|
||||
let state_clone = state.clone();
|
||||
match task::spawn_blocking(move || {
|
||||
persist_assets_metadata(state_clone, &initial, &asset_persistences)
|
||||
persist_assets_metadata(
|
||||
state_clone,
|
||||
&initial,
|
||||
&[
|
||||
AssetPersistence {
|
||||
asset_type: PREVIEW_ASSET_TYPE,
|
||||
asset_id: preview_asset_id,
|
||||
s3_key: &preview_s3_key,
|
||||
generated: &generation.preview,
|
||||
},
|
||||
AssetPersistence {
|
||||
asset_type: THUMBNAIL_ASSET_TYPE,
|
||||
asset_id: thumbnail_asset_id,
|
||||
s3_key: &thumbnail_s3_key,
|
||||
generated: &generation.thumbnail,
|
||||
},
|
||||
],
|
||||
)
|
||||
})
|
||||
.await
|
||||
{
|
||||
@@ -363,9 +250,7 @@ struct ThumbnailContext {
|
||||
document: Document,
|
||||
version: DocumentVersion,
|
||||
existing_thumbnail: Option<DocumentAsset>,
|
||||
existing_thumbnail_objects: Vec<DocumentAssetObject>,
|
||||
existing_preview: Option<DocumentAsset>,
|
||||
existing_preview_objects: Vec<DocumentAssetObject>,
|
||||
skip: bool,
|
||||
}
|
||||
|
||||
@@ -375,34 +260,24 @@ struct GeneratedImage {
|
||||
height: Option<i32>,
|
||||
}
|
||||
|
||||
struct GeneratedAsset {
|
||||
objects: Vec<GeneratedImage>,
|
||||
}
|
||||
|
||||
struct GeneratedAssets {
|
||||
thumbnail: GeneratedAsset,
|
||||
preview: GeneratedAsset,
|
||||
thumbnail: GeneratedImage,
|
||||
preview: GeneratedImage,
|
||||
page_count: Option<u32>,
|
||||
}
|
||||
|
||||
struct AssetObjectPersistence {
|
||||
ordinal: i32,
|
||||
s3_key: String,
|
||||
width: Option<i32>,
|
||||
height: Option<i32>,
|
||||
}
|
||||
|
||||
struct AssetPersistence {
|
||||
struct AssetPersistence<'a> {
|
||||
asset_type: &'static str,
|
||||
asset_id: Uuid,
|
||||
objects: Vec<AssetObjectPersistence>,
|
||||
s3_key: &'a str,
|
||||
generated: &'a GeneratedImage,
|
||||
}
|
||||
|
||||
fn load_thumbnail_context(
|
||||
state: Arc<AppState>,
|
||||
payload: &ThumbnailPayload,
|
||||
) -> Result<ThumbnailContext, String> {
|
||||
let mut conn = state.db_unscoped().map_err(|err| format!("{err:?}"))?;
|
||||
let mut conn = state.db().map_err(|err| format!("{err:?}"))?;
|
||||
|
||||
let version: DocumentVersion = document_versions::table
|
||||
.find(payload.document_version_id)
|
||||
@@ -418,42 +293,21 @@ fn load_thumbnail_context(
|
||||
.first(&mut conn)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
|
||||
let tenant_id = document.tenant_id;
|
||||
|
||||
let existing_assets: Vec<DocumentAsset> = document_assets::table
|
||||
.filter(document_assets::document_version_id.eq(payload.document_version_id))
|
||||
.filter(document_assets::asset_type.eq_any(vec![
|
||||
THUMBNAIL_ASSET_TYPE.to_string(),
|
||||
PREVIEW_ASSET_TYPE.to_string(),
|
||||
]))
|
||||
.filter(document_assets::tenant_id.eq(tenant_id))
|
||||
.load(&mut conn)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
|
||||
let mut existing_thumbnail = None;
|
||||
let mut existing_thumbnail_objects: Vec<DocumentAssetObject> = Vec::new();
|
||||
let mut existing_preview = None;
|
||||
let mut existing_preview_objects: Vec<DocumentAssetObject> = Vec::new();
|
||||
for asset in existing_assets {
|
||||
match asset.asset_type.as_str() {
|
||||
THUMBNAIL_ASSET_TYPE => {
|
||||
existing_thumbnail_objects = document_asset_objects::table
|
||||
.filter(document_asset_objects::asset_id.eq(asset.id))
|
||||
.filter(document_asset_objects::tenant_id.eq(tenant_id))
|
||||
.order(document_asset_objects::ordinal.asc())
|
||||
.load(&mut conn)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
existing_thumbnail = Some(asset);
|
||||
}
|
||||
PREVIEW_ASSET_TYPE => {
|
||||
existing_preview_objects = document_asset_objects::table
|
||||
.filter(document_asset_objects::asset_id.eq(asset.id))
|
||||
.filter(document_asset_objects::tenant_id.eq(tenant_id))
|
||||
.order(document_asset_objects::ordinal.asc())
|
||||
.load(&mut conn)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
existing_preview = Some(asset);
|
||||
}
|
||||
THUMBNAIL_ASSET_TYPE => existing_thumbnail = Some(asset),
|
||||
PREVIEW_ASSET_TYPE => existing_preview = Some(asset),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -463,33 +317,13 @@ fn load_thumbnail_context(
|
||||
return Err("thumbnail generation not supported for this document".into());
|
||||
}
|
||||
|
||||
let expected_cardinality = expected_asset_cardinality(&document, &version);
|
||||
let preview_cardinality = existing_preview
|
||||
.as_ref()
|
||||
.and_then(|asset| asset.cardinality)
|
||||
.unwrap_or_else(|| existing_preview_objects.len() as i32);
|
||||
let thumbnail_cardinality = existing_thumbnail
|
||||
.as_ref()
|
||||
.and_then(|asset| asset.cardinality)
|
||||
.unwrap_or_else(|| existing_thumbnail_objects.len() as i32);
|
||||
|
||||
let needs_regeneration = preview_cardinality < expected_cardinality
|
||||
|| thumbnail_cardinality < expected_cardinality
|
||||
|| (existing_preview_objects.len() as i32) < expected_cardinality
|
||||
|| (existing_thumbnail_objects.len() as i32) < expected_cardinality;
|
||||
|
||||
let skip = existing_thumbnail.is_some()
|
||||
&& existing_preview.is_some()
|
||||
&& !payload.force
|
||||
&& !needs_regeneration;
|
||||
let skip = existing_thumbnail.is_some() && existing_preview.is_some() && !payload.force;
|
||||
|
||||
Ok(ThumbnailContext {
|
||||
document,
|
||||
version,
|
||||
existing_thumbnail,
|
||||
existing_thumbnail_objects,
|
||||
existing_preview,
|
||||
existing_preview_objects,
|
||||
skip,
|
||||
})
|
||||
}
|
||||
@@ -498,7 +332,18 @@ fn generate_preview_and_thumbnail(
|
||||
document: &Document,
|
||||
bytes: &[u8],
|
||||
) -> Result<GeneratedAssets, String> {
|
||||
let is_pdf = document_is_pdf(document);
|
||||
let is_pdf = document
|
||||
.content_type
|
||||
.as_deref()
|
||||
.map(|mime| mime == "application/pdf")
|
||||
.unwrap_or_else(|| {
|
||||
document
|
||||
.original_name
|
||||
.rsplit('.')
|
||||
.next()
|
||||
.map(|ext| ext.eq_ignore_ascii_case("pdf"))
|
||||
.unwrap_or(false)
|
||||
});
|
||||
|
||||
if is_pdf {
|
||||
let pdf_assets = generate_pdf_assets(bytes)?;
|
||||
@@ -517,7 +362,7 @@ fn generate_preview_and_thumbnail(
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_image_assets(bytes: &[u8]) -> Result<(GeneratedAsset, GeneratedAsset), String> {
|
||||
fn generate_image_assets(bytes: &[u8]) -> Result<(GeneratedImage, GeneratedImage), String> {
|
||||
let reader = ImageReader::new(Cursor::new(bytes))
|
||||
.with_guessed_format()
|
||||
.map_err(|err| err.to_string())?;
|
||||
@@ -539,19 +384,12 @@ fn generate_image_assets(bytes: &[u8]) -> Result<(GeneratedAsset, GeneratedAsset
|
||||
let preview = encode_dynamic_image(preview_image)?;
|
||||
let thumbnail = encode_dynamic_image(thumbnail_image)?;
|
||||
|
||||
Ok((
|
||||
GeneratedAsset {
|
||||
objects: vec![preview],
|
||||
},
|
||||
GeneratedAsset {
|
||||
objects: vec![thumbnail],
|
||||
},
|
||||
))
|
||||
Ok((preview, thumbnail))
|
||||
}
|
||||
|
||||
struct PdfGeneratedAssets {
|
||||
preview: GeneratedAsset,
|
||||
thumbnail: GeneratedAsset,
|
||||
preview: GeneratedImage,
|
||||
thumbnail: GeneratedImage,
|
||||
page_count: u32,
|
||||
}
|
||||
|
||||
@@ -564,7 +402,11 @@ fn generate_pdf_assets(bytes: &[u8]) -> Result<PdfGeneratedAssets, String> {
|
||||
.map_err(|err| format!("load pdf: {err}"))?;
|
||||
|
||||
let pages = document.pages();
|
||||
let total_pages = pages.len() as usize;
|
||||
let total_pages = pages.len();
|
||||
|
||||
let page = pages
|
||||
.get(0)
|
||||
.map_err(|err| format!("load first page: {err}"))?;
|
||||
|
||||
let render_config = PdfRenderConfig::new()
|
||||
.set_target_width(PREVIEW_WIDTH as i32)
|
||||
@@ -572,44 +414,30 @@ fn generate_pdf_assets(bytes: &[u8]) -> Result<PdfGeneratedAssets, String> {
|
||||
.render_form_data(true)
|
||||
.rotate_if_landscape(PdfPageRenderRotation::None, true);
|
||||
|
||||
let mut preview_objects: Vec<GeneratedImage> = Vec::with_capacity(total_pages);
|
||||
let mut thumbnail_objects: Vec<GeneratedImage> = Vec::with_capacity(total_pages);
|
||||
let bitmap = page
|
||||
.render_with_config(&render_config)
|
||||
.map_err(|err| format!("render pdf page: {err}"))?;
|
||||
|
||||
for page_index in 0..total_pages {
|
||||
let page = pages
|
||||
.get(u16::try_from(page_index).map_err(|_| "page index overflow".to_string())?)
|
||||
.map_err(|err| format!("load page {page_index}: {err}"))?;
|
||||
let preview_buffer = bitmap.as_image().to_rgb8();
|
||||
let preview_image = image::DynamicImage::ImageRgb8(preview_buffer);
|
||||
|
||||
let bitmap = page
|
||||
.render_with_config(&render_config)
|
||||
.map_err(|err| format!("render pdf page {page_index}: {err}"))?;
|
||||
|
||||
let preview_buffer = bitmap.as_image().to_rgb8();
|
||||
let preview_image = image::DynamicImage::ImageRgb8(preview_buffer);
|
||||
|
||||
let thumbnail_image = if preview_image.width() > THUMBNAIL_WIDTH
|
||||
|| preview_image.height() > THUMBNAIL_HEIGHT
|
||||
{
|
||||
let thumbnail_image =
|
||||
if preview_image.width() > THUMBNAIL_WIDTH || preview_image.height() > THUMBNAIL_HEIGHT {
|
||||
preview_image.thumbnail(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT)
|
||||
} else {
|
||||
preview_image.clone()
|
||||
};
|
||||
|
||||
preview_objects.push(encode_dynamic_image(preview_image)?);
|
||||
thumbnail_objects.push(encode_dynamic_image(thumbnail_image)?);
|
||||
}
|
||||
let preview = encode_dynamic_image(preview_image)?;
|
||||
let thumbnail = encode_dynamic_image(thumbnail_image)?;
|
||||
|
||||
let page_count: u32 = total_pages
|
||||
.try_into()
|
||||
.map_err(|_| "page count exceeds supported range".to_string())?;
|
||||
|
||||
Ok(PdfGeneratedAssets {
|
||||
preview: GeneratedAsset {
|
||||
objects: preview_objects,
|
||||
},
|
||||
thumbnail: GeneratedAsset {
|
||||
objects: thumbnail_objects,
|
||||
},
|
||||
preview,
|
||||
thumbnail,
|
||||
page_count,
|
||||
})
|
||||
}
|
||||
@@ -630,51 +458,22 @@ fn encode_dynamic_image(image: image::DynamicImage) -> Result<GeneratedImage, St
|
||||
fn persist_assets_metadata(
|
||||
state: Arc<AppState>,
|
||||
context: &ThumbnailContext,
|
||||
assets: &[AssetPersistence],
|
||||
assets: &[AssetPersistence<'_>],
|
||||
) -> Result<(), String> {
|
||||
let tenant_id = context.document.tenant_id;
|
||||
let mut conn = state
|
||||
.db_for_tenant(tenant_id)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
|
||||
if let Some(existing_preview) = &context.existing_preview {
|
||||
diesel::delete(document_assets::table.filter(document_assets::id.eq(existing_preview.id)))
|
||||
.execute(&mut conn)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
}
|
||||
|
||||
if let Some(existing_thumbnail) = &context.existing_thumbnail {
|
||||
diesel::delete(
|
||||
document_assets::table.filter(document_assets::id.eq(existing_thumbnail.id)),
|
||||
)
|
||||
.execute(&mut conn)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
}
|
||||
let mut conn = state.db().map_err(|err| format!("{err:?}"))?;
|
||||
|
||||
for asset in assets {
|
||||
if asset.objects.is_empty() {
|
||||
return Err(format!(
|
||||
"asset {} has no generated objects",
|
||||
asset.asset_type
|
||||
));
|
||||
}
|
||||
|
||||
let object_count: i32 = asset
|
||||
.objects
|
||||
.len()
|
||||
.try_into()
|
||||
.map_err(|_| "asset contains too many objects".to_string())?;
|
||||
|
||||
let new_asset = NewDocumentAsset {
|
||||
id: asset.asset_id,
|
||||
document_version_id: context.version.id,
|
||||
asset_type: asset.asset_type.to_string(),
|
||||
s3_key: asset.s3_key.to_string(),
|
||||
mime_type: "image/png".to_string(),
|
||||
metadata: json!({
|
||||
"generated_at": Utc::now().to_rfc3339(),
|
||||
"width": asset.generated.width,
|
||||
"height": asset.generated.height,
|
||||
}),
|
||||
cardinality: Some(object_count),
|
||||
tenant_id,
|
||||
};
|
||||
|
||||
diesel::insert_into(document_assets::table)
|
||||
@@ -685,46 +484,12 @@ fn persist_assets_metadata(
|
||||
))
|
||||
.do_update()
|
||||
.set((
|
||||
document_assets::s3_key.eq(excluded(document_assets::s3_key)),
|
||||
document_assets::mime_type.eq(excluded(document_assets::mime_type)),
|
||||
document_assets::metadata.eq(excluded(document_assets::metadata)),
|
||||
document_assets::cardinality.eq(excluded(document_assets::cardinality)),
|
||||
))
|
||||
.execute(&mut conn)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
|
||||
diesel::delete(
|
||||
document_asset_objects::table
|
||||
.filter(document_asset_objects::asset_id.eq(asset.asset_id))
|
||||
.filter(document_asset_objects::tenant_id.eq(tenant_id)),
|
||||
)
|
||||
.execute(&mut conn)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
|
||||
for object in &asset.objects {
|
||||
let mut metadata_map = Map::new();
|
||||
if let Some(width) = object.width {
|
||||
metadata_map.insert("width".to_string(), Value::from(width));
|
||||
}
|
||||
if let Some(height) = object.height {
|
||||
metadata_map.insert("height".to_string(), Value::from(height));
|
||||
}
|
||||
|
||||
let object_metadata = Value::Object(metadata_map);
|
||||
|
||||
let new_object = NewDocumentAssetObject {
|
||||
id: Uuid::new_v4(),
|
||||
asset_id: asset.asset_id,
|
||||
ordinal: object.ordinal,
|
||||
s3_key: object.s3_key.clone(),
|
||||
metadata: object_metadata,
|
||||
tenant_id,
|
||||
};
|
||||
|
||||
diesel::insert_into(document_asset_objects::table)
|
||||
.values(&new_object)
|
||||
.execute(&mut conn)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -736,17 +501,11 @@ fn persist_document_page_count(
|
||||
document_version_id: Uuid,
|
||||
page_count: u32,
|
||||
) -> Result<(), String> {
|
||||
let mut conn = state.db_unscoped().map_err(|err| format!("{err:?}"))?;
|
||||
let tenant_id: Uuid = documents::table
|
||||
.find(document_id)
|
||||
.select(documents::tenant_id)
|
||||
.first(&mut conn)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
let mut conn = state.db().map_err(|err| format!("{err:?}"))?;
|
||||
|
||||
let existing_metadata: Value = document_versions::table
|
||||
.filter(document_versions::id.eq(document_version_id))
|
||||
.filter(document_versions::document_id.eq(document_id))
|
||||
.filter(document_versions::tenant_id.eq(tenant_id))
|
||||
.select(document_versions::metadata)
|
||||
.first(&mut conn)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
@@ -766,8 +525,7 @@ fn persist_document_page_count(
|
||||
diesel::update(
|
||||
document_versions::table
|
||||
.filter(document_versions::id.eq(document_version_id))
|
||||
.filter(document_versions::document_id.eq(document_id))
|
||||
.filter(document_versions::tenant_id.eq(tenant_id)),
|
||||
.filter(document_versions::document_id.eq(document_id)),
|
||||
)
|
||||
.set(document_versions::metadata.eq(updated))
|
||||
.execute(&mut conn)
|
||||
@@ -775,37 +533,3 @@ fn persist_document_page_count(
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn document_is_pdf(document: &Document) -> bool {
|
||||
document
|
||||
.content_type
|
||||
.as_deref()
|
||||
.map(|mime| mime.eq_ignore_ascii_case("application/pdf"))
|
||||
.unwrap_or_else(|| {
|
||||
document
|
||||
.original_name
|
||||
.rsplit('.')
|
||||
.next()
|
||||
.map(|ext| ext.eq_ignore_ascii_case("pdf"))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
}
|
||||
|
||||
fn expected_asset_cardinality(document: &Document, version: &DocumentVersion) -> i32 {
|
||||
if let Value::Object(map) = &version.metadata {
|
||||
if let Some(count) = map.get("page_count").and_then(|v| v.as_i64()) {
|
||||
if count > 0 {
|
||||
return count
|
||||
.min(i64::from(i32::MAX))
|
||||
.try_into()
|
||||
.unwrap_or(i32::MAX);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if document_is_pdf(document) {
|
||||
1
|
||||
} else {
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
+6
-267
@@ -1,47 +1,14 @@
|
||||
mod common;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use axum::body::Body;
|
||||
use axum::http::{header::SET_COOKIE, StatusCode};
|
||||
use backend::models::NewUserMembership;
|
||||
use backend::schema::{tenants, user_memberships};
|
||||
use anyhow::Result;
|
||||
use axum::http::StatusCode;
|
||||
use common::{acquire_db_lock, body_to_vec, TestApp};
|
||||
use diesel::prelude::*;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AuthenticatedUser {
|
||||
username: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ErrorResponse {
|
||||
error: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LoginTenant {
|
||||
slug: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LoginResponse {
|
||||
access_token: String,
|
||||
tenant: LoginTenant,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TenantSelectionResponse {
|
||||
access_token: String,
|
||||
tenants: Vec<TenantSummary>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TenantSummary {
|
||||
tenant_id: Uuid,
|
||||
slug: String,
|
||||
role: String,
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -52,244 +19,16 @@ async fn login_and_me_roundtrip() -> Result<()> {
|
||||
let password = "s3cret";
|
||||
app.insert_user("alice", password, "admin").await?;
|
||||
|
||||
let (login, _) = login_with_session(&app, "alice", password).await?;
|
||||
let token = app.login_token("alice", password).await?;
|
||||
|
||||
let response = app.get("/api/auth/me", Some(&login.access_token)).await?;
|
||||
let response = app.get("/api/auth/me", Some(&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)?;
|
||||
|
||||
assert_eq!(user.username, "alice");
|
||||
assert_eq!(user.role, "admin");
|
||||
|
||||
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::UNAUTHORIZED);
|
||||
let body = body_to_vec(response.into_body()).await?;
|
||||
let err: ErrorResponse = serde_json::from_slice(&body)?;
|
||||
assert_eq!(err.error, "unauthorized");
|
||||
|
||||
app.cleanup().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[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::UNAUTHORIZED);
|
||||
let body = body_to_vec(response.into_body()).await?;
|
||||
let err: ErrorResponse = serde_json::from_slice(&body)?;
|
||||
assert_eq!(err.error, "unauthorized");
|
||||
|
||||
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.slug, login.tenant.slug);
|
||||
|
||||
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_slug = "secondary".to_string();
|
||||
let slug_for_insert = secondary_slug.clone();
|
||||
let secondary_id = Uuid::new_v4();
|
||||
app.with_conn(move |conn| {
|
||||
diesel::insert_into(tenants::table)
|
||||
.values((
|
||||
tenants::id.eq(secondary_id),
|
||||
tenants::slug.eq(&slug_for_insert),
|
||||
))
|
||||
.execute(conn)?;
|
||||
|
||||
let membership = NewUserMembership {
|
||||
id: Uuid::new_v4(),
|
||||
user_id,
|
||||
tenant_id: secondary_id,
|
||||
role: "admin".to_string(),
|
||||
};
|
||||
|
||||
diesel::insert_into(user_memberships::table)
|
||||
.values(&membership)
|
||||
.execute(conn)?;
|
||||
Ok(())
|
||||
})
|
||||
.await?;
|
||||
|
||||
let payload = json!({ "username": "multipass", "password": password });
|
||||
let response = app.post_json("/api/auth/login", &payload, None).await?;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = body_to_vec(response.into_body()).await?;
|
||||
let selection: TenantSelectionResponse = serde_json::from_slice(&body)?;
|
||||
assert!(selection.tenants.len() >= 2);
|
||||
let secondary = selection
|
||||
.tenants
|
||||
.iter()
|
||||
.find(|tenant| tenant.slug == secondary_slug)
|
||||
.map(|t| t.tenant_id)
|
||||
.context("secondary tenant missing from selection")?;
|
||||
|
||||
let select_response = app
|
||||
.post_json(
|
||||
"/api/auth/select-tenant",
|
||||
&json!({ "tenant_id": secondary }),
|
||||
Some(&selection.access_token),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(select_response.status(), StatusCode::OK);
|
||||
let session_cookie = extract_refresh_cookie(select_response.headers())?;
|
||||
let select_body = body_to_vec(select_response.into_body()).await?;
|
||||
let login: LoginResponse = serde_json::from_slice(&select_body)?;
|
||||
assert_eq!(login.tenant.slug, secondary_slug);
|
||||
|
||||
let me_response = app.get("/api/auth/me", Some(&login.access_token)).await?;
|
||||
assert_eq!(me_response.status(), StatusCode::OK);
|
||||
|
||||
let refresh_response = app
|
||||
.post_json_with_cookie("/api/auth/refresh", &json!({}), None, Some(&session_cookie))
|
||||
.await?;
|
||||
assert_eq!(refresh_response.status(), StatusCode::OK);
|
||||
|
||||
app.cleanup().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn login_with_session(
|
||||
app: &TestApp,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<(LoginResponse, String)> {
|
||||
let payload = json!({ "username": username, "password": password });
|
||||
let response = app.post_json("/api/auth/login", &payload, None).await?;
|
||||
ensure_status(&response, StatusCode::OK)?;
|
||||
let refresh_cookie = extract_refresh_cookie(response.headers())?;
|
||||
let body = body_to_vec(response.into_body()).await?;
|
||||
let login: LoginResponse = serde_json::from_slice(&body)
|
||||
.map_err(|_| anyhow!("expected login response with session"))?;
|
||||
Ok((login, refresh_cookie))
|
||||
}
|
||||
|
||||
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 ensure_status(response: &hyper::Response<Body>, expected: StatusCode) -> Result<()> {
|
||||
if response.status() == expected {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow!(
|
||||
"unexpected status: got {}, expected {}",
|
||||
response.status(),
|
||||
expected
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
+13
-298
@@ -6,25 +6,23 @@ use std::time::Duration;
|
||||
use anyhow::{anyhow, ensure, Context, Result};
|
||||
use async_trait::async_trait;
|
||||
use axum::body::Body;
|
||||
use axum::http::{header, Method, Request, StatusCode};
|
||||
use axum::http::{Method, Request, StatusCode};
|
||||
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, NewUser};
|
||||
use backend::routes;
|
||||
use backend::state::AppState;
|
||||
use backend::storage::ObjectStorage;
|
||||
use diesel::connection::SimpleConnection;
|
||||
use diesel::prelude::*;
|
||||
use diesel::OptionalExtension;
|
||||
use diesel::PgConnection;
|
||||
use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
|
||||
use http_body_util::BodyExt;
|
||||
use once_cell::sync::Lazy;
|
||||
use rand::rngs::OsRng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json;
|
||||
use serde::Serialize;
|
||||
use tokio::sync::Mutex;
|
||||
use tower::util::ServiceExt;
|
||||
use uuid::Uuid;
|
||||
@@ -140,7 +138,6 @@ impl TestApp {
|
||||
s3_bucket: "test-bucket".to_string(),
|
||||
quickwit_endpoint: None,
|
||||
quickwit_index: None,
|
||||
default_tenant_slug: "admin".to_string(),
|
||||
};
|
||||
|
||||
let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?;
|
||||
@@ -152,20 +149,16 @@ impl TestApp {
|
||||
let state = AppState::new(pool.clone(), config, storage_for_state, jwt);
|
||||
let router = routes::create_router(state.clone());
|
||||
|
||||
let app = Self {
|
||||
Ok(Self {
|
||||
state,
|
||||
router,
|
||||
storage,
|
||||
};
|
||||
|
||||
app.ensure_default_tenant().await?;
|
||||
|
||||
Ok(app)
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn cleanup(&self) -> Result<()> {
|
||||
let pool = self.state.pool.clone();
|
||||
let _ = tokio::task::spawn_blocking(move || -> Result<()> {
|
||||
tokio::task::spawn_blocking(move || -> Result<()> {
|
||||
let mut conn = pool
|
||||
.get()
|
||||
.map_err(|err| anyhow!("failed to get cleanup connection: {err}"))?;
|
||||
@@ -173,10 +166,7 @@ impl TestApp {
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.context("cleanup task panicked")?;
|
||||
|
||||
self.ensure_default_tenant().await?;
|
||||
Ok(())
|
||||
.context("cleanup task panicked")?
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
@@ -184,111 +174,27 @@ impl TestApp {
|
||||
self.storage.clone()
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn storage_key_for(&self, key: &str) -> Result<String> {
|
||||
let tenant = self
|
||||
.state
|
||||
.tenants
|
||||
.get_by_slug(&self.state.config.default_tenant_slug)
|
||||
.map_err(|err| anyhow!("default tenant not found: {:?}", err))?;
|
||||
let root = tenant
|
||||
.storage_root
|
||||
.clone()
|
||||
.ok_or_else(|| anyhow!("default tenant missing storage root"))?;
|
||||
Ok(format!("{}{}", root, key))
|
||||
}
|
||||
|
||||
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))?;
|
||||
self.with_conn(move |conn| {
|
||||
let password_hash = hash_password(&password)?;
|
||||
let user = NewUser {
|
||||
id: Uuid::new_v4(),
|
||||
username,
|
||||
password_hash,
|
||||
role,
|
||||
};
|
||||
diesel::insert_into(backend::schema::users::table)
|
||||
.values(&user)
|
||||
.execute(conn)
|
||||
.context("failed to insert user")?;
|
||||
|
||||
let membership = NewUserMembership {
|
||||
id: Uuid::new_v4(),
|
||||
user_id: user.id,
|
||||
tenant_id,
|
||||
role,
|
||||
};
|
||||
|
||||
diesel::insert_into(backend::schema::user_memberships::table)
|
||||
.values(&membership)
|
||||
.execute(conn)
|
||||
.context("failed to insert user membership")?;
|
||||
Ok(user.id)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn ensure_default_tenant(&self) -> Result<Uuid> {
|
||||
let slug_value = self.state.config.default_tenant_slug.clone();
|
||||
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))
|
||||
.first::<Tenant>(conn)
|
||||
.optional()
|
||||
.context("failed to load default tenant")?;
|
||||
|
||||
let tenant_id = if let Some(current) = existing {
|
||||
let desired_root = current
|
||||
.storage_root
|
||||
.clone()
|
||||
.filter(|root| root.ends_with('/'))
|
||||
.unwrap_or_else(|| format!("test-tenants/{}/", current.id));
|
||||
|
||||
if current.storage_root.as_deref() != Some(desired_root.as_str()) {
|
||||
diesel::update(tenants_dsl::tenants.filter(tenants_dsl::id.eq(current.id)))
|
||||
.set(tenants_dsl::storage_root.eq(Some(desired_root)))
|
||||
.execute(conn)
|
||||
.context("failed to update default tenant storage root")?;
|
||||
}
|
||||
|
||||
current.id
|
||||
} else {
|
||||
let new_id = Uuid::new_v4();
|
||||
let root = format!("test-tenants/{}/", new_id);
|
||||
let quickwit_value = if quickwit_enabled {
|
||||
Some(format!("documents-{}", new_id))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
diesel::insert_into(tenants_dsl::tenants)
|
||||
.values((
|
||||
tenants_dsl::id.eq(new_id),
|
||||
tenants_dsl::slug.eq(&slug_value),
|
||||
tenants_dsl::storage_root.eq(Some(root)),
|
||||
tenants_dsl::quickwit_index.eq(quickwit_value),
|
||||
))
|
||||
.execute(conn)
|
||||
.context("failed to insert default tenant")?;
|
||||
|
||||
new_id
|
||||
};
|
||||
|
||||
Ok(tenant_id)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn login_token(&self, username: &str, password: &str) -> Result<String> {
|
||||
#[derive(Serialize)]
|
||||
struct LoginPayload<'a> {
|
||||
@@ -311,57 +217,11 @@ impl TestApp {
|
||||
);
|
||||
|
||||
let body = body_to_vec(response.into_body()).await?;
|
||||
#[derive(Deserialize)]
|
||||
#[derive(serde::Deserialize)]
|
||||
struct LoginResponse {
|
||||
access_token: String,
|
||||
}
|
||||
|
||||
if let Ok(parsed) = serde_json::from_slice::<LoginResponse>(&body) {
|
||||
return Ok(parsed.access_token);
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TenantSummary {
|
||||
tenant_id: Uuid,
|
||||
_slug: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TenantSelectionResponse {
|
||||
access_token: String,
|
||||
tenants: Vec<TenantSummary>,
|
||||
}
|
||||
|
||||
let selection: TenantSelectionResponse = serde_json::from_slice(&body)?;
|
||||
ensure!(
|
||||
!selection.tenants.is_empty(),
|
||||
"login returned no tenant options",
|
||||
);
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SelectTenantPayload {
|
||||
tenant_id: Uuid,
|
||||
}
|
||||
|
||||
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.access_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)?;
|
||||
let parsed: LoginResponse = serde_json::from_slice(&body)?;
|
||||
Ok(parsed.access_token)
|
||||
}
|
||||
|
||||
@@ -396,16 +256,6 @@ 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()
|
||||
@@ -415,9 +265,6 @@ 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
|
||||
@@ -491,60 +338,6 @@ impl TestApp {
|
||||
data: &[u8],
|
||||
folder_id: Option<Uuid>,
|
||||
token: &str,
|
||||
) -> Result<hyper::Response<Body>> {
|
||||
let extras = UploadExtras::empty();
|
||||
self.upload_document_with_extras(
|
||||
path,
|
||||
filename,
|
||||
content_type,
|
||||
data,
|
||||
folder_id,
|
||||
extras,
|
||||
token,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn upload_document_with_options(
|
||||
&self,
|
||||
path: &str,
|
||||
filename: &str,
|
||||
content_type: &str,
|
||||
data: &[u8],
|
||||
folder_id: Option<Uuid>,
|
||||
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();
|
||||
@@ -567,46 +360,6 @@ impl TestApp {
|
||||
body.extend(b"\r\n");
|
||||
}
|
||||
|
||||
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) = 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()
|
||||
@@ -627,7 +380,7 @@ impl TestApp {
|
||||
.expect("infallible response"))
|
||||
}
|
||||
|
||||
pub async fn with_conn<F, T>(&self, f: F) -> Result<T>
|
||||
async fn with_conn<F, T>(&self, f: F) -> Result<T>
|
||||
where
|
||||
F: FnOnce(&mut PgConnection) -> Result<T> + Send + 'static,
|
||||
T: Send + 'static,
|
||||
@@ -644,28 +397,6 @@ 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
|
||||
}
|
||||
@@ -695,29 +426,13 @@ async fn prepare_database(pool: &PgPool) -> Result<()> {
|
||||
|
||||
fn truncate_all(conn: &mut PgConnection) -> Result<()> {
|
||||
conn.batch_execute(
|
||||
"TRUNCATE TABLE \
|
||||
document_asset_objects, \
|
||||
document_assets, \
|
||||
document_correspondents, \
|
||||
correspondents, \
|
||||
document_tags, \
|
||||
document_versions, \
|
||||
documents, \
|
||||
folders, \
|
||||
jobs, \
|
||||
refresh_tokens, \
|
||||
tags, \
|
||||
webdav_tokens, \
|
||||
user_memberships, \
|
||||
users, \
|
||||
tenants \
|
||||
RESTART IDENTITY CASCADE;",
|
||||
"TRUNCATE TABLE document_tags, document_versions, documents, folders, tags, users RESTART IDENTITY CASCADE;",
|
||||
)
|
||||
.context("failed to truncate tables")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn hash_password(password: &str) -> Result<String> {
|
||||
fn hash_password(password: &str) -> Result<String> {
|
||||
use argon2::password_hash::{PasswordHasher, SaltString};
|
||||
use argon2::Argon2;
|
||||
|
||||
|
||||
@@ -1,276 +0,0 @@
|
||||
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)?)
|
||||
}
|
||||
+352
-898
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