19 Commits
Author SHA1 Message Date
nils 157f03b655 Skip uploads of existing documents and support checksum preflight in importer 2025-10-26 02:56:35 +02:00
nils 9b7ca3d692 409 on duplicate 2025-10-26 02:33:30 +02:00
nils e51a59a829 upload with more data 2025-10-26 02:28:02 +02:00
nils 62cadbcfa0 openapi 2025-10-25 23:46:01 +02:00
nils d7aefc4110 k8s: disable index creation job 2025-10-25 13:57:56 +02:00
nils e972a8dddb fix folder move to root 2025-10-25 02:11:05 +02:00
nils 88b9375a4e foo 2025-10-24 12:55:21 +02:00
nils f7a3e3f0f8 compose 2025-10-24 02:08:25 +02:00
nils 72df3dec3b reanalyze removal 2025-10-24 01:17:39 +02:00
nils b80ec5c6ac admin 2025-10-24 00:43:11 +02:00
nils fbd3aff6d8 user tenant_id 2025-10-23 16:58:52 +02:00
nils 0ad79c9bc1 more multi-tenancy 2025-10-23 16:06:56 +02:00
nils e175d28c2c multi tenancy part 1 2025-10-22 22:52:59 +02:00
nils 8e3f09774a frontend: remove tabindex=0 (confuses password managers) 2025-10-22 18:16:09 +02:00
nils dbc54032f7 backend: storage_paths.rs 2025-10-21 21:24:04 +02:00
nils 047b99e2aa frontend: multi-page assets
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 26s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 56s
2025-10-20 21:59:58 +02:00
nils 1859078cf4 backend: generate thumbnails and previews for all pages
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 15m27s
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 15m30s
2025-10-20 16:54:56 +02:00
nils c44ba91ec7 backend: split assets into objects 2025-10-20 16:28:57 +02:00
nils 96a6d0ee5d backend: reduce assets detail in DocumentVersionResponse 2025-10-20 14:26:11 +02:00
100 changed files with 6539 additions and 2334 deletions
+87 -25
View File
@@ -67,7 +67,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.106",
] ]
[[package]] [[package]]
@@ -635,6 +635,7 @@ dependencies = [
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"url", "url",
"utoipa",
"uuid", "uuid",
] ]
@@ -704,7 +705,7 @@ dependencies = [
"regex", "regex",
"rustc-hash", "rustc-hash",
"shlex", "shlex",
"syn", "syn 2.0.106",
] ]
[[package]] [[package]]
@@ -989,7 +990,7 @@ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"strsim", "strsim",
"syn", "syn 2.0.106",
] ]
[[package]] [[package]]
@@ -1000,7 +1001,7 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81"
dependencies = [ dependencies = [
"darling_core", "darling_core",
"quote", "quote",
"syn", "syn 2.0.106",
] ]
[[package]] [[package]]
@@ -1050,7 +1051,7 @@ dependencies = [
"dsl_auto_type", "dsl_auto_type",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.106",
] ]
[[package]] [[package]]
@@ -1070,7 +1071,7 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe2444076b48641147115697648dc743c2c00b61adade0f01ce67133c7babe8c" checksum = "fe2444076b48641147115697648dc743c2c00b61adade0f01ce67133c7babe8c"
dependencies = [ dependencies = [
"syn", "syn 2.0.106",
] ]
[[package]] [[package]]
@@ -1092,7 +1093,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.106",
] ]
[[package]] [[package]]
@@ -1118,7 +1119,7 @@ dependencies = [
"heck", "heck",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.106",
] ]
[[package]] [[package]]
@@ -1287,7 +1288,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.106",
] ]
[[package]] [[package]]
@@ -1812,6 +1813,8 @@ checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5"
dependencies = [ dependencies = [
"equivalent", "equivalent",
"hashbrown 0.16.0", "hashbrown 0.16.0",
"serde",
"serde_core",
] ]
[[package]] [[package]]
@@ -2267,7 +2270,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.106",
] ]
[[package]] [[package]]
@@ -2359,7 +2362,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"syn", "syn 2.0.106",
]
[[package]]
name = "proc-macro-error"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
dependencies = [
"proc-macro-error-attr",
"proc-macro2",
"quote",
"syn 1.0.109",
"version_check",
]
[[package]]
name = "proc-macro-error-attr"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
dependencies = [
"proc-macro2",
"quote",
"version_check",
] ]
[[package]] [[package]]
@@ -2894,7 +2921,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.106",
] ]
[[package]] [[package]]
@@ -3082,6 +3109,16 @@ version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" 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]] [[package]]
name = "syn" name = "syn"
version = "2.0.106" version = "2.0.106"
@@ -3110,7 +3147,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.106",
] ]
[[package]] [[package]]
@@ -3152,7 +3189,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.106",
] ]
[[package]] [[package]]
@@ -3163,7 +3200,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.106",
] ]
[[package]] [[package]]
@@ -3259,7 +3296,7 @@ checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.106",
] ]
[[package]] [[package]]
@@ -3409,7 +3446,7 @@ checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.106",
] ]
[[package]] [[package]]
@@ -3514,6 +3551,31 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "utoipa"
version = "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]] [[package]]
name = "uuid" name = "uuid"
version = "1.18.1" version = "1.18.1"
@@ -3615,7 +3677,7 @@ dependencies = [
"log", "log",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.106",
"wasm-bindgen-shared", "wasm-bindgen-shared",
] ]
@@ -3650,7 +3712,7 @@ checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.106",
"wasm-bindgen-backend", "wasm-bindgen-backend",
"wasm-bindgen-shared", "wasm-bindgen-shared",
] ]
@@ -3727,7 +3789,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.106",
] ]
[[package]] [[package]]
@@ -3738,7 +3800,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.106",
] ]
[[package]] [[package]]
@@ -3965,7 +4027,7 @@ checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.106",
"synstructure", "synstructure",
] ]
@@ -3986,7 +4048,7 @@ checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.106",
] ]
[[package]] [[package]]
@@ -4006,7 +4068,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.106",
"synstructure", "synstructure",
] ]
@@ -4046,7 +4108,7 @@ checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.106",
] ]
[[package]] [[package]]
+2
View File
@@ -44,6 +44,8 @@ base64 = "0.21"
quick-xml = "0.32" quick-xml = "0.32"
futures-util = "0.3" futures-util = "0.3"
url = "2.5" url = "2.5"
once_cell = "1.19"
utoipa = { version = "4.2", default-features = false, features = ["chrono", "uuid", "preserve_order"] }
# Error handling # Error handling
thiserror = "1.0" thiserror = "1.0"
+2 -1
View File
@@ -20,7 +20,7 @@ COPY migrations ./migrations
COPY tests ./tests COPY tests ./tests
COPY diesel.toml ./ COPY diesel.toml ./
RUN cargo build --release --bin backend --bin worker --bin webdav RUN cargo build --release --bin backend --bin worker --bin webdav --bin admin
RUN cargo install diesel_cli --no-default-features --features postgres RUN cargo install diesel_cli --no-default-features --features postgres
FROM debian:trixie-slim AS runtime FROM debian:trixie-slim AS runtime
@@ -53,6 +53,7 @@ RUN apt-get update \
COPY --from=builder /app/target/release/backend /usr/local/bin/papercrate-backend COPY --from=builder /app/target/release/backend /usr/local/bin/papercrate-backend
COPY --from=builder /app/target/release/worker /usr/local/bin/papercrate-worker 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/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 --from=builder /usr/local/cargo/bin/diesel /usr/local/bin/diesel
COPY migrations ./migrations COPY migrations ./migrations
COPY diesel.toml ./ COPY diesel.toml ./
-19
View File
@@ -1,19 +0,0 @@
use argon2::{
password_hash::{PasswordHasher, SaltString},
Argon2,
};
use rand::thread_rng;
use std::env;
fn main() {
let password = env::args()
.nth(1)
.expect("Usage: cargo run --example hash_password <password>");
let salt = SaltString::generate(&mut thread_rng());
let argon2 = Argon2::default();
let hash = argon2
.hash_password(password.as_bytes(), &salt)
.expect("hashing failed")
.to_string();
println!("{}", hash);
}
@@ -1,11 +0,0 @@
DROP INDEX IF EXISTS idx_document_tags_tag;
DROP TABLE IF EXISTS document_tags;
DROP INDEX IF EXISTS idx_document_versions_document;
DROP TABLE IF EXISTS document_versions;
DROP INDEX IF EXISTS idx_documents_deleted_at;
DROP INDEX IF EXISTS idx_documents_folder;
DROP TABLE IF EXISTS documents;
DROP INDEX IF EXISTS idx_folders_parent;
DROP TABLE IF EXISTS folders;
DROP TABLE IF EXISTS tags;
DROP TABLE IF EXISTS users;
@@ -1,77 +0,0 @@
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE TABLE users (
id UUID PRIMARY KEY,
username VARCHAR(100) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
role VARCHAR(16) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE folders (
id UUID PRIMARY KEY,
name VARCHAR(255) NOT NULL,
parent_id UUID REFERENCES folders(id) ON DELETE SET NULL,
path_cache VARCHAR(1000),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT folders_parent_name_unique UNIQUE (parent_id, name)
);
CREATE INDEX idx_folders_parent ON folders(parent_id);
CREATE TABLE documents (
id UUID PRIMARY KEY,
filename VARCHAR(255) NOT NULL,
original_name VARCHAR(255) NOT NULL,
content_type VARCHAR(100),
folder_id UUID REFERENCES folders(id) ON DELETE SET NULL,
current_version INTEGER NOT NULL,
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb
);
CREATE INDEX idx_documents_folder ON documents(folder_id);
CREATE INDEX idx_documents_deleted_at ON documents(deleted_at);
CREATE TABLE document_versions (
id UUID PRIMARY KEY,
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
version_number INTEGER NOT NULL,
s3_key VARCHAR(500) NOT NULL,
size_bytes BIGINT NOT NULL,
checksum VARCHAR(64) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
operations_summary JSONB NOT NULL DEFAULT '{}'::jsonb,
CONSTRAINT document_versions_unique_version UNIQUE (document_id, version_number)
);
CREATE INDEX idx_document_versions_document ON document_versions(document_id);
CREATE TABLE tags (
id UUID PRIMARY KEY,
label VARCHAR(100) NOT NULL UNIQUE,
color VARCHAR(7),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE document_tags (
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(),
assigned_by UUID REFERENCES users(id),
PRIMARY KEY (document_id, tag_id)
);
CREATE INDEX idx_document_tags_tag ON document_tags(tag_id);
INSERT INTO users (id, username, password_hash, role)
VALUES (
gen_random_uuid(),
'admin',
'$argon2id$v=19$m=19456,t=2,p=1$UMkfsNut028fmZupy9JoQg$/YFvGQoEZ2hhMiDCyv68ZROF97GcwAxxRwRgwSbpX5U',
'admin'
);
@@ -1,5 +0,0 @@
DROP TRIGGER IF EXISTS trg_jobs_updated_at ON jobs;
DROP FUNCTION IF EXISTS touch_jobs_updated_at;
DROP INDEX IF EXISTS idx_jobs_job_type;
DROP INDEX IF EXISTS idx_jobs_status_run_after;
DROP TABLE IF EXISTS jobs;
@@ -1,28 +0,0 @@
CREATE TABLE jobs (
id UUID PRIMARY KEY,
job_type TEXT NOT NULL,
payload JSONB NOT NULL,
status TEXT NOT NULL DEFAULT 'queued',
attempts INTEGER NOT NULL DEFAULT 0,
run_after TIMESTAMPTZ NOT NULL DEFAULT now(),
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT jobs_status_check CHECK (status IN ('queued', 'processing', 'succeeded', 'failed'))
);
CREATE INDEX idx_jobs_status_run_after ON jobs (status, run_after);
CREATE INDEX idx_jobs_job_type ON jobs (job_type);
CREATE OR REPLACE FUNCTION touch_jobs_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_jobs_updated_at
BEFORE UPDATE ON jobs
FOR EACH ROW
EXECUTE FUNCTION touch_jobs_updated_at();
@@ -1,3 +0,0 @@
DROP INDEX IF EXISTS idx_document_assets_type;
DROP INDEX IF EXISTS idx_document_assets_version;
DROP TABLE IF EXISTS document_assets;
@@ -1,15 +0,0 @@
CREATE TABLE document_assets (
id UUID PRIMARY KEY,
document_version_id UUID NOT NULL REFERENCES document_versions(id) ON DELETE CASCADE,
asset_type TEXT NOT NULL,
s3_key TEXT NOT NULL,
mime_type TEXT NOT NULL,
width INTEGER,
height INTEGER,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT document_assets_unique UNIQUE (document_version_id, asset_type)
);
CREATE INDEX idx_document_assets_version ON document_assets(document_version_id);
CREATE INDEX idx_document_assets_type ON document_assets(asset_type);
@@ -1,4 +0,0 @@
DROP INDEX IF EXISTS folders_parent_name_unique_idx;
ALTER TABLE folders
ADD CONSTRAINT folders_parent_name_unique UNIQUE (parent_id, name);
@@ -1,5 +0,0 @@
ALTER TABLE folders
DROP CONSTRAINT IF EXISTS folders_parent_name_unique;
CREATE UNIQUE INDEX folders_parent_name_unique_idx
ON folders (COALESCE(parent_id, '00000000-0000-0000-0000-000000000000'::uuid), name);
@@ -1,2 +0,0 @@
ALTER TABLE documents
DROP COLUMN issued_at;
@@ -1,2 +0,0 @@
ALTER TABLE documents
ADD COLUMN issued_at TIMESTAMPTZ;
@@ -1,2 +0,0 @@
ALTER TABLE documents
DROP COLUMN name;
@@ -1,11 +0,0 @@
ALTER TABLE documents
ADD COLUMN name VARCHAR(255);
UPDATE documents
SET name = CASE
WHEN filename ~ '\\.[^./]+$' THEN regexp_replace(filename, '\\.[^./]+$', '')
ELSE filename
END;
ALTER TABLE documents
ALTER COLUMN name SET NOT NULL;
@@ -1,2 +0,0 @@
ALTER TABLE documents
RENAME COLUMN title TO name;
@@ -1,2 +0,0 @@
ALTER TABLE documents
RENAME COLUMN name TO title;
@@ -1 +0,0 @@
DROP TABLE refresh_tokens;
@@ -1,13 +0,0 @@
CREATE TABLE refresh_tokens (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL,
issued_at TIMESTAMPTZ NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id);
CREATE INDEX idx_refresh_tokens_token_hash ON refresh_tokens(token_hash);
@@ -1,13 +0,0 @@
-- Restore width/height columns and repopulate from metadata where available.
ALTER TABLE document_assets
ADD COLUMN width INTEGER,
ADD COLUMN height INTEGER;
UPDATE document_assets
SET width = (metadata->>'width')::INTEGER
WHERE metadata ? 'width';
UPDATE document_assets
SET height = (metadata->>'height')::INTEGER
WHERE metadata ? 'height';
@@ -1,15 +0,0 @@
-- Backfill existing width/height values into metadata then drop the columns.
UPDATE document_assets
SET metadata = metadata || jsonb_build_object('width', width)
WHERE width IS NOT NULL
AND NOT (metadata ? 'width');
UPDATE document_assets
SET metadata = metadata || jsonb_build_object('height', height)
WHERE height IS NOT NULL
AND NOT (metadata ? 'height');
ALTER TABLE document_assets
DROP COLUMN width,
DROP COLUMN height;
@@ -1,17 +0,0 @@
ALTER TABLE documents ADD COLUMN current_version INT4;
UPDATE documents AS d
SET current_version = dv.version_number
FROM document_versions AS dv
WHERE dv.id = d.current_version_id;
ALTER TABLE documents
ALTER COLUMN current_version SET NOT NULL;
DROP INDEX IF EXISTS idx_documents_current_version_id;
ALTER TABLE documents
DROP CONSTRAINT IF EXISTS documents_current_version_fk;
ALTER TABLE documents
DROP COLUMN current_version_id;
@@ -1,21 +0,0 @@
ALTER TABLE documents ADD COLUMN current_version_id UUID;
UPDATE documents AS d
SET current_version_id = dv.id
FROM document_versions AS dv
WHERE dv.document_id = d.id
AND dv.version_number = d.current_version;
ALTER TABLE documents
ALTER COLUMN current_version_id SET NOT NULL;
ALTER TABLE documents
ADD CONSTRAINT documents_current_version_fk
FOREIGN KEY (current_version_id)
REFERENCES document_versions(id)
DEFERRABLE INITIALLY DEFERRED;
CREATE INDEX idx_documents_current_version_id
ON documents(current_version_id);
ALTER TABLE documents DROP COLUMN current_version;
@@ -1,5 +0,0 @@
DROP INDEX IF EXISTS idx_document_correspondents_role;
DROP INDEX IF EXISTS idx_document_correspondents_correspondent;
DROP INDEX IF EXISTS idx_document_correspondents_document;
DROP TABLE IF EXISTS document_correspondents;
DROP TABLE IF EXISTS correspondents;
@@ -1,27 +0,0 @@
CREATE TABLE correspondents (
id UUID PRIMARY KEY,
name VARCHAR(255) NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT correspondents_name_unique UNIQUE (name)
);
CREATE TABLE document_correspondents (
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
correspondent_id UUID NOT NULL REFERENCES correspondents(id) ON DELETE CASCADE,
role VARCHAR(32) NOT NULL,
assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(),
assigned_by UUID REFERENCES users(id),
PRIMARY KEY (document_id, correspondent_id, role),
CONSTRAINT document_correspondents_role_check CHECK (role IN ('sender', 'receiver', 'other'))
);
CREATE INDEX idx_document_correspondents_document
ON document_correspondents(document_id);
CREATE INDEX idx_document_correspondents_correspondent
ON document_correspondents(correspondent_id);
CREATE INDEX idx_document_correspondents_role
ON document_correspondents(role);
@@ -1,2 +0,0 @@
DROP INDEX IF EXISTS idx_documents_folder_filename;
DROP INDEX IF EXISTS idx_documents_folder_title;
@@ -1,13 +0,0 @@
CREATE INDEX idx_documents_folder_title
ON documents (
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
title
)
WHERE deleted_at IS NULL;
CREATE INDEX idx_documents_folder_filename
ON documents (
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
filename
)
WHERE deleted_at IS NULL;
@@ -1,16 +0,0 @@
DROP INDEX IF EXISTS documents_unique_folder_filename;
DROP INDEX IF EXISTS idx_documents_folder_title;
CREATE INDEX idx_documents_folder_title
ON documents (
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
title
)
WHERE deleted_at IS NULL;
CREATE INDEX idx_documents_folder_filename
ON documents (
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
filename
)
WHERE deleted_at IS NULL;
@@ -1,18 +0,0 @@
DROP INDEX IF EXISTS idx_documents_folder_title;
DROP INDEX IF EXISTS idx_documents_folder_filename;
DROP INDEX IF EXISTS documents_unique_folder_title;
DROP INDEX IF EXISTS documents_unique_folder_filename;
CREATE INDEX idx_documents_folder_title
ON documents (
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
title
)
WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX documents_unique_folder_filename
ON documents (
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
filename
)
WHERE deleted_at IS NULL;
@@ -1,2 +0,0 @@
ALTER TABLE document_versions
DROP COLUMN metadata;
@@ -1,2 +0,0 @@
ALTER TABLE document_versions
ADD COLUMN metadata JSONB NOT NULL DEFAULT '{}'::jsonb;
@@ -1,2 +0,0 @@
ALTER TABLE folders
ADD COLUMN path_cache VARCHAR(1000);
@@ -1,2 +0,0 @@
ALTER TABLE folders
DROP COLUMN path_cache;
@@ -1,34 +0,0 @@
ALTER TABLE document_assets
ADD COLUMN s3_key TEXT;
UPDATE document_assets AS da
SET s3_key = dao.s3_key
FROM document_asset_objects AS dao
WHERE dao.asset_id = da.id
AND dao.ordinal = 1
AND da.s3_key IS NULL;
ALTER TABLE document_assets
ALTER COLUMN s3_key SET NOT NULL;
UPDATE document_assets AS da
SET metadata = jsonb_set(da.metadata, '{width}', dao.metadata->'width', true)
FROM document_asset_objects AS dao
WHERE dao.asset_id = da.id
AND dao.ordinal = 1
AND dao.metadata ? 'width';
UPDATE document_assets AS da
SET metadata = jsonb_set(da.metadata, '{height}', dao.metadata->'height', true)
FROM document_asset_objects AS dao
WHERE dao.asset_id = da.id
AND dao.ordinal = 1
AND dao.metadata ? 'height';
DROP INDEX IF EXISTS idx_document_asset_objects_asset_ordinal;
DROP TABLE IF EXISTS document_asset_objects;
ALTER TABLE document_assets
DROP CONSTRAINT IF EXISTS document_assets_cardinality_positive;
ALTER TABLE document_assets
DROP COLUMN IF EXISTS cardinality;
@@ -1,54 +0,0 @@
ALTER TABLE document_assets
ADD COLUMN cardinality INTEGER,
ADD CONSTRAINT document_assets_cardinality_positive CHECK (cardinality IS NULL OR cardinality >= 1);
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,
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);
INSERT INTO document_asset_objects (id, asset_id, ordinal, s3_key, metadata)
SELECT
gen_random_uuid(),
id,
1,
s3_key,
'{}'::jsonb
FROM document_assets;
UPDATE document_asset_objects AS dao
SET metadata = jsonb_set(dao.metadata, '{width}', da.metadata->'width', true)
FROM document_assets AS da
WHERE dao.asset_id = da.id
AND dao.ordinal = 1
AND da.metadata ? 'width';
UPDATE document_asset_objects AS dao
SET metadata = jsonb_set(dao.metadata, '{height}', da.metadata->'height', true)
FROM document_assets AS da
WHERE dao.asset_id = da.id
AND dao.ordinal = 1
AND da.metadata ? 'height';
UPDATE document_assets
SET metadata = metadata - 'width'
WHERE metadata ? 'width';
UPDATE document_assets
SET metadata = metadata - 'height'
WHERE metadata ? 'height';
UPDATE document_assets
SET cardinality = 1
WHERE cardinality IS NULL;
ALTER TABLE document_assets
DROP COLUMN s3_key;
@@ -0,0 +1,21 @@
DROP TRIGGER IF EXISTS trg_jobs_updated_at ON jobs;
DROP FUNCTION IF EXISTS touch_jobs_updated_at();
ALTER TABLE documents DROP CONSTRAINT IF EXISTS documents_current_version_fk;
DROP TABLE IF EXISTS document_asset_objects;
DROP TABLE IF EXISTS document_assets;
DROP TABLE IF EXISTS document_versions;
DROP TABLE IF EXISTS document_tags;
DROP TABLE IF EXISTS document_correspondents;
DROP TABLE IF EXISTS correspondents;
DROP TABLE IF EXISTS documents;
DROP TABLE IF EXISTS folders;
DROP TABLE IF EXISTS tags;
DROP TABLE IF EXISTS jobs;
DROP TABLE IF EXISTS refresh_tokens;
DROP TABLE IF EXISTS user_memberships;
DROP TABLE IF EXISTS users;
DROP TABLE IF EXISTS tenants;
DROP EXTENSION IF EXISTS "pgcrypto";
@@ -0,0 +1,244 @@
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE TABLE tenants (
id UUID PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
storage_root TEXT,
quickwit_index TEXT,
status TEXT NOT NULL DEFAULT 'active',
config JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX tenants_storage_root_unique
ON tenants (storage_root)
WHERE storage_root IS NOT NULL;
CREATE UNIQUE INDEX tenants_quickwit_index_unique
ON tenants (quickwit_index)
WHERE quickwit_index IS NOT NULL;
CREATE TABLE users (
id UUID PRIMARY KEY,
username VARCHAR(100) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE user_memberships (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
role TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_id, tenant_id)
);
CREATE INDEX user_memberships_tenant_id_idx ON user_memberships (tenant_id);
CREATE INDEX user_memberships_user_id_idx ON user_memberships (user_id);
CREATE TABLE folders (
id UUID PRIMARY KEY,
name VARCHAR(255) NOT NULL,
parent_id UUID REFERENCES folders(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
tenant_id UUID NOT NULL REFERENCES tenants(id)
);
CREATE INDEX idx_folders_parent ON folders(parent_id);
CREATE INDEX folders_tenant_id_idx ON folders(tenant_id);
CREATE UNIQUE INDEX folders_parent_name_unique_idx
ON folders (COALESCE(parent_id, '00000000-0000-0000-0000-000000000000'::uuid), name);
CREATE TABLE documents (
id UUID PRIMARY KEY,
filename VARCHAR(255) NOT NULL,
original_name VARCHAR(255) NOT NULL,
content_type VARCHAR(100),
folder_id UUID REFERENCES folders(id) ON DELETE SET NULL,
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
issued_at TIMESTAMPTZ,
title VARCHAR(255) NOT NULL,
current_version_id UUID NOT NULL,
tenant_id UUID NOT NULL REFERENCES tenants(id)
);
CREATE INDEX idx_documents_folder ON documents (folder_id);
CREATE INDEX idx_documents_deleted_at ON documents (deleted_at);
CREATE INDEX documents_tenant_id_idx ON documents (tenant_id);
CREATE INDEX idx_documents_current_version_id ON documents (current_version_id);
CREATE INDEX idx_documents_folder_title
ON documents (
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
title
)
WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX documents_unique_folder_filename
ON documents (
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
filename
)
WHERE deleted_at IS NULL;
CREATE TABLE document_versions (
id UUID PRIMARY KEY,
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
version_number INT NOT NULL,
s3_key VARCHAR(500) NOT NULL,
size_bytes BIGINT NOT NULL,
checksum VARCHAR(64) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
operations_summary JSONB NOT NULL DEFAULT '{}'::jsonb,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
tenant_id UUID NOT NULL REFERENCES tenants(id),
CONSTRAINT document_versions_unique_version UNIQUE (document_id, version_number)
);
CREATE INDEX idx_document_versions_document ON document_versions (document_id);
CREATE INDEX document_versions_tenant_id_idx ON document_versions (tenant_id);
ALTER TABLE documents
ADD CONSTRAINT documents_current_version_fk
FOREIGN KEY (current_version_id)
REFERENCES document_versions(id)
DEFERRABLE INITIALLY DEFERRED;
CREATE TABLE tags (
id UUID PRIMARY KEY,
label VARCHAR(100) NOT NULL UNIQUE,
color VARCHAR(7),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
tenant_id UUID NOT NULL REFERENCES tenants(id)
);
CREATE INDEX tags_tenant_id_idx ON tags (tenant_id);
CREATE TABLE document_tags (
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
assigned_by UUID REFERENCES users(id),
tenant_id UUID NOT NULL REFERENCES tenants(id),
PRIMARY KEY (document_id, tag_id)
);
CREATE INDEX idx_document_tags_tag ON document_tags (tag_id);
CREATE INDEX document_tags_tenant_id_idx ON document_tags (tenant_id);
CREATE TABLE correspondents (
id UUID PRIMARY KEY,
name VARCHAR(255) NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
CONSTRAINT correspondents_name_unique UNIQUE (name)
);
CREATE INDEX correspondents_tenant_id_idx ON correspondents (tenant_id);
CREATE TABLE document_correspondents (
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
correspondent_id UUID NOT NULL REFERENCES correspondents(id) ON DELETE CASCADE,
role VARCHAR(32) NOT NULL,
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
assigned_by UUID REFERENCES users(id),
tenant_id UUID NOT NULL REFERENCES tenants(id),
PRIMARY KEY (document_id, correspondent_id, role),
CONSTRAINT document_correspondents_role_check CHECK (role IN ('sender', 'receiver', 'other'))
);
CREATE INDEX idx_document_correspondents_document ON document_correspondents (document_id);
CREATE INDEX idx_document_correspondents_correspondent ON document_correspondents (correspondent_id);
CREATE INDEX idx_document_correspondents_role ON document_correspondents (role);
CREATE INDEX document_correspondents_tenant_id_idx ON document_correspondents (tenant_id);
CREATE TABLE document_assets (
id UUID PRIMARY KEY,
document_version_id UUID NOT NULL REFERENCES document_versions(id) ON DELETE CASCADE,
asset_type TEXT NOT NULL,
mime_type TEXT NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
cardinality INTEGER,
tenant_id UUID NOT NULL REFERENCES tenants(id),
CONSTRAINT document_assets_unique UNIQUE (document_version_id, asset_type),
CONSTRAINT document_assets_cardinality_positive CHECK (cardinality IS NULL OR cardinality >= 1)
);
CREATE INDEX idx_document_assets_version ON document_assets (document_version_id);
CREATE INDEX idx_document_assets_type ON document_assets (asset_type);
CREATE INDEX document_assets_tenant_id_idx ON document_assets (tenant_id);
CREATE TABLE document_asset_objects (
id UUID PRIMARY KEY,
asset_id UUID NOT NULL REFERENCES document_assets(id) ON DELETE CASCADE,
ordinal INTEGER NOT NULL,
s3_key TEXT NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
tenant_id UUID NOT NULL REFERENCES tenants(id),
CONSTRAINT document_asset_objects_ordinal_positive CHECK (ordinal >= 1),
CONSTRAINT document_asset_objects_asset_ordinal_unique UNIQUE (asset_id, ordinal)
);
CREATE INDEX idx_document_asset_objects_asset_ordinal
ON document_asset_objects (asset_id, ordinal);
CREATE INDEX document_asset_objects_tenant_id_idx
ON document_asset_objects (tenant_id);
CREATE TABLE jobs (
id UUID PRIMARY KEY,
job_type TEXT NOT NULL,
payload JSONB NOT NULL,
status TEXT NOT NULL DEFAULT 'queued',
attempts INTEGER NOT NULL DEFAULT 0,
run_after TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
CONSTRAINT jobs_status_check CHECK (status IN ('queued', 'processing', 'succeeded', 'failed'))
);
CREATE INDEX idx_jobs_status_run_after ON jobs (status, run_after);
CREATE INDEX idx_jobs_job_type ON jobs (job_type);
CREATE INDEX jobs_tenant_id_idx ON jobs (tenant_id);
CREATE OR REPLACE FUNCTION touch_jobs_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_jobs_updated_at
BEFORE UPDATE ON jobs
FOR EACH ROW
EXECUTE FUNCTION touch_jobs_updated_at();
CREATE TABLE refresh_tokens (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL,
issued_at TIMESTAMPTZ NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
tenant_id UUID NOT NULL REFERENCES tenants(id)
);
CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens (user_id);
CREATE INDEX idx_refresh_tokens_token_hash ON refresh_tokens (token_hash);
CREATE INDEX refresh_tokens_tenant_id_idx ON refresh_tokens (tenant_id);
+46 -4
View File
@@ -15,6 +15,8 @@ pub struct JwtService {
expiry: Duration, expiry: Duration,
download_audience: String, download_audience: String,
download_expiry: Duration, download_expiry: Duration,
selector_audience: String,
selector_expiry: Duration,
} }
impl JwtService { impl JwtService {
@@ -27,16 +29,18 @@ impl JwtService {
expiry: Duration::minutes(config.jwt_expiry_minutes), expiry: Duration::minutes(config.jwt_expiry_minutes),
download_audience: config.download_token_audience.clone(), download_audience: config.download_token_audience.clone(),
download_expiry: Duration::minutes(config.download_token_expiry_minutes), 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, username: &str, role: &str) -> Result<String> { pub fn generate_token(&self, user_id: Uuid, tenant_id: Uuid, username: &str) -> Result<String> {
let now = Utc::now(); let now = Utc::now();
let exp = now + self.expiry; let exp = now + self.expiry;
let claims = Claims { let claims = Claims {
sub: user_id, sub: user_id,
tenant_id,
username: username.to_owned(), username: username.to_owned(),
role: role.to_owned(),
iss: self.issuer.clone(), iss: self.issuer.clone(),
aud: self.audience.clone(), aud: self.audience.clone(),
iat: now.timestamp() as usize, iat: now.timestamp() as usize,
@@ -54,12 +58,18 @@ impl JwtService {
Ok(data.claims) Ok(data.claims)
} }
pub fn generate_download_token(&self, document_id: Uuid, user_id: Uuid) -> Result<String> { pub fn generate_download_token(
&self,
document_id: Uuid,
user_id: Uuid,
tenant_id: Uuid,
) -> Result<String> {
let now = Utc::now(); let now = Utc::now();
let exp = now + self.download_expiry; let exp = now + self.download_expiry;
let claims = DownloadClaims { let claims = DownloadClaims {
doc_id: document_id, doc_id: document_id,
user_id, user_id,
tenant_id,
iss: self.issuer.clone(), iss: self.issuer.clone(),
aud: self.download_audience.clone(), aud: self.download_audience.clone(),
iat: now.timestamp() as usize, iat: now.timestamp() as usize,
@@ -76,13 +86,35 @@ impl JwtService {
let data = decode::<DownloadClaims>(token, &self.decoding, &validation)?; let data = decode::<DownloadClaims>(token, &self.decoding, &validation)?;
Ok(data.claims) 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Claims { pub struct Claims {
pub sub: Uuid, pub sub: Uuid,
pub tenant_id: Uuid,
pub username: String, pub username: String,
pub role: String,
pub iss: String, pub iss: String,
pub aud: String, pub aud: String,
pub iat: usize, pub iat: usize,
@@ -93,6 +125,16 @@ pub struct Claims {
pub struct DownloadClaims { pub struct DownloadClaims {
pub doc_id: Uuid, pub doc_id: Uuid,
pub user_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 iss: String,
pub aud: String, pub aud: String,
pub iat: usize, pub iat: usize,
+50 -4
View File
@@ -6,13 +6,17 @@ use axum_extra::headers::{authorization::Bearer, Authorization};
use axum_extra::TypedHeader; use axum_extra::TypedHeader;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{error::AppError, state::AppState}; use crate::{
error::AppError,
state::{AppState, PgPooledConnection},
};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthenticatedUser { pub struct AuthenticatedUser {
pub user_id: uuid::Uuid, pub user_id: uuid::Uuid,
pub username: String, pub username: String,
pub role: String, pub tenant_id: uuid::Uuid,
} }
#[async_trait] #[async_trait]
@@ -23,6 +27,10 @@ impl FromRequestParts<AppState> for AuthenticatedUser {
parts: &mut Parts, parts: &mut Parts,
state: &AppState, state: &AppState,
) -> Result<Self, Self::Rejection> { ) -> Result<Self, Self::Rejection> {
if let Some(user) = parts.extensions.get::<AuthenticatedUser>() {
return Ok(user.clone());
}
let TypedHeader(Authorization(bearer)) = let TypedHeader(Authorization(bearer)) =
TypedHeader::<Authorization<Bearer>>::from_request_parts(parts, state) TypedHeader::<Authorization<Bearer>>::from_request_parts(parts, state)
.await .await
@@ -33,10 +41,48 @@ impl FromRequestParts<AppState> for AuthenticatedUser {
.verify_token(bearer.token()) .verify_token(bearer.token())
.map_err(|_| AppError::unauthorized())?; .map_err(|_| AppError::unauthorized())?;
Ok(AuthenticatedUser { let user = AuthenticatedUser {
user_id: claims.sub, user_id: claims.sub,
username: claims.username, username: claims.username,
role: claims.role, tenant_id: claims.tenant_id,
};
parts.extensions.insert(user.clone());
Ok(user)
}
}
pub struct TenantScopedConn {
pub conn: PgPooledConnection,
pub tenant_id: Uuid,
pub user_id: Uuid,
pub user: AuthenticatedUser,
}
impl TenantScopedConn {
pub fn conn(&mut self) -> &mut PgPooledConnection {
&mut self.conn
}
}
#[async_trait]
impl FromRequestParts<AppState> for TenantScopedConn {
type Rejection = AppError;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
let user = AuthenticatedUser::from_request_parts(parts, state).await?;
let tenant_id = user.tenant_id;
let conn = state.db_for_tenant(tenant_id)?;
Ok(Self {
conn,
tenant_id,
user_id: user.user_id,
user,
}) })
} }
} }
+707
View File
@@ -0,0 +1,707 @@
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()
}
-83
View File
@@ -1,83 +0,0 @@
use std::env;
use anyhow::{Context, Result};
use diesel::prelude::*;
use uuid::Uuid;
use backend::{
config::AppConfig,
db,
models::{DocumentAsset, DocumentAssetObject},
s3,
schema::{document_asset_objects, 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());
let asset_ids: Vec<Uuid> = assets.iter().map(|asset| asset.id).collect();
let objects: Vec<DocumentAssetObject> = document_asset_objects::table
.filter(document_asset_objects::asset_id.eq_any(&asset_ids))
.load(&mut conn)
.context("failed to load document asset objects")?;
for object in &objects {
if let Err(err) = storage.delete_object(&object.s3_key).await {
eprintln!(
"Failed to delete object {} from storage: {err}",
object.s3_key
);
}
}
diesel::delete(document_assets::table)
.execute(&mut conn)
.context("failed to remove asset records")?;
println!("Asset records deleted.");
Ok(())
}
+8
View File
@@ -0,0 +1,8 @@
use backend::openapi::ApiDoc;
use utoipa::OpenApi;
fn main() {
let spec = ApiDoc::openapi();
let json = serde_json::to_string_pretty(&spec).expect("serialize openapi");
println!("{}", json);
}
+9 -41
View File
@@ -1,47 +1,24 @@
use std::net::SocketAddr; use std::net::SocketAddr;
use std::sync::Arc;
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tower::make::Shared; use tower::make::Shared;
use tracing_subscriber::EnvFilter;
use backend::auth::jwt::JwtService; use backend::{routes::webdav, utils::bootstrap::init_component};
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] #[tokio::main]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
dotenv::dotenv().ok(); let state = init_component("webdav", None).await?;
init_tracing(); let webdav_host = state.config.webdav_host.clone();
let webdav_port = state.config.webdav_port;
let config = AppConfig::from_env()?;
tracing::info!( tracing::info!(
component = "webdav", component = "webdav",
database_url = %config.redacted_database_url(), webdav_host = %webdav_host,
pool_size = config.database_max_pool_size, webdav_port,
server_host = %config.server_host, "starting webdav server"
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 state = AppState::new(pool, config, storage, jwt); let listen_addr: SocketAddr = format!("{}:{}", webdav_host, webdav_port).parse()?;
let listen_addr: SocketAddr = { let router = webdav::create_router().with_state(state.as_ref().clone());
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?; let listener = TcpListener::bind(listen_addr).await?;
tracing::info!("listening for WebDAV on {}", listen_addr); tracing::info!("listening for WebDAV on {}", listen_addr);
@@ -49,12 +26,3 @@ async fn main() -> anyhow::Result<()> {
axum::serve(listener, Shared::new(router)).await?; axum::serve(listener, Shared::new(router)).await?;
Ok(()) 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();
}
+4 -33
View File
@@ -1,33 +1,13 @@
use std::{sync::Arc, time::Duration}; use std::time::Duration;
use tokio::signal; use tokio::signal;
use tracing_subscriber::EnvFilter;
use backend::{ use backend::{default_handlers, utils::bootstrap::init_component, Worker};
auth::jwt::JwtService, config::AppConfig, db, default_handlers, s3::build_client,
state::AppState, storage::S3Storage, Worker,
};
#[tokio::main] #[tokio::main]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
dotenv::dotenv().ok(); let state = init_component("worker", Some(1)).await?;
init_tracing(); tracing::info!(component = "worker", "starting worker process");
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)); let worker = Worker::new(state, default_handlers(), Duration::from_secs(2));
tokio::select! { tokio::select! {
@@ -39,12 +19,3 @@ async fn main() -> anyhow::Result<()> {
Ok(()) 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();
}
+24 -2
View File
@@ -30,9 +30,24 @@ pub struct AppConfig {
pub s3_bucket: String, pub s3_bucket: String,
pub quickwit_endpoint: Option<String>, pub quickwit_endpoint: Option<String>,
pub quickwit_index: Option<String>, pub quickwit_index: Option<String>,
pub default_tenant_slug: String,
} }
impl AppConfig { 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> { pub fn from_env() -> Result<Self> {
let database_url = env::var("DATABASE_URL").context("DATABASE_URL must be set")?; let database_url = env::var("DATABASE_URL").context("DATABASE_URL must be set")?;
let database_max_pool_size = env::var("DATABASE_MAX_POOL_SIZE") let database_max_pool_size = env::var("DATABASE_MAX_POOL_SIZE")
@@ -79,6 +94,8 @@ impl AppConfig {
let s3_bucket = env::var("S3_BUCKET").context("S3_BUCKET must be set")?; let s3_bucket = env::var("S3_BUCKET").context("S3_BUCKET must be set")?;
let quickwit_endpoint = env::var("QUICKWIT_ENDPOINT").ok(); let quickwit_endpoint = env::var("QUICKWIT_ENDPOINT").ok();
let quickwit_index = env::var("QUICKWIT_INDEX").ok(); let quickwit_index = env::var("QUICKWIT_INDEX").ok();
let default_tenant_slug =
env::var("DEFAULT_TENANT_SLUG").unwrap_or_else(|_| "admin".to_string());
Ok(Self { Ok(Self {
database_url, database_url,
@@ -104,6 +121,7 @@ impl AppConfig {
s3_bucket, s3_bucket,
quickwit_endpoint, quickwit_endpoint,
quickwit_index, quickwit_index,
default_tenant_slug,
}) })
} }
@@ -115,8 +133,12 @@ impl AppConfig {
fn redact_database_url(raw: &str) -> String { fn redact_database_url(raw: &str) -> String {
match Url::parse(raw) { match Url::parse(raw) {
Ok(mut parsed) => { Ok(mut parsed) => {
let _ = parsed.set_password(Some("*****")); if parsed.password().is_some() {
parsed.to_string() let _ = parsed.set_password(Some("*****"));
parsed.to_string()
} else {
raw.to_string()
}
} }
Err(_) => "***".to_string(), Err(_) => "***".to_string(),
} }
+14
View File
@@ -12,6 +12,7 @@ pub type AppResult<T> = Result<T, AppError>;
pub struct AppError { pub struct AppError {
status: StatusCode, status: StatusCode,
message: String, message: String,
code: Option<String>,
} }
impl AppError { impl AppError {
@@ -19,6 +20,7 @@ impl AppError {
Self { Self {
status, status,
message: message.into(), message: message.into(),
code: None,
} }
} }
@@ -26,6 +28,10 @@ impl AppError {
Self::new(StatusCode::BAD_REQUEST, message) Self::new(StatusCode::BAD_REQUEST, message)
} }
pub fn conflict(message: impl Into<String>) -> Self {
Self::new(StatusCode::CONFLICT, message)
}
pub fn unauthorized() -> Self { pub fn unauthorized() -> Self {
Self::new(StatusCode::UNAUTHORIZED, "unauthorized") Self::new(StatusCode::UNAUTHORIZED, "unauthorized")
} }
@@ -37,6 +43,11 @@ impl AppError {
pub fn internal<E: Display>(error: E) -> Self { pub fn internal<E: Display>(error: E) -> Self {
Self::new(StatusCode::INTERNAL_SERVER_ERROR, error.to_string()) 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 { impl IntoResponse for AppError {
@@ -44,6 +55,7 @@ impl IntoResponse for AppError {
let status = self.status; let status = self.status;
let body = Json(ErrorResponse { let body = Json(ErrorResponse {
error: self.message, error: self.message,
code: self.code,
}); });
(status, body).into_response() (status, body).into_response()
} }
@@ -52,6 +64,8 @@ impl IntoResponse for AppError {
#[derive(Serialize)] #[derive(Serialize)]
struct ErrorResponse { struct ErrorResponse {
error: String, error: String,
#[serde(skip_serializing_if = "Option::is_none")]
code: Option<String>,
} }
impl From<diesel::result::Error> for AppError { impl From<diesel::result::Error> for AppError {
+2
View File
@@ -30,6 +30,7 @@ pub type JobQueueResult<T> = Result<T, JobQueueError>;
pub fn enqueue_job( pub fn enqueue_job(
conn: &mut PgConnection, conn: &mut PgConnection,
tenant_id: Uuid,
job_type: &str, job_type: &str,
payload: Value, payload: Value,
run_after: Option<NaiveDateTime>, run_after: Option<NaiveDateTime>,
@@ -40,6 +41,7 @@ pub fn enqueue_job(
payload, payload,
status: STATUS_QUEUED.to_string(), status: STATUS_QUEUED.to_string(),
run_after: run_after.unwrap_or_else(|| Utc::now().naive_utc()), run_after: run_after.unwrap_or_else(|| Utc::now().naive_utc()),
tenant_id,
}; };
diesel::insert_into(jobs::table) diesel::insert_into(jobs::table)
+2
View File
@@ -4,11 +4,13 @@ pub mod db;
pub mod error; pub mod error;
pub mod jobs; pub mod jobs;
pub mod models; pub mod models;
pub mod openapi;
pub mod routes; pub mod routes;
pub mod s3; pub mod s3;
pub mod schema; pub mod schema;
pub mod state; pub mod state;
pub mod storage; pub mod storage;
pub mod tenants;
pub mod utils; pub mod utils;
pub mod workers; pub mod workers;
pub use workers::{default_handlers, Worker}; pub use workers::{default_handlers, Worker};
+9 -38
View File
@@ -1,57 +1,28 @@
use std::net::SocketAddr; use std::net::SocketAddr;
use std::sync::Arc;
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tower::make::Shared; use tower::make::Shared;
use tracing_subscriber::EnvFilter;
use backend::auth::jwt::JwtService; use backend::{routes, utils::bootstrap::init_component};
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] #[tokio::main]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
dotenv::dotenv().ok(); let state = init_component("api", None).await?;
init_tracing(); let server_host = state.config.server_host.clone();
let server_port = state.config.server_port;
let config = AppConfig::from_env()?;
tracing::info!( tracing::info!(
component = "api", component = "api",
database_url = %config.redacted_database_url(), server_host = %server_host,
pool_size = config.database_max_pool_size, server_port,
server_host = %config.server_host, "starting api server"
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 state = AppState::new(pool, config, storage, jwt); let router = routes::create_router(state.as_ref().clone());
let router = routes::create_router(state.clone()); let addr: SocketAddr = format!("{}:{}", server_host, server_port).parse()?;
let addr: SocketAddr =
format!("{}:{}", state.config.server_host, state.config.server_port).parse()?;
let listener = TcpListener::bind(addr).await?; let listener = TcpListener::bind(addr).await?;
tracing::info!("listening on {}", addr); tracing::info!("listening on {}", addr);
axum::serve(listener, Shared::new(router)).await?; axum::serve(listener, Shared::new(router)).await?;
Ok(()) 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();
}
+58 -2
View File
@@ -4,13 +4,48 @@ use uuid::Uuid;
use crate::schema::*; 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)] #[derive(Debug, Clone, Queryable, Identifiable)]
#[diesel(table_name = users)] #[diesel(table_name = users)]
pub struct User { pub struct User {
pub id: Uuid, pub id: Uuid,
pub username: String, pub username: String,
pub password_hash: String, pub password_hash: String,
pub role: String,
pub created_at: NaiveDateTime, pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime, pub updated_at: NaiveDateTime,
} }
@@ -21,7 +56,6 @@ pub struct NewUser {
pub id: Uuid, pub id: Uuid,
pub username: String, pub username: String,
pub password_hash: String, pub password_hash: String,
pub role: String,
} }
#[derive(Debug, Clone, Queryable, Identifiable)] #[derive(Debug, Clone, Queryable, Identifiable)]
@@ -32,6 +66,7 @@ pub struct Folder {
pub parent_id: Option<Uuid>, pub parent_id: Option<Uuid>,
pub created_at: NaiveDateTime, pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime, pub updated_at: NaiveDateTime,
pub tenant_id: Uuid,
} }
#[derive(Debug, Insertable)] #[derive(Debug, Insertable)]
@@ -40,6 +75,7 @@ pub struct NewFolder {
pub id: Uuid, pub id: Uuid,
pub name: String, pub name: String,
pub parent_id: Option<Uuid>, pub parent_id: Option<Uuid>,
pub tenant_id: Uuid,
} }
#[derive(Debug, Clone, Queryable, Identifiable, Associations)] #[derive(Debug, Clone, Queryable, Identifiable, Associations)]
@@ -58,6 +94,7 @@ pub struct Document {
pub issued_at: Option<NaiveDateTime>, pub issued_at: Option<NaiveDateTime>,
pub title: String, pub title: String,
pub current_version_id: Uuid, pub current_version_id: Uuid,
pub tenant_id: Uuid,
} }
#[derive(Debug, Insertable)] #[derive(Debug, Insertable)]
@@ -72,6 +109,7 @@ pub struct NewDocument {
pub metadata: serde_json::Value, pub metadata: serde_json::Value,
pub issued_at: Option<NaiveDateTime>, pub issued_at: Option<NaiveDateTime>,
pub title: String, pub title: String,
pub tenant_id: Uuid,
} }
#[derive(Debug, Clone, Queryable, Identifiable, Associations)] #[derive(Debug, Clone, Queryable, Identifiable, Associations)]
@@ -87,6 +125,7 @@ pub struct DocumentVersion {
pub created_at: NaiveDateTime, pub created_at: NaiveDateTime,
pub operations_summary: serde_json::Value, pub operations_summary: serde_json::Value,
pub metadata: serde_json::Value, pub metadata: serde_json::Value,
pub tenant_id: Uuid,
} }
#[derive(Debug, Insertable)] #[derive(Debug, Insertable)]
@@ -100,6 +139,7 @@ pub struct NewDocumentVersion {
pub checksum: String, pub checksum: String,
pub operations_summary: serde_json::Value, pub operations_summary: serde_json::Value,
pub metadata: serde_json::Value, pub metadata: serde_json::Value,
pub tenant_id: Uuid,
} }
#[derive(Debug, Clone, Queryable, Identifiable, Associations)] #[derive(Debug, Clone, Queryable, Identifiable, Associations)]
@@ -113,6 +153,7 @@ pub struct DocumentAsset {
pub metadata: serde_json::Value, pub metadata: serde_json::Value,
pub created_at: NaiveDateTime, pub created_at: NaiveDateTime,
pub cardinality: Option<i32>, pub cardinality: Option<i32>,
pub tenant_id: Uuid,
} }
#[derive(Debug, Insertable)] #[derive(Debug, Insertable)]
@@ -124,6 +165,7 @@ pub struct NewDocumentAsset {
pub mime_type: String, pub mime_type: String,
pub metadata: serde_json::Value, pub metadata: serde_json::Value,
pub cardinality: Option<i32>, pub cardinality: Option<i32>,
pub tenant_id: Uuid,
} }
#[derive(Debug, Clone, Queryable, Identifiable, Associations)] #[derive(Debug, Clone, Queryable, Identifiable, Associations)]
@@ -135,6 +177,7 @@ pub struct DocumentAssetObject {
pub ordinal: i32, pub ordinal: i32,
pub s3_key: String, pub s3_key: String,
pub metadata: serde_json::Value, pub metadata: serde_json::Value,
pub tenant_id: Uuid,
} }
#[derive(Debug, Insertable)] #[derive(Debug, Insertable)]
@@ -145,6 +188,7 @@ pub struct NewDocumentAssetObject {
pub ordinal: i32, pub ordinal: i32,
pub s3_key: String, pub s3_key: String,
pub metadata: serde_json::Value, pub metadata: serde_json::Value,
pub tenant_id: Uuid,
} }
#[derive(Debug, Clone, Queryable, Identifiable)] #[derive(Debug, Clone, Queryable, Identifiable)]
@@ -159,6 +203,7 @@ pub struct Job {
pub last_error: Option<String>, pub last_error: Option<String>,
pub created_at: NaiveDateTime, pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime, pub updated_at: NaiveDateTime,
pub tenant_id: Uuid,
} }
#[derive(Debug, Insertable)] #[derive(Debug, Insertable)]
@@ -169,6 +214,7 @@ pub struct NewJob {
pub payload: serde_json::Value, pub payload: serde_json::Value,
pub status: String, pub status: String,
pub run_after: NaiveDateTime, pub run_after: NaiveDateTime,
pub tenant_id: Uuid,
} }
#[derive(Debug, Clone, Queryable, Identifiable)] #[derive(Debug, Clone, Queryable, Identifiable)]
@@ -178,6 +224,7 @@ pub struct Tag {
pub label: String, pub label: String,
pub color: Option<String>, pub color: Option<String>,
pub created_at: NaiveDateTime, pub created_at: NaiveDateTime,
pub tenant_id: Uuid,
} }
#[derive(Debug, Insertable)] #[derive(Debug, Insertable)]
@@ -186,6 +233,7 @@ pub struct NewTag {
pub id: Uuid, pub id: Uuid,
pub label: String, pub label: String,
pub color: Option<String>, pub color: Option<String>,
pub tenant_id: Uuid,
} }
#[allow(dead_code)] #[allow(dead_code)]
@@ -199,6 +247,7 @@ pub struct DocumentTag {
pub tag_id: Uuid, pub tag_id: Uuid,
pub assigned_at: NaiveDateTime, pub assigned_at: NaiveDateTime,
pub assigned_by: Option<Uuid>, pub assigned_by: Option<Uuid>,
pub tenant_id: Uuid,
} }
#[derive(Debug, Insertable)] #[derive(Debug, Insertable)]
@@ -207,6 +256,7 @@ pub struct NewDocumentTag {
pub document_id: Uuid, pub document_id: Uuid,
pub tag_id: Uuid, pub tag_id: Uuid,
pub assigned_by: Option<Uuid>, pub assigned_by: Option<Uuid>,
pub tenant_id: Uuid,
} }
#[derive(Debug, Clone, Queryable, Identifiable)] #[derive(Debug, Clone, Queryable, Identifiable)]
@@ -217,6 +267,7 @@ pub struct Correspondent {
pub metadata: serde_json::Value, pub metadata: serde_json::Value,
pub created_at: NaiveDateTime, pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime, pub updated_at: NaiveDateTime,
pub tenant_id: Uuid,
} }
#[derive(Debug, Insertable)] #[derive(Debug, Insertable)]
@@ -225,6 +276,7 @@ pub struct NewCorrespondent {
pub id: Uuid, pub id: Uuid,
pub name: String, pub name: String,
pub metadata: serde_json::Value, pub metadata: serde_json::Value,
pub tenant_id: Uuid,
} }
#[derive(Debug, Clone, Queryable, Associations)] #[derive(Debug, Clone, Queryable, Associations)]
@@ -238,6 +290,7 @@ pub struct DocumentCorrespondent {
pub role: String, pub role: String,
pub assigned_at: NaiveDateTime, pub assigned_at: NaiveDateTime,
pub assigned_by: Option<Uuid>, pub assigned_by: Option<Uuid>,
pub tenant_id: Uuid,
} }
#[derive(Debug, Insertable)] #[derive(Debug, Insertable)]
@@ -247,6 +300,7 @@ pub struct NewDocumentCorrespondent {
pub correspondent_id: Uuid, pub correspondent_id: Uuid,
pub role: String, pub role: String,
pub assigned_by: Option<Uuid>, pub assigned_by: Option<Uuid>,
pub tenant_id: Uuid,
} }
#[derive(Debug, Clone, Queryable, Identifiable, Associations)] #[derive(Debug, Clone, Queryable, Identifiable, Associations)]
@@ -261,6 +315,7 @@ pub struct RefreshToken {
pub revoked_at: Option<NaiveDateTime>, pub revoked_at: Option<NaiveDateTime>,
pub created_at: NaiveDateTime, pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime, pub updated_at: NaiveDateTime,
pub tenant_id: Uuid,
} }
#[derive(Debug, Insertable)] #[derive(Debug, Insertable)]
@@ -271,4 +326,5 @@ pub struct NewRefreshToken {
pub token_hash: String, pub token_hash: String,
pub issued_at: NaiveDateTime, pub issued_at: NaiveDateTime,
pub expires_at: NaiveDateTime, pub expires_at: NaiveDateTime,
pub tenant_id: Uuid,
} }
+951
View File
@@ -0,0 +1,951 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use utoipa::{IntoParams, OpenApi, ToSchema};
use uuid::Uuid;
#[derive(OpenApi)]
#[openapi(
paths(
doc::health_check,
doc::login,
doc::refresh,
doc::logout,
doc::me,
doc::select_tenant,
doc::list_documents,
doc::check_document,
doc::upload_document,
doc::get_document,
doc::update_document,
doc::delete_document,
doc::download_document,
doc::download_with_token,
doc::move_document,
doc::assign_tags,
doc::remove_tag,
doc::bulk_move_documents,
doc::bulk_update_tags,
doc::bulk_assign_correspondents,
doc::assign_correspondents,
doc::remove_correspondent,
doc::reanalyze_selected_documents,
doc::list_document_assets,
doc::request_document_assets,
doc::get_document_asset,
doc::create_folder,
doc::ensure_folder_path,
doc::get_folder,
doc::list_folder_contents,
doc::delete_folder,
doc::update_folder,
doc::list_tags,
doc::create_tag,
doc::update_tag,
doc::delete_tag,
doc::list_correspondents,
doc::create_correspondent,
doc::update_correspondent,
doc::delete_correspondent,
),
components(
schemas(
schemas::LoginRequest,
schemas::AccessTokenResponse,
schemas::TenantSummary,
schemas::TenantSelectionResponse,
schemas::TenantSelectionRequest,
schemas::LoginResponseVariants,
schemas::DocumentResponse,
schemas::DocumentDetailResponse,
schemas::DocumentVersion,
schemas::DocumentAssetSummary,
schemas::DocumentAssetDetail,
schemas::DocumentAssetObject,
schemas::DocumentCorrespondent,
schemas::DocumentTag,
schemas::DocumentDownloadResponse,
schemas::UpdateDocumentRequest,
schemas::BulkMoveDocumentsRequest,
schemas::BulkMoveDocumentsResponse,
schemas::AssignTagsRequest,
schemas::MoveDocumentRequest,
schemas::BulkTagRequest,
schemas::BulkTagResponse,
schemas::CorrespondentAssignment,
schemas::BulkTagAction,
schemas::BulkCorrespondentsRequest,
schemas::BulkCorrespondentsResponse,
schemas::BulkCorrespondentAction,
schemas::AssignCorrespondentsRequest,
schemas::RemoveCorrespondentParams,
schemas::ReanalyzeRequest,
schemas::ReanalyzeResponse,
schemas::DocumentAssetRequestParams,
schemas::AssetObjectsQuery,
schemas::DocumentCheckQuery,
schemas::DocumentCheckResponse,
schemas::UploadDocumentForm,
schemas::CreateFolderRequest,
schemas::EnsureFolderPathRequest,
schemas::FolderResponse,
schemas::FolderInfo,
schemas::FolderContentsResponse,
schemas::FolderDocumentSummary,
schemas::UpdateFolderRequest,
schemas::FolderContentsParams,
schemas::TagCatalogEntry,
schemas::CreateTagRequest,
schemas::UpdateTagRequest,
schemas::CorrespondentCatalogEntry,
schemas::CreateCorrespondentRequest,
schemas::UpdateCorrespondentRequest,
)
),
tags(
(name = "Health", description = "Service health"),
(name = "Auth", description = "Authentication"),
(name = "Documents", description = "Document management"),
(name = "Assets", description = "Document assets"),
(name = "Folders", description = "Folder management"),
(name = "Tags", description = "Tag catalog"),
(name = "Correspondents", description = "Correspondent catalog")
)
)]
pub struct ApiDoc;
#[allow(dead_code)]
mod doc {
use super::schemas::*;
use uuid::Uuid;
#[allow(dead_code)]
fn __keep_uuid_import() {
let _ = Uuid::nil();
}
#[utoipa::path(
get,
path = "/api/health",
responses((status = 200, description = "Service is healthy")),
tag = "Health"
)]
pub(super) fn health_check() {}
#[utoipa::path(
post,
path = "/api/auth/login",
request_body = LoginRequest,
responses(
(status = 200, description = "Login succeeded", body = LoginResponseVariants),
(status = 401, description = "Invalid credentials")
),
tag = "Auth"
)]
pub(super) fn login() {}
#[utoipa::path(
post,
path = "/api/auth/refresh",
responses(
(status = 200, description = "Refreshed access token", body = AccessTokenResponse),
(status = 401, description = "Missing or invalid refresh token")
),
tag = "Auth"
)]
pub(super) fn refresh() {}
#[utoipa::path(
post,
path = "/api/auth/logout",
responses((status = 204, description = "Session revoked")),
tag = "Auth"
)]
pub(super) fn logout() {}
#[utoipa::path(
get,
path = "/api/auth/me",
responses((status = 200, description = "Authenticated principal", body = AccessTokenResponse)),
tag = "Auth"
)]
pub(super) fn me() {}
#[utoipa::path(
post,
path = "/api/auth/select-tenant",
request_body = TenantSelectionRequest,
responses((status = 200, description = "Tenant selected", body = AccessTokenResponse)),
tag = "Auth"
)]
pub(super) fn select_tenant() {}
#[utoipa::path(
get,
path = "/api/documents",
params(DocumentListQuery),
responses((status = 200, description = "List documents", body = [DocumentResponse])),
tag = "Documents"
)]
pub(super) fn list_documents() {}
#[utoipa::path(
post,
path = "/api/documents",
request_body = UploadDocumentForm,
responses(
(status = 201, description = "Document created", body = DocumentDetailResponse),
(status = 200, description = "Existing document reused", body = DocumentDetailResponse),
(status = 204, description = "Upload skipped because the document already exists")
),
tag = "Documents"
)]
pub(super) fn upload_document() {}
#[utoipa::path(
get,
path = "/api/documents/check",
params(DocumentCheckQuery),
responses((status = 200, description = "Checksum lookup", body = DocumentCheckResponse)),
tag = "Documents"
)]
pub(super) fn check_document() {}
#[utoipa::path(
get,
path = "/api/documents/{id}",
params(("id" = Uuid, Path, description = "Document ID")),
responses((status = 200, description = "Document detail", body = DocumentDetailResponse)),
tag = "Documents"
)]
pub(super) fn get_document() {}
#[utoipa::path(
patch,
path = "/api/documents/{id}",
params(("id" = Uuid, Path, description = "Document ID")),
request_body = UpdateDocumentRequest,
responses((status = 200, description = "Updated document", body = DocumentDetailResponse)),
tag = "Documents"
)]
pub(super) fn update_document() {}
#[utoipa::path(
delete,
path = "/api/documents/{id}",
params(("id" = Uuid, Path, description = "Document ID")),
responses((status = 204, description = "Document deleted")),
tag = "Documents"
)]
pub(super) fn delete_document() {}
#[utoipa::path(
get,
path = "/api/documents/{id}/download",
params(("id" = Uuid, Path, description = "Document ID")),
responses((status = 200, description = "Download metadata", body = DocumentDownloadResponse)),
tag = "Documents"
)]
pub(super) fn download_document() {}
#[utoipa::path(
get,
path = "/download/{token}",
params(("token" = String, Path, description = "Download token")),
responses((status = 302, description = "Redirect to pre-signed URL")),
tag = "Documents"
)]
pub(super) fn download_with_token() {}
#[utoipa::path(
patch,
path = "/api/documents/{id}/folder",
params(("id" = Uuid, Path, description = "Document ID")),
request_body = MoveDocumentRequest,
responses((status = 204, description = "Document moved")),
tag = "Documents"
)]
pub(super) fn move_document() {}
#[utoipa::path(
post,
path = "/api/documents/{id}/tags",
params(("id" = Uuid, Path, description = "Document ID")),
request_body = AssignTagsRequest,
responses((status = 204, description = "Tags assigned")),
tag = "Documents"
)]
pub(super) fn assign_tags() {}
#[utoipa::path(
delete,
path = "/api/documents/{id}/tags/{tag_id}",
params(
("id" = Uuid, Path, description = "Document ID"),
("tag_id" = Uuid, Path, description = "Tag ID")
),
responses((status = 204, description = "Tag removed")),
tag = "Documents"
)]
pub(super) fn remove_tag() {}
#[utoipa::path(
post,
path = "/api/documents/bulk/move",
request_body = BulkMoveDocumentsRequest,
responses((status = 200, description = "Bulk move outcome", body = BulkMoveDocumentsResponse)),
tag = "Documents"
)]
pub(super) fn bulk_move_documents() {}
#[utoipa::path(
post,
path = "/api/documents/bulk/tags",
request_body = BulkTagRequest,
responses((status = 200, description = "Bulk tag outcome", body = BulkTagResponse)),
tag = "Documents"
)]
pub(super) fn bulk_update_tags() {}
#[utoipa::path(
post,
path = "/api/documents/bulk/correspondents",
request_body = BulkCorrespondentsRequest,
responses((status = 200, description = "Bulk correspondents outcome", body = BulkCorrespondentsResponse)),
tag = "Documents"
)]
pub(super) fn bulk_assign_correspondents() {}
#[utoipa::path(
post,
path = "/api/documents/{id}/correspondents",
params(("id" = Uuid, Path, description = "Document ID")),
request_body = AssignCorrespondentsRequest,
responses((status = 204, description = "Correspondents assigned")),
tag = "Documents"
)]
pub(super) fn assign_correspondents() {}
#[utoipa::path(
delete,
path = "/api/documents/{id}/correspondents/{correspondent_id}",
params(
("id" = Uuid, Path, description = "Document ID"),
("correspondent_id" = Uuid, Path, description = "Correspondent ID"),
RemoveCorrespondentParams
),
responses((status = 204, description = "Correspondent removed")),
tag = "Documents"
)]
pub(super) fn remove_correspondent() {}
#[utoipa::path(
post,
path = "/api/documents/bulk/reanalyze",
request_body = ReanalyzeRequest,
responses((status = 200, description = "Reanalyze queued", body = ReanalyzeResponse)),
tag = "Documents"
)]
pub(super) fn reanalyze_selected_documents() {}
#[utoipa::path(
get,
path = "/api/documents/{id}/assets",
params(("id" = Uuid, Path, description = "Document ID")),
responses((status = 200, description = "Document assets", body = [DocumentAssetSummary])),
tag = "Assets"
)]
pub(super) fn list_document_assets() {}
#[utoipa::path(
post,
path = "/api/documents/{id}/assets",
params(
("id" = Uuid, Path, description = "Document ID"),
DocumentAssetRequestParams
),
responses((status = 202, description = "Asset generation requested")),
tag = "Assets"
)]
pub(super) fn request_document_assets() {}
#[utoipa::path(
get,
path = "/api/assets/{asset_id}",
params(
("asset_id" = Uuid, Path, description = "Asset ID"),
AssetObjectsQuery
),
responses((status = 200, description = "Asset detail", body = DocumentAssetDetail)),
tag = "Assets"
)]
pub(super) fn get_document_asset() {}
#[utoipa::path(
post,
path = "/api/folders",
request_body = CreateFolderRequest,
responses((status = 200, description = "Folder created", body = FolderResponse)),
tag = "Folders"
)]
pub(super) fn create_folder() {}
#[utoipa::path(
post,
path = "/api/folders/path",
request_body = EnsureFolderPathRequest,
responses((status = 200, description = "Folder path ensured", body = FolderResponse)),
tag = "Folders"
)]
pub(super) fn ensure_folder_path() {}
#[utoipa::path(
get,
path = "/api/folders/{id}",
params(("id" = Uuid, Path, description = "Folder ID")),
responses((status = 200, description = "Folder detail", body = FolderResponse)),
tag = "Folders"
)]
pub(super) fn get_folder() {}
#[utoipa::path(
get,
path = "/api/folders/{id}/contents",
params(
("id" = Uuid, Path, description = "Folder ID"),
FolderContentsParams
),
responses((status = 200, description = "Folder contents", body = FolderContentsResponse)),
tag = "Folders"
)]
pub(super) fn list_folder_contents() {}
#[utoipa::path(
delete,
path = "/api/folders/{id}",
params(("id" = Uuid, Path, description = "Folder ID")),
responses((status = 204, description = "Folder deleted")),
tag = "Folders"
)]
pub(super) fn delete_folder() {}
#[utoipa::path(
patch,
path = "/api/folders/{id}",
params(("id" = Uuid, Path, description = "Folder ID")),
request_body = UpdateFolderRequest,
responses((status = 204, description = "Folder updated")),
tag = "Folders"
)]
pub(super) fn update_folder() {}
#[utoipa::path(
get,
path = "/api/tags",
responses((status = 200, description = "Tags", body = [TagCatalogEntry])),
tag = "Tags"
)]
pub(super) fn list_tags() {}
#[utoipa::path(
post,
path = "/api/tags",
request_body = CreateTagRequest,
responses((status = 200, description = "Tag created", body = TagCatalogEntry)),
tag = "Tags"
)]
pub(super) fn create_tag() {}
#[utoipa::path(
patch,
path = "/api/tags/{id}",
params(("id" = Uuid, Path, description = "Tag ID")),
request_body = UpdateTagRequest,
responses((status = 200, description = "Tag updated", body = TagCatalogEntry)),
tag = "Tags"
)]
pub(super) fn update_tag() {}
#[utoipa::path(
delete,
path = "/api/tags/{id}",
params(("id" = Uuid, Path, description = "Tag ID")),
responses((status = 204, description = "Tag deleted")),
tag = "Tags"
)]
pub(super) fn delete_tag() {}
#[utoipa::path(
get,
path = "/api/correspondents",
responses((status = 200, description = "Correspondents", body = [CorrespondentCatalogEntry])),
tag = "Correspondents"
)]
pub(super) fn list_correspondents() {}
#[utoipa::path(
post,
path = "/api/correspondents",
request_body = CreateCorrespondentRequest,
responses((status = 200, description = "Correspondent created", body = CorrespondentCatalogEntry)),
tag = "Correspondents"
)]
pub(super) fn create_correspondent() {}
#[utoipa::path(
patch,
path = "/api/correspondents/{id}",
params(("id" = Uuid, Path, description = "Correspondent ID")),
request_body = UpdateCorrespondentRequest,
responses((status = 200, description = "Correspondent updated", body = CorrespondentCatalogEntry)),
tag = "Correspondents"
)]
pub(super) fn update_correspondent() {}
#[utoipa::path(
delete,
path = "/api/correspondents/{id}",
params(("id" = Uuid, Path, description = "Correspondent ID")),
responses((status = 204, description = "Correspondent deleted")),
tag = "Correspondents"
)]
pub(super) fn delete_correspondent() {}
}
#[cfg(test)]
mod tests {
use super::ApiDoc;
use utoipa::OpenApi;
#[test]
fn openapi_serializes() {
let spec = ApiDoc::openapi();
let _ = serde_json::to_string(&spec).expect("serialize openapi");
}
}
pub mod schemas {
use super::*;
#[derive(Serialize, Deserialize, ToSchema)]
pub struct LoginRequest {
pub username: String,
pub password: String,
#[schema(nullable)]
pub preferred_tenant_slug: Option<String>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct AccessTokenResponse {
pub access_token: String,
pub token_type: String,
pub expires_in: i64,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct TenantSummary {
pub tenant_id: Uuid,
pub slug: String,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct TenantSelectionResponse {
pub selection_token: String,
pub tenants: Vec<TenantSummary>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct TenantSelectionRequest {
pub tenant_id: Uuid,
}
#[derive(Serialize, Deserialize, ToSchema)]
#[serde(untagged)]
pub enum LoginResponseVariants {
Token(AccessTokenResponse),
Selection(TenantSelectionResponse),
}
#[derive(Serialize, Deserialize, IntoParams, ToSchema)]
#[into_params(parameter_in = Query)]
pub struct DocumentListQuery {
pub folder_id: Option<Uuid>,
pub include_deleted: Option<bool>,
pub include_descendants: Option<bool>,
pub query: Option<String>,
pub tags: Option<String>,
pub correspondents: Option<String>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct DocumentTag {
pub id: Uuid,
pub label: String,
#[schema(nullable)]
pub color: Option<String>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct DocumentAssetObject {
pub id: Uuid,
pub ordinal: i32,
pub metadata: Value,
#[schema(nullable)]
pub url: Option<String>,
#[schema(nullable)]
pub expires_at: Option<i64>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct DocumentAssetSummary {
pub id: Uuid,
pub asset_type: String,
pub mime_type: String,
pub metadata: Value,
#[schema(nullable)]
pub cardinality: Option<i32>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct DocumentAssetDetail {
pub id: Uuid,
pub asset_type: String,
pub mime_type: String,
pub metadata: Value,
pub created_at: String,
#[schema(nullable)]
pub cardinality: Option<i32>,
pub objects: Vec<DocumentAssetObject>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct DocumentVersion {
pub id: Uuid,
pub version_number: i32,
pub checksum: String,
pub size_bytes: i64,
pub created_at: String,
pub metadata: Value,
#[schema(nullable)]
pub operations_summary: Option<Value>,
#[schema(nullable)]
pub assets: Option<Vec<DocumentAssetSummary>>,
pub download_path: String,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct DocumentCorrespondent {
pub id: Uuid,
pub name: String,
pub role: String,
pub metadata: Value,
pub assigned_at: String,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct DocumentResponse {
pub id: Uuid,
pub filename: String,
pub title: String,
pub original_name: String,
#[schema(nullable)]
pub content_type: Option<String>,
#[schema(nullable)]
pub folder_id: Option<Uuid>,
pub uploaded_at: String,
pub updated_at: String,
#[schema(nullable)]
pub deleted_at: Option<String>,
#[schema(nullable)]
pub issued_at: Option<String>,
pub metadata: Value,
pub tags: Vec<DocumentTag>,
#[schema(nullable)]
pub correspondents: Option<Vec<DocumentCorrespondent>>,
#[schema(nullable)]
pub current_version: Option<DocumentVersion>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct DocumentDetailResponse {
pub document: DocumentResponse,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct DocumentDownloadResponse {
pub url: String,
pub expires_in: u64,
pub filename: String,
#[schema(nullable)]
pub content_type: Option<String>,
pub size_bytes: i64,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct UpdateDocumentRequest {
#[schema(nullable)]
pub title: Option<String>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct BulkMoveDocumentsRequest {
pub document_ids: Vec<Uuid>,
#[schema(nullable)]
pub folder_id: Option<Uuid>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct BulkMoveDocumentsResponse {
pub updated: usize,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct AssignTagsRequest {
pub tag_ids: Vec<Uuid>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct MoveDocumentRequest {
#[schema(nullable)]
pub folder_id: Option<Uuid>,
}
#[derive(Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum BulkTagAction {
Add,
Remove,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct BulkTagRequest {
pub document_ids: Vec<Uuid>,
pub tag_ids: Vec<Uuid>,
pub action: BulkTagAction,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct BulkTagResponse {
pub added: usize,
pub removed: usize,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct CorrespondentAssignment {
pub correspondent_id: Uuid,
pub role: String,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct AssignCorrespondentsRequest {
pub assignments: Vec<CorrespondentAssignment>,
#[serde(default)]
pub replace: bool,
}
#[derive(Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum BulkCorrespondentAction {
Add,
Remove,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct BulkCorrespondentsRequest {
pub document_ids: Vec<Uuid>,
pub assignments: Vec<CorrespondentAssignment>,
#[serde(default = "default_bulk_correspondent_action")]
pub action: BulkCorrespondentAction,
}
fn default_bulk_correspondent_action() -> BulkCorrespondentAction {
BulkCorrespondentAction::Add
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct BulkCorrespondentsResponse {
pub assigned: usize,
pub removed: usize,
}
#[derive(Serialize, Deserialize, IntoParams, ToSchema)]
#[into_params(parameter_in = Query)]
pub struct RemoveCorrespondentParams {
pub role: String,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct ReanalyzeRequest {
pub document_ids: Vec<Uuid>,
#[serde(default)]
pub force: bool,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct ReanalyzeResponse {
pub queued: usize,
}
#[derive(Serialize, Deserialize, IntoParams, ToSchema)]
#[into_params(parameter_in = Query)]
pub struct DocumentAssetRequestParams {
#[serde(default)]
pub force: Option<bool>,
}
#[derive(Serialize, Deserialize, IntoParams, ToSchema)]
#[into_params(parameter_in = Query)]
pub struct AssetObjectsQuery {
#[serde(default)]
pub start: Option<i32>,
#[serde(default)]
pub limit: Option<i32>,
}
#[derive(Serialize, Deserialize, IntoParams, ToSchema)]
#[into_params(parameter_in = Query)]
pub struct DocumentCheckQuery {
pub checksum: String,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct DocumentCheckResponse {
pub exists: bool,
#[schema(nullable)]
pub document_id: Option<Uuid>,
#[schema(nullable)]
pub title: Option<String>,
#[schema(nullable)]
pub filename: Option<String>,
#[schema(nullable)]
pub version_id: Option<Uuid>,
#[schema(nullable)]
pub version_number: Option<i32>,
#[schema(nullable)]
pub uploaded_at: Option<String>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct UploadDocumentForm {
#[schema(value_type = String, format = Binary)]
pub file: String,
#[schema(nullable)]
pub folder_id: Option<Uuid>,
#[schema(nullable)]
pub metadata: Option<Value>,
#[schema(nullable)]
pub title: Option<String>,
#[schema(nullable, value_type = Vec<Uuid>)]
pub tag_ids: Option<Vec<Uuid>>,
#[schema(nullable, value_type = Vec<CorrespondentAssignment>)]
pub correspondents: Option<Vec<CorrespondentAssignment>>,
#[schema(nullable, example = "2024-01-01T00:00:00Z")]
pub issued_at: Option<String>,
#[schema(nullable)]
pub skip_existing: Option<bool>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct CreateFolderRequest {
pub name: String,
#[schema(nullable)]
pub parent_id: Option<Uuid>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct EnsureFolderPathRequest {
#[schema(nullable)]
pub parent_id: Option<Uuid>,
pub segments: Vec<String>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct FolderInfo {
pub id: Uuid,
pub name: String,
#[schema(nullable)]
pub parent_id: Option<Uuid>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct FolderResponse {
pub folder: FolderInfo,
}
#[derive(Serialize, Deserialize, IntoParams, ToSchema)]
#[into_params(parameter_in = Query)]
pub struct FolderContentsParams {
#[serde(default)]
pub include_documents: Option<bool>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct FolderDocumentSummary {
pub id: Uuid,
pub title: String,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct FolderContentsResponse {
#[schema(nullable)]
pub folder: Option<FolderInfo>,
pub subfolders: Vec<FolderInfo>,
pub documents: Vec<FolderDocumentSummary>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct UpdateFolderRequest {
#[schema(nullable)]
pub parent_id: Option<Option<Uuid>>,
#[schema(nullable)]
pub name: Option<String>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct TagCatalogEntry {
pub id: Uuid,
pub label: String,
#[schema(nullable)]
pub color: Option<String>,
pub usage_count: i64,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct CreateTagRequest {
pub label: String,
#[schema(nullable)]
pub color: Option<String>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct UpdateTagRequest {
#[schema(nullable)]
pub label: Option<Option<String>>,
#[schema(nullable)]
pub color: Option<Option<String>>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct CorrespondentCatalogEntry {
pub id: Uuid,
pub name: String,
pub metadata: Value,
pub role_counts: Value,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct CreateCorrespondentRequest {
pub name: String,
#[schema(default, value_type = Object)]
pub metadata: Value,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct UpdateCorrespondentRequest {
#[schema(nullable)]
pub name: Option<String>,
#[schema(nullable)]
pub metadata: Option<Value>,
}
}
+152 -72
View File
@@ -1,11 +1,15 @@
use axum::{ use axum::{
extract::State, extract::State,
http::{header::SET_COOKIE, HeaderMap, HeaderValue, StatusCode}, http::{header::SET_COOKIE, HeaderMap, HeaderValue, StatusCode},
response::{IntoResponse, Response},
Json, Json,
}; };
use axum_extra::{headers::Cookie, typed_header::TypedHeader}; use axum_extra::{
headers::{authorization::Bearer, Authorization, Cookie},
typed_header::TypedHeader,
};
use chrono::{Duration as ChronoDuration, Utc}; use chrono::{Duration as ChronoDuration, Utc};
use diesel::prelude::*; use diesel::{pg::PgConnection, prelude::*};
use rand::{rngs::OsRng, RngCore}; use rand::{rngs::OsRng, RngCore};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
@@ -14,8 +18,11 @@ use uuid::Uuid;
use crate::{ use crate::{
auth::{password, AuthenticatedUser}, auth::{password, AuthenticatedUser},
error::{AppError, AppResult}, error::{AppError, AppResult},
models::{NewRefreshToken, RefreshToken, User}, models::{NewRefreshToken, RefreshToken, Tenant, User, UserMembership},
schema::{refresh_tokens, users::dsl}, schema::{
refresh_tokens, tenants::dsl as tenant_dsl, user_memberships::dsl as memberships_dsl,
users::dsl,
},
state::AppState, state::AppState,
}; };
@@ -27,6 +34,8 @@ const REFRESH_COOKIE_NAME: &str = "refresh_token";
pub struct LoginRequest { pub struct LoginRequest {
pub username: String, pub username: String,
pub password: String, pub password: String,
#[serde(default)]
pub preferred_tenant_slug: Option<String>,
} }
#[derive(Serialize)] #[derive(Serialize)]
@@ -36,11 +45,28 @@ pub struct LoginResponse {
pub expires_in: i64, pub expires_in: i64,
} }
#[derive(Serialize)]
pub struct TenantSummary {
pub tenant_id: Uuid,
pub slug: String,
}
#[derive(Serialize)]
pub struct TenantSelectionResponse {
pub selection_token: String,
pub tenants: Vec<TenantSummary>,
}
#[derive(Deserialize)]
pub struct TenantSelectionRequest {
pub tenant_id: Uuid,
}
pub async fn login( pub async fn login(
State(state): State<AppState>, State(state): State<AppState>,
Json(payload): Json<LoginRequest>, Json(payload): Json<LoginRequest>,
) -> AppResult<(HeaderMap, Json<LoginResponse>)> { ) -> AppResult<Response> {
let mut conn = state.db()?; let mut conn = state.db_unscoped()?;
let user: User = dsl::users let user: User = dsl::users
.filter(dsl::username.eq(&payload.username)) .filter(dsl::username.eq(&payload.username))
@@ -53,55 +79,67 @@ pub async fn login(
return Err(AppError::unauthorized()); return Err(AppError::unauthorized());
} }
let access_token = state 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
.jwt .jwt
.generate_token(user.id, &user.username, &user.role) .generate_tenant_selector_token(user.id)
.map_err(AppError::from)?; .map_err(AppError::from)?;
let now = Utc::now(); let tenants = memberships
let refresh_value = generate_refresh_token(); .into_iter()
let refresh_hash = hash_refresh_token(&refresh_value); .map(|(_, tenant)| TenantSummary {
let refresh_expires_at = now + ChronoDuration::days(state.config.refresh_token_expiry_days); tenant_id: tenant.id,
slug: tenant.slug,
})
.collect();
let new_refresh = NewRefreshToken { let response = Json(TenantSelectionResponse {
id: Uuid::new_v4(), selection_token,
user_id: user.id, tenants,
token_hash: refresh_hash, })
issued_at: now.naive_utc(), .into_response();
expires_at: refresh_expires_at.naive_utc(),
};
diesel::insert_into(refresh_tokens::table) Ok(response)
.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( pub async fn refresh(
State(state): State<AppState>, State(state): State<AppState>,
jar: Option<TypedHeader<Cookie>>, jar: Option<TypedHeader<Cookie>>,
) -> AppResult<(HeaderMap, Json<LoginResponse>)> { ) -> AppResult<Response> {
let cookies = jar.ok_or_else(AppError::unauthorized)?; let cookies = jar.ok_or_else(AppError::unauthorized)?;
let refresh_value = cookies let refresh_value = cookies
.get(REFRESH_COOKIE_NAME) .get(REFRESH_COOKIE_NAME)
.ok_or_else(AppError::unauthorized)?; .ok_or_else(AppError::unauthorized)?;
let hashed = hash_refresh_token(refresh_value); let hashed = hash_refresh_token(refresh_value);
let mut conn = state.db()?; let mut conn = state.db_unscoped()?;
let now = Utc::now(); let now = Utc::now();
let now_naive = now.naive_utc(); let now_naive = now.naive_utc();
@@ -128,41 +166,39 @@ pub async fn refresh(
.first(&mut conn) .first(&mut conn)
.map_err(AppError::from)?; .map_err(AppError::from)?;
let access_token = state 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 claims = state
.jwt .jwt
.generate_token(user.id, &user.username, &user.role) .verify_tenant_selector_token(bearer.token())
.map_err(|_| AppError::unauthorized())?;
let mut conn = state.db_unscoped()?;
let membership_exists = memberships_dsl::user_memberships
.filter(memberships_dsl::user_id.eq(claims.sub))
.filter(memberships_dsl::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(claims.sub)
.first(&mut conn)
.map_err(AppError::from)?; .map_err(AppError::from)?;
let new_refresh_value = generate_refresh_token(); issue_session(&state, &mut conn, &user, payload.tenant_id)
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( pub async fn logout(
@@ -170,7 +206,7 @@ pub async fn logout(
user: AuthenticatedUser, user: AuthenticatedUser,
jar: Option<TypedHeader<Cookie>>, jar: Option<TypedHeader<Cookie>>,
) -> AppResult<(HeaderMap, StatusCode)> { ) -> AppResult<(HeaderMap, StatusCode)> {
let mut conn = state.db()?; let mut conn = state.db_unscoped()?;
let now = Utc::now().naive_utc(); let now = Utc::now().naive_utc();
let mut rows_affected = 0; let mut rows_affected = 0;
@@ -214,6 +250,50 @@ pub async fn me(user: AuthenticatedUser) -> Json<AuthenticatedUser> {
Json(user) Json(user)
} }
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 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,
})
.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 { fn hash_refresh_token(token: &str) -> String {
let mut hasher = Sha256::new(); let mut hasher = Sha256::new();
hasher.update(token.as_bytes()); hasher.update(token.as_bytes());
+66 -35
View File
@@ -1,11 +1,6 @@
use std::collections::{BTreeMap, HashMap}; use std::collections::{BTreeMap, HashMap};
use axum::{ use axum::{extract::Path, http::StatusCode, Json};
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
Json,
};
use chrono::Utc; use chrono::Utc;
use diesel::{dsl::count_star, prelude::*, result::DatabaseErrorKind, PgConnection}; use diesel::{dsl::count_star, prelude::*, result::DatabaseErrorKind, PgConnection};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -13,14 +8,16 @@ use serde_json::Value;
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
auth::TenantScopedConn,
error::{AppError, AppResult}, error::{AppError, AppResult},
models::{Correspondent, NewCorrespondent}, models::{Correspondent, NewCorrespondent},
schema::{correspondents, document_correspondents}, schema::{correspondents, document_correspondents},
state::AppState, utils::{
db::{no_content, EnsureEntity, IntoJsonResponse},
time::to_iso,
},
}; };
use super::documents::to_iso;
#[derive(Serialize)] #[derive(Serialize)]
pub struct CorrespondentUsage { pub struct CorrespondentUsage {
pub total: i64, pub total: i64,
@@ -59,15 +56,19 @@ struct CorrespondentChangeset<'a> {
} }
pub async fn list_correspondents( pub async fn list_correspondents(
State(state): State<AppState>, TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<Json<Vec<CorrespondentSummary>>> { ) -> AppResult<Json<Vec<CorrespondentSummary>>> {
let mut conn = state.db()?;
let correspondents_list: Vec<Correspondent> = correspondents::table let correspondents_list: Vec<Correspondent> = correspondents::table
.filter(correspondents::tenant_id.eq(tenant_id))
.order(correspondents::name.asc()) .order(correspondents::name.asc())
.load(&mut conn)?; .load(&mut conn)?;
let usage_rows: Vec<(Uuid, String, i64)> = document_correspondents::table let usage_rows: Vec<(Uuid, String, i64)> = document_correspondents::table
.filter(document_correspondents::tenant_id.eq(tenant_id))
.group_by(( .group_by((
document_correspondents::correspondent_id, document_correspondents::correspondent_id,
document_correspondents::role, document_correspondents::role,
@@ -93,11 +94,15 @@ pub async fn list_correspondents(
response.push(build_summary(correspondent, role_counts)); response.push(build_summary(correspondent, role_counts));
} }
Ok(Json(response)) response.into_json()
} }
pub async fn create_correspondent( pub async fn create_correspondent(
State(state): State<AppState>, TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
Json(payload): Json<CreateCorrespondentRequest>, Json(payload): Json<CreateCorrespondentRequest>,
) -> AppResult<Json<CorrespondentSummary>> { ) -> AppResult<Json<CorrespondentSummary>> {
let name = payload.name.trim(); let name = payload.name.trim();
@@ -111,9 +116,9 @@ pub async fn create_correspondent(
id: new_id, id: new_id,
name: name.to_string(), name: name.to_string(),
metadata: metadata_value, metadata: metadata_value,
tenant_id,
}; };
let mut conn = state.db()?;
match diesel::insert_into(correspondents::table) match diesel::insert_into(correspondents::table)
.values(&new_correspondent) .values(&new_correspondent)
.execute(&mut conn) .execute(&mut conn)
@@ -125,19 +130,29 @@ pub async fn create_correspondent(
Err(err) => return Err(AppError::from(err)), Err(err) => return Err(AppError::from(err)),
} }
let correspondent: Correspondent = correspondents::table.find(new_id).first(&mut conn)?; let correspondent: Correspondent = correspondents::table
Ok(Json(build_summary(correspondent, BTreeMap::new()))) .find(new_id)
.filter(correspondents::tenant_id.eq(tenant_id))
.first(&mut conn)
.one()?;
build_summary(correspondent, BTreeMap::new()).into_json()
} }
pub async fn update_correspondent( pub async fn update_correspondent(
State(state): State<AppState>,
Path(correspondent_id): Path<Uuid>, Path(correspondent_id): Path<Uuid>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
Json(payload): Json<UpdateCorrespondentRequest>, Json(payload): Json<UpdateCorrespondentRequest>,
) -> AppResult<Json<CorrespondentSummary>> { ) -> AppResult<Json<CorrespondentSummary>> {
let mut conn = state.db()?;
let existing: Correspondent = correspondents::table let existing: Correspondent = correspondents::table
.find(correspondent_id) .find(correspondent_id)
.first(&mut conn)?; .filter(correspondents::tenant_id.eq(tenant_id))
.first(&mut conn)
.one()?;
let mut new_name: Option<String> = None; let mut new_name: Option<String> = None;
if let Some(ref candidate) = payload.name { if let Some(ref candidate) = payload.name {
@@ -149,6 +164,7 @@ pub async fn update_correspondent(
let duplicate = correspondents::table let duplicate = correspondents::table
.filter(correspondents::name.eq(trimmed)) .filter(correspondents::name.eq(trimmed))
.filter(correspondents::id.ne(correspondent_id)) .filter(correspondents::id.ne(correspondent_id))
.filter(correspondents::tenant_id.eq(tenant_id))
.first::<Correspondent>(&mut conn) .first::<Correspondent>(&mut conn)
.optional()?; .optional()?;
if duplicate.is_some() { if duplicate.is_some() {
@@ -167,8 +183,8 @@ pub async fn update_correspondent(
} }
if new_name.is_none() && new_metadata.is_none() { if new_name.is_none() && new_metadata.is_none() {
let usage = load_usage_for_correspondent(&mut conn, correspondent_id)?; let usage = load_usage_for_correspondent(&mut conn, tenant_id, correspondent_id)?;
return Ok(Json(build_summary(existing.clone(), usage))); return build_summary(existing.clone(), usage).into_json();
} }
let mut changeset = CorrespondentChangeset::default(); let mut changeset = CorrespondentChangeset::default();
@@ -180,24 +196,33 @@ pub async fn update_correspondent(
} }
let now = Utc::now().naive_utc(); let now = Utc::now().naive_utc();
diesel::update(correspondents::table.find(correspondent_id)) diesel::update(
.set((&changeset, correspondents::updated_at.eq(now))) correspondents::table
.execute(&mut conn)?; .find(correspondent_id)
.filter(correspondents::tenant_id.eq(tenant_id)),
)
.set((&changeset, correspondents::updated_at.eq(now)))
.execute(&mut conn)?;
let updated: Correspondent = correspondents::table let updated: Correspondent = correspondents::table
.find(correspondent_id) .find(correspondent_id)
.first(&mut conn)?; .filter(correspondents::tenant_id.eq(tenant_id))
let usage = load_usage_for_correspondent(&mut conn, correspondent_id)?; .first(&mut conn)
Ok(Json(build_summary(updated, usage))) .one()?;
let usage = load_usage_for_correspondent(&mut conn, tenant_id, correspondent_id)?;
build_summary(updated, usage).into_json()
} }
pub async fn delete_correspondent( pub async fn delete_correspondent(
State(state): State<AppState>,
Path(correspondent_id): Path<Uuid>, Path(correspondent_id): Path<Uuid>,
) -> AppResult<impl IntoResponse> { TenantScopedConn {
let mut conn = state.db()?; mut conn,
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<StatusCode> {
let usage: i64 = document_correspondents::table let usage: i64 = document_correspondents::table
.filter(document_correspondents::tenant_id.eq(tenant_id))
.filter(document_correspondents::correspondent_id.eq(correspondent_id)) .filter(document_correspondents::correspondent_id.eq(correspondent_id))
.select(count_star()) .select(count_star())
.first(&mut conn)?; .first(&mut conn)?;
@@ -208,12 +233,16 @@ pub async fn delete_correspondent(
)); ));
} }
let deleted = let deleted = diesel::delete(
diesel::delete(correspondents::table.find(correspondent_id)).execute(&mut conn)?; correspondents::table
.filter(correspondents::id.eq(correspondent_id))
.filter(correspondents::tenant_id.eq(tenant_id)),
)
.execute(&mut conn)?;
if deleted == 0 { if deleted == 0 {
return Err(AppError::not_found()); return Err(AppError::not_found());
} }
Ok(StatusCode::NO_CONTENT) no_content()
} }
fn build_summary( fn build_summary(
@@ -243,10 +272,12 @@ fn normalize_metadata(input: Option<Value>) -> Value {
fn load_usage_for_correspondent( fn load_usage_for_correspondent(
conn: &mut PgConnection, conn: &mut PgConnection,
tenant_id: Uuid,
correspondent_id: Uuid, correspondent_id: Uuid,
) -> AppResult<BTreeMap<String, i64>> { ) -> AppResult<BTreeMap<String, i64>> {
let rows: Vec<(String, i64)> = document_correspondents::table let rows: Vec<(String, i64)> = document_correspondents::table
.filter(document_correspondents::correspondent_id.eq(correspondent_id)) .filter(document_correspondents::correspondent_id.eq(correspondent_id))
.filter(document_correspondents::tenant_id.eq(tenant_id))
.group_by(document_correspondents::role) .group_by(document_correspondents::role)
.select((document_correspondents::role, count_star())) .select((document_correspondents::role, count_star()))
.load(conn)?; .load(conn)?;
File diff suppressed because it is too large Load Diff
+120
View File
@@ -0,0 +1,120 @@
use std::path::Path as FsPath;
use uuid::Uuid;
use crate::error::{AppError, AppResult};
use crate::models::{Document, DocumentAsset, DocumentAssetObject, DocumentVersion};
use crate::state::AppState;
use crate::utils::time::to_iso;
use super::{
DocumentAssetDetailResponse, DocumentAssetObjectResponse, DocumentAssetResponse,
DocumentVersionResponse,
};
pub fn build_download_path(
state: &AppState,
document: &Document,
user_id: Uuid,
) -> AppResult<String> {
state
.jwt
.generate_download_token(document.id, user_id, document.tenant_id)
.map(|token| format!("/download/{token}"))
.map_err(|err| AppError::internal(format!("failed to generate download token: {err}")))
}
pub fn to_version_response(
version: DocumentVersion,
include_operations_summary: bool,
) -> DocumentVersionResponse {
DocumentVersionResponse {
id: version.id,
version_number: version.version_number,
s3_key: version.s3_key,
size_bytes: version.size_bytes,
checksum: version.checksum,
created_at: to_iso(version.created_at),
metadata: version.metadata,
operations_summary: if include_operations_summary {
Some(version.operations_summary)
} else {
None
},
}
}
pub fn to_asset_summary(asset: DocumentAsset) -> DocumentAssetResponse {
DocumentAssetResponse {
id: asset.id,
asset_type: asset.asset_type,
mime_type: asset.mime_type,
metadata: asset.metadata,
cardinality: asset.cardinality,
}
}
pub fn to_asset_detail_response(
asset: DocumentAsset,
objects: Vec<DocumentAssetObjectResponse>,
) -> DocumentAssetDetailResponse {
DocumentAssetDetailResponse {
id: asset.id,
asset_type: asset.asset_type,
mime_type: asset.mime_type,
metadata: asset.metadata,
created_at: to_iso(asset.created_at),
cardinality: asset.cardinality,
objects,
}
}
pub fn to_asset_object_response(
object: DocumentAssetObject,
url: Option<String>,
expires_at: Option<i64>,
) -> DocumentAssetObjectResponse {
DocumentAssetObjectResponse {
id: object.id,
ordinal: object.ordinal,
metadata: object.metadata,
url,
expires_at,
}
}
pub fn derive_document_title(original: &str) -> String {
let trimmed = original.trim();
if trimmed.is_empty() {
return "Document".to_string();
}
let stem = FsPath::new(trimmed)
.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
stem.unwrap_or_else(|| trimmed.to_string())
}
pub fn filename_with_retained_extension(title: &str, current_filename: &str) -> String {
let extension = FsPath::new(current_filename)
.extension()
.and_then(|ext| ext.to_str());
if let Some(ext) = extension {
if title
.rsplit_once('.')
.map(|(_, existing_ext)| existing_ext.eq_ignore_ascii_case(ext))
.unwrap_or(false)
{
title.to_string()
} else {
format!("{title}.{ext}")
}
} else {
title.to_string()
}
}
@@ -0,0 +1,61 @@
use std::collections::HashSet;
use uuid::Uuid;
use crate::error::{AppError, AppResult};
use super::CorrespondentAssignmentInput;
pub const CORRESPONDENT_ROLES: &[&str] = &["sender", "receiver", "other"];
pub fn normalize_role(value: &str) -> String {
value.trim().to_lowercase()
}
pub fn is_valid_correspondent_role(role: &str) -> bool {
CORRESPONDENT_ROLES.iter().any(|allowed| *allowed == role)
}
pub fn normalize_correspondent_assignments(
assignments: &[CorrespondentAssignmentInput],
) -> AppResult<(Vec<(Uuid, String)>, Vec<Uuid>, Vec<String>)> {
let mut unique_pairs: HashSet<(Uuid, String)> = HashSet::new();
let mut normalized_pairs: Vec<(Uuid, String)> = Vec::new();
let mut role_set: HashSet<String> = HashSet::new();
let mut correspondent_ids: HashSet<Uuid> = HashSet::new();
for assignment in assignments {
let role = normalize_role(&assignment.role);
if role.is_empty() {
return Err(AppError::bad_request("role must not be empty"));
}
if !is_valid_correspondent_role(&role) {
return Err(AppError::bad_request(format!(
"invalid correspondent role '{role}'. Allowed roles: {}",
CORRESPONDENT_ROLES.join(", ")
)));
}
if !unique_pairs.insert((assignment.correspondent_id, role.clone())) {
continue;
}
normalized_pairs.push((assignment.correspondent_id, role.clone()));
role_set.insert(role);
correspondent_ids.insert(assignment.correspondent_id);
}
if normalized_pairs.is_empty() {
return Err(AppError::bad_request(
"assignments must contain at least one unique correspondent/role pair",
));
}
let mut correspondents_vec: Vec<Uuid> = correspondent_ids.into_iter().collect();
correspondents_vec.sort();
let mut roles_vec: Vec<String> = role_set.into_iter().collect();
roles_vec.sort();
Ok((normalized_pairs, correspondents_vec, roles_vec))
}
@@ -0,0 +1,91 @@
use serde_json::Value;
use uuid::Uuid;
pub fn build_quickwit_query(input: &str) -> Option<String> {
let tokens: Vec<String> = input
.split_whitespace()
.filter(|token| !token.is_empty())
.map(|token| {
let normalized = token.to_lowercase();
escape_quickwit_token(&normalized)
})
.collect();
if tokens.is_empty() {
return None;
}
let parts: Vec<String> = tokens
.into_iter()
.map(|token| format!("(title:{token} OR text:{token})"))
.collect();
Some(parts.join(" AND "))
}
pub fn escape_quickwit_token(token: &str) -> String {
let mut escaped = String::with_capacity(token.len());
for ch in token.chars() {
match ch {
'+' | '-' | '&' | '|' | '!' | '(' | ')' | '{' | '}' | '[' | ']' | '^' | '"' | '~'
| '*' | '?' | ':' | '\\' | '/' => {
escaped.push('\\');
escaped.push(ch);
}
_ => escaped.push(ch),
}
}
escaped
}
pub fn extract_document_id(hit: &Value) -> Option<Uuid> {
for key in ["_source", "source", "fields", "stored_fields"] {
if let Some(value) = hit.get(key) {
if let Some(uuid) = extract_uuid_from_value(value) {
return Some(uuid);
}
}
}
if let Some(value) = hit.get("document_id") {
if let Some(uuid) = extract_uuid_from_value(value) {
return Some(uuid);
}
}
None
}
pub fn extract_uuid_from_value(value: &Value) -> Option<Uuid> {
if let Some(obj) = value.as_object() {
if let Some(inner) = obj.get("document_id") {
return parse_uuid_value(inner);
}
}
if let Some(arr) = value.as_array() {
for item in arr {
if let Some(uuid) = extract_uuid_from_value(item) {
return Some(uuid);
}
}
}
parse_uuid_value(value)
}
pub fn parse_uuid_value(value: &Value) -> Option<Uuid> {
if let Some(s) = value.as_str() {
return Uuid::parse_str(s).ok();
}
if let Some(arr) = value.as_array() {
for item in arr {
if let Some(uuid) = parse_uuid_value(item) {
return Some(uuid);
}
}
}
None
}
+149 -61
View File
@@ -4,19 +4,24 @@ use axum::{
}; };
use diesel::{dsl::exists, prelude::*, PgConnection}; use diesel::{dsl::exists, prelude::*, PgConnection};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value;
use uuid::Uuid; use uuid::Uuid;
use crate::models::{Document, Folder, NewFolder}; use crate::models::{Document, Folder, NewFolder};
use crate::schema::{documents, folders}; use crate::schema::{documents, folders};
use crate::state::AppState; use crate::state::AppState;
use crate::{ use crate::{
auth::AuthenticatedUser, auth::TenantScopedConn,
error::{AppError, AppResult}, error::{AppError, AppResult},
}; };
use super::documents::{ use super::documents::{
load_correspondents_for_documents, load_primary_assets, load_tags_for_documents, load_correspondents_for_documents, load_primary_assets, load_tags_for_documents,
to_document_response, to_iso, DocumentResponse, to_document_response, DocumentResponse,
};
use crate::utils::{
json::{classify_nullable, NullableValue},
time::to_iso,
}; };
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -31,13 +36,6 @@ pub struct EnsureFolderPathRequest {
pub segments: Vec<String>, pub segments: Vec<String>,
} }
#[derive(Deserialize)]
pub struct UpdateFolderRequest {
#[serde(default)]
pub parent_id: Option<Option<Uuid>>,
pub name: Option<String>,
}
#[derive(Serialize)] #[derive(Serialize)]
pub struct FolderResponse { pub struct FolderResponse {
pub folder: FolderInfo, pub folder: FolderInfo,
@@ -69,16 +67,36 @@ pub struct FolderInfo {
pub updated_at: String, 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( pub async fn ensure_folder_path(
State(state): State<AppState>, TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
Json(payload): Json<EnsureFolderPathRequest>, Json(payload): Json<EnsureFolderPathRequest>,
) -> AppResult<Json<FolderResponse>> { ) -> AppResult<Json<FolderResponse>> {
if payload.segments.is_empty() { if payload.segments.is_empty() {
return Err(AppError::bad_request("segments must not be 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 target_folder = conn.transaction::<Folder, AppError, _>(|conn| {
let mut current_parent = payload.parent_id; let mut current_parent = payload.parent_id;
let mut last_folder: Option<Folder> = None; let mut last_folder: Option<Folder> = None;
@@ -93,12 +111,14 @@ pub async fn ensure_folder_path(
folders::table folders::table
.filter(folders::parent_id.eq(Some(parent_id))) .filter(folders::parent_id.eq(Some(parent_id)))
.filter(folders::name.eq(name)) .filter(folders::name.eq(name))
.filter(folders::tenant_id.eq(tenant_id))
.first(conn) .first(conn)
.optional()? .optional()?
} else { } else {
folders::table folders::table
.filter(folders::parent_id.is_null()) .filter(folders::parent_id.is_null())
.filter(folders::name.eq(name)) .filter(folders::name.eq(name))
.filter(folders::tenant_id.eq(tenant_id))
.first(conn) .first(conn)
.optional()? .optional()?
}; };
@@ -110,6 +130,7 @@ pub async fn ensure_folder_path(
id: Uuid::new_v4(), id: Uuid::new_v4(),
name: name.to_string(), name: name.to_string(),
parent_id: current_parent, parent_id: current_parent,
tenant_id,
}; };
diesel::insert_into(folders::table) diesel::insert_into(folders::table)
@@ -132,19 +153,22 @@ pub async fn ensure_folder_path(
} }
pub async fn create_folder( pub async fn create_folder(
State(state): State<AppState>, TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
Json(payload): Json<CreateFolderRequest>, Json(payload): Json<CreateFolderRequest>,
) -> AppResult<Json<FolderResponse>> { ) -> AppResult<Json<FolderResponse>> {
if payload.name.trim().is_empty() { if payload.name.trim().is_empty() {
return Err(AppError::bad_request("name must not be empty")); return Err(AppError::bad_request("name must not be empty"));
} }
let mut conn = state.db()?;
let new_folder = NewFolder { let new_folder = NewFolder {
id: Uuid::new_v4(), id: Uuid::new_v4(),
name: payload.name.trim().to_string(), name: payload.name.trim().to_string(),
parent_id: payload.parent_id, parent_id: payload.parent_id,
tenant_id,
}; };
diesel::insert_into(folders::table) diesel::insert_into(folders::table)
@@ -161,10 +185,13 @@ pub async fn list_folder_contents(
State(state): State<AppState>, State(state): State<AppState>,
Path(folder_identifier): Path<String>, Path(folder_identifier): Path<String>,
Query(query): Query<FolderContentsQuery>, Query(query): Query<FolderContentsQuery>,
user: AuthenticatedUser, TenantScopedConn {
mut conn,
tenant_id,
user_id,
..
}: TenantScopedConn,
) -> AppResult<Json<FolderContentsResponse>> { ) -> AppResult<Json<FolderContentsResponse>> {
let mut conn = state.db()?;
let folder_id = if folder_identifier.eq_ignore_ascii_case("root") { let folder_id = if folder_identifier.eq_ignore_ascii_case("root") {
None None
} else { } else {
@@ -176,7 +203,10 @@ pub async fn list_folder_contents(
let folder = match folder_id { let folder = match folder_id {
Some(id) => Some(folder_to_info( Some(id) => Some(folder_to_info(
folders::table.find(id).first::<Folder>(&mut conn)?, folders::table
.find(id)
.filter(folders::tenant_id.eq(tenant_id))
.first::<Folder>(&mut conn)?,
)), )),
None => None, None => None,
}; };
@@ -184,11 +214,13 @@ pub async fn list_folder_contents(
let child_folders: Vec<Folder> = if let Some(parent_id) = folder_id { let child_folders: Vec<Folder> = if let Some(parent_id) = folder_id {
folders::table folders::table
.filter(folders::parent_id.eq(parent_id)) .filter(folders::parent_id.eq(parent_id))
.filter(folders::tenant_id.eq(tenant_id))
.order(folders::name.asc()) .order(folders::name.asc())
.load(&mut conn)? .load(&mut conn)?
} else { } else {
folders::table folders::table
.filter(folders::parent_id.is_null()) .filter(folders::parent_id.is_null())
.filter(folders::tenant_id.eq(tenant_id))
.order(folders::name.asc()) .order(folders::name.asc())
.load(&mut conn)? .load(&mut conn)?
}; };
@@ -197,6 +229,7 @@ pub async fn list_folder_contents(
let documents = if query.include_documents { let documents = if query.include_documents {
let docs_query = documents::table let docs_query = documents::table
.filter(documents::deleted_at.is_null()) .filter(documents::deleted_at.is_null())
.filter(documents::tenant_id.eq(tenant_id))
.order(documents::uploaded_at.desc()); .order(documents::uploaded_at.desc());
let docs: Vec<Document> = if let Some(current_folder) = folder_id { let docs: Vec<Document> = if let Some(current_folder) = folder_id {
@@ -214,7 +247,7 @@ pub async fn list_folder_contents(
let mut correspondents_map = load_correspondents_for_documents(&mut conn, &doc_ids)?; let mut correspondents_map = load_correspondents_for_documents(&mut conn, &doc_ids)?;
drop(conn); drop(conn);
let primary_versions = load_primary_assets(&state, &docs).await?; let primary_versions = load_primary_assets(&state, tenant_id, &docs).await?;
let mut documents = Vec::with_capacity(doc_ids.len()); let mut documents = Vec::with_capacity(doc_ids.len());
for doc in docs { for doc in docs {
@@ -223,7 +256,7 @@ pub async fn list_folder_contents(
let current_version = primary_versions.get(&doc.id).cloned(); let current_version = primary_versions.get(&doc.id).cloned();
documents.push(to_document_response( documents.push(to_document_response(
&state, &state,
user.user_id, user_id,
doc, doc,
tags, tags,
correspondents, correspondents,
@@ -244,16 +277,23 @@ pub async fn list_folder_contents(
} }
pub async fn delete_folder( pub async fn delete_folder(
State(state): State<AppState>,
Path(folder_id): Path<Uuid>, Path(folder_id): Path<Uuid>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<StatusCode> { ) -> AppResult<StatusCode> {
let mut conn = state.db()?;
conn.transaction::<_, AppError, _>(|conn| { conn.transaction::<_, AppError, _>(|conn| {
folders::table.find(folder_id).first::<Folder>(conn)?; folders::table
.find(folder_id)
.filter(folders::tenant_id.eq(tenant_id))
.first::<Folder>(conn)?;
let has_child_folders: bool = diesel::select(exists( let has_child_folders: bool = diesel::select(exists(
folders::table.filter(folders::parent_id.eq(Some(folder_id))), folders::table
.filter(folders::parent_id.eq(Some(folder_id)))
.filter(folders::tenant_id.eq(tenant_id)),
)) ))
.get_result(conn)?; .get_result(conn)?;
@@ -266,6 +306,7 @@ pub async fn delete_folder(
let has_documents: bool = diesel::select(exists( let has_documents: bool = diesel::select(exists(
documents::table documents::table
.filter(documents::folder_id.eq(Some(folder_id))) .filter(documents::folder_id.eq(Some(folder_id)))
.filter(documents::tenant_id.eq(tenant_id))
.filter(documents::deleted_at.is_null()), .filter(documents::deleted_at.is_null()),
)) ))
.get_result(conn)?; .get_result(conn)?;
@@ -276,7 +317,12 @@ pub async fn delete_folder(
)); ));
} }
diesel::delete(folders::table.find(folder_id)).execute(conn)?; diesel::delete(
folders::table
.filter(folders::id.eq(folder_id))
.filter(folders::tenant_id.eq(tenant_id)),
)
.execute(conn)?;
Ok(()) Ok(())
})?; })?;
@@ -285,50 +331,84 @@ pub async fn delete_folder(
} }
pub async fn update_folder( pub async fn update_folder(
State(state): State<AppState>,
Path(folder_id): Path<Uuid>, Path(folder_id): Path<Uuid>,
Json(payload): Json<UpdateFolderRequest>, TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
Json(body): Json<Value>,
) -> AppResult<StatusCode> { ) -> AppResult<StatusCode> {
let mut conn = state.db()?; if !body.is_object() {
return Err(AppError::bad_request("request body must be a JSON object"));
}
let parent_class = classify_nullable(body.get("parent_id")).map_err(AppError::bad_request)?;
let name_class = classify_nullable(body.get("name")).map_err(AppError::bad_request)?;
conn.transaction::<(), AppError, _>(|conn| { conn.transaction::<(), AppError, _>(|conn| {
let folder: Folder = folders::table.find(folder_id).first(conn)?; let folder: Folder = folders::table
.find(folder_id)
.filter(folders::tenant_id.eq(tenant_id))
.first(conn)?;
let mut next_parent = folder.parent_id; let mut next_parent = folder.parent_id;
let mut parent_changed = false; let mut parent_changed = false;
match parent_class {
if let Some(parent_request) = payload.parent_id { NullableValue::Omitted => {}
if parent_request == Some(folder_id) { NullableValue::Null => {
return Err(AppError::bad_request("folder cannot be its own parent")); if folder.parent_id.is_some() {
} parent_changed = true;
if let Some(parent_id) = parent_request {
let _parent: Folder = folders::table.find(parent_id).first(conn)?;
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 = None;
} }
NullableValue::String(value) => {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(AppError::bad_request("parent_id must not be empty"));
}
let parent_id = Uuid::parse_str(trimmed)
.map_err(|_| AppError::bad_request("parent_id must be a valid UUID or null"))?;
if parent_id == folder_id {
return Err(AppError::bad_request("folder cannot be its own parent"));
}
parent_changed = parent_request != folder.parent_id; let _parent: Folder = folders::table
next_parent = parent_request; .find(parent_id)
.filter(folders::tenant_id.eq(tenant_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;
}
next_parent = Some(parent_id);
}
} }
let mut new_name = folder.name.clone(); let mut new_name = folder.name.clone();
let mut name_changed = false; let mut name_changed = false;
match name_class {
if let Some(name) = payload.name { NullableValue::Omitted => {}
let trimmed = name.trim(); NullableValue::Null => {
if trimmed.is_empty() { return Err(AppError::bad_request("name cannot be null"));
return Err(AppError::bad_request("name must not be empty"));
} }
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 { if trimmed != folder.name {
new_name = trimmed.to_string(); new_name = trimmed.to_string();
name_changed = true; name_changed = true;
}
} }
} }
@@ -341,6 +421,7 @@ pub async fn update_folder(
.filter(folders::parent_id.eq(Some(parent_id))) .filter(folders::parent_id.eq(Some(parent_id)))
.filter(folders::name.eq(&new_name)) .filter(folders::name.eq(&new_name))
.filter(folders::id.ne(folder_id)) .filter(folders::id.ne(folder_id))
.filter(folders::tenant_id.eq(tenant_id))
.first::<Folder>(conn) .first::<Folder>(conn)
.optional()? .optional()?
} else { } else {
@@ -348,6 +429,7 @@ pub async fn update_folder(
.filter(folders::parent_id.is_null()) .filter(folders::parent_id.is_null())
.filter(folders::name.eq(&new_name)) .filter(folders::name.eq(&new_name))
.filter(folders::id.ne(folder_id)) .filter(folders::id.ne(folder_id))
.filter(folders::tenant_id.eq(tenant_id))
.first::<Folder>(conn) .first::<Folder>(conn)
.optional()? .optional()?
}; };
@@ -358,12 +440,16 @@ pub async fn update_folder(
)); ));
} }
diesel::update(folders::table.find(folder_id)) diesel::update(
.set(( folders::table
folders::parent_id.eq(next_parent), .find(folder_id)
folders::name.eq(&new_name), .filter(folders::tenant_id.eq(tenant_id)),
)) )
.execute(conn)?; .set((
folders::parent_id.eq(next_parent),
folders::name.eq(&new_name),
))
.execute(conn)?;
Ok(()) Ok(())
})?; })?;
@@ -383,6 +469,7 @@ fn folder_to_info(folder: Folder) -> FolderInfo {
pub(super) fn gather_descendant_folder_ids( pub(super) fn gather_descendant_folder_ids(
conn: &mut PgConnection, conn: &mut PgConnection,
tenant_id: Uuid,
folder_id: Uuid, folder_id: Uuid,
) -> AppResult<Vec<Uuid>> { ) -> AppResult<Vec<Uuid>> {
let mut ids = vec![folder_id]; let mut ids = vec![folder_id];
@@ -391,6 +478,7 @@ pub(super) fn gather_descendant_folder_ids(
while let Some(current) = queue.pop() { while let Some(current) = queue.pop() {
let child_ids: Vec<Uuid> = folders::table let child_ids: Vec<Uuid> = folders::table
.filter(folders::parent_id.eq(Some(current))) .filter(folders::parent_id.eq(Some(current)))
.filter(folders::tenant_id.eq(tenant_id))
.select(folders::id) .select(folders::id)
.load(conn)?; .load(conn)?;
queue.extend(child_ids.iter().copied()); queue.extend(child_ids.iter().copied());
+25 -7
View File
@@ -2,12 +2,15 @@ use axum::http::HeaderValue;
use axum::{ use axum::{
extract::DefaultBodyLimit, extract::DefaultBodyLimit,
middleware, middleware,
response::Json,
routing::{delete, get, patch, post}, routing::{delete, get, patch, post},
Router, Router,
}; };
use std::sync::Arc;
use tower_http::cors::{AllowOrigin, CorsLayer}; use tower_http::cors::{AllowOrigin, CorsLayer};
use utoipa::OpenApi;
use crate::{auth::AuthenticatedUser, state::AppState}; use crate::{auth::AuthenticatedUser, openapi::ApiDoc, state::AppState};
pub mod auth; pub mod auth;
pub mod correspondents; pub mod correspondents;
@@ -50,14 +53,15 @@ pub fn create_router(state: AppState) -> Router<()> {
.route("/login", post(auth::login)) .route("/login", post(auth::login))
.route("/refresh", post(auth::refresh)) .route("/refresh", post(auth::refresh))
.route("/logout", post(auth::logout)) .route("/logout", post(auth::logout))
.route("/select-tenant", post(auth::select_tenant))
.route("/me", get(auth::me)); .route("/me", get(auth::me));
let documents_routes = Router::new() let documents_routes = Router::new()
.route("/check", get(documents::check_document))
.route( .route(
"/", "/",
get(documents::list_documents).post(documents::upload_document), 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/move", post(documents::bulk_move_documents))
.route("/bulk/tags", post(documents::bulk_update_tags)) .route("/bulk/tags", post(documents::bulk_update_tags))
.route( .route(
@@ -75,7 +79,6 @@ pub fn create_router(state: AppState) -> Router<()> {
.patch(documents::update_document), .patch(documents::update_document),
) )
.route("/:id/download", get(documents::download_document)) .route("/:id/download", get(documents::download_document))
.route("/:id/assets/:asset_id", get(documents::get_document_asset))
.route( .route(
"/:id/assets", "/:id/assets",
get(documents::list_document_assets).post(documents::request_document_assets), get(documents::list_document_assets).post(documents::request_document_assets),
@@ -98,10 +101,9 @@ pub fn create_router(state: AppState) -> Router<()> {
let folders_routes = Router::new() let folders_routes = Router::new()
.route("/", post(folders::create_folder)) .route("/", post(folders::create_folder))
.route("/path", post(folders::ensure_folder_path)) .route("/path", post(folders::ensure_folder_path))
.route( .route("/:id", get(folders::get_folder))
"/:id", .route("/:id", delete(folders::delete_folder))
delete(folders::delete_folder).patch(folders::update_folder), .route("/:id", patch(folders::update_folder))
)
.route("/:id/contents", get(folders::list_folder_contents)); .route("/:id/contents", get(folders::list_folder_contents));
let tags_routes = Router::new() let tags_routes = Router::new()
@@ -120,16 +122,32 @@ pub fn create_router(state: AppState) -> Router<()> {
); );
let protected_state = state.clone(); let protected_state = state.clone();
let assets_routes = Router::new().route("/:asset_id", get(documents::get_document_asset));
let protected_routes = Router::new() let protected_routes = Router::new()
.nest("/api/documents", documents_routes) .nest("/api/documents", documents_routes)
.nest("/api/folders", folders_routes) .nest("/api/folders", folders_routes)
.nest("/api/tags", tags_routes) .nest("/api/tags", tags_routes)
.nest("/api/correspondents", correspondents_routes) .nest("/api/correspondents", correspondents_routes)
.nest("/api/assets", assets_routes)
.layer(middleware::from_extractor_with_state::<AuthenticatedUser, _>(protected_state)); .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() Router::new()
.merge(download_routes) .merge(download_routes)
.merge(protected_routes) .merge(protected_routes)
.merge(docs_route)
.nest("/api/auth", auth_routes) .nest("/api/auth", auth_routes)
.route("/api/health", get(health::health_check)) .route("/api/health", get(health::health_check))
.with_state(state) .with_state(state)
+77 -34
View File
@@ -1,19 +1,16 @@
use crate::utils::json::{classify_nullable, NullableValue}; use crate::utils::json::{classify_nullable, NullableValue};
use axum::{ use axum::{extract::Path, http::StatusCode, Json};
extract::{Path, State},
http::StatusCode,
Json,
};
use diesel::{dsl::count_star, prelude::*}; use diesel::{dsl::count_star, prelude::*};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use std::collections::HashMap; use std::collections::HashMap;
use uuid::Uuid; use uuid::Uuid;
use crate::auth::TenantScopedConn;
use crate::error::{AppError, AppResult}; use crate::error::{AppError, AppResult};
use crate::models::{NewTag, Tag}; use crate::models::{NewTag, Tag};
use crate::schema::{document_tags, tags}; use crate::schema::{document_tags, tags};
use crate::state::AppState; use crate::utils::db::{no_content, EnsureEntity, IntoJsonResponse};
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct CreateTagRequest { pub struct CreateTagRequest {
@@ -36,19 +33,27 @@ pub struct TagCatalogEntry {
pub usage_count: i64, pub usage_count: i64,
} }
pub async fn list_tags(State(state): State<AppState>) -> AppResult<Json<Vec<TagCatalogEntry>>> { pub async fn list_tags(
let mut conn = state.db()?; TenantScopedConn {
mut conn,
let tag_list: Vec<Tag> = tags::table.order(tags::label.asc()).load(&mut conn)?; tenant_id,
..
}: TenantScopedConn,
) -> AppResult<Json<Vec<TagCatalogEntry>>> {
let tag_list: Vec<Tag> = tags::table
.filter(tags::tenant_id.eq(tenant_id))
.order(tags::label.asc())
.load(&mut conn)?;
let usage_rows: Vec<(Uuid, i64)> = document_tags::table let usage_rows: Vec<(Uuid, i64)> = document_tags::table
.filter(document_tags::tenant_id.eq(tenant_id))
.group_by(document_tags::tag_id) .group_by(document_tags::tag_id)
.select((document_tags::tag_id, count_star())) .select((document_tags::tag_id, count_star()))
.load(&mut conn)?; .load(&mut conn)?;
let usage_map: HashMap<Uuid, i64> = usage_rows.into_iter().collect(); let usage_map: HashMap<Uuid, i64> = usage_rows.into_iter().collect();
let response = tag_list let response: Vec<TagCatalogEntry> = tag_list
.into_iter() .into_iter()
.map(|tag| TagCatalogEntry { .map(|tag| TagCatalogEntry {
id: tag.id, id: tag.id,
@@ -58,22 +63,26 @@ pub async fn list_tags(State(state): State<AppState>) -> AppResult<Json<Vec<TagC
}) })
.collect(); .collect();
Ok(Json(response)) response.into_json()
} }
pub async fn create_tag( pub async fn create_tag(
State(state): State<AppState>, TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
Json(payload): Json<CreateTagRequest>, Json(payload): Json<CreateTagRequest>,
) -> AppResult<Json<TagCatalogEntry>> { ) -> AppResult<Json<TagCatalogEntry>> {
if payload.label.trim().is_empty() { if payload.label.trim().is_empty() {
return Err(AppError::bad_request("label must not be empty")); return Err(AppError::bad_request("label must not be empty"));
} }
let mut conn = state.db()?;
let new_tag = NewTag { let new_tag = NewTag {
id: Uuid::new_v4(), id: Uuid::new_v4(),
label: payload.label.trim().to_string(), label: payload.label.trim().to_string(),
color: payload.color, color: payload.color,
tenant_id,
}; };
match diesel::insert_into(tags::table) match diesel::insert_into(tags::table)
@@ -90,22 +99,35 @@ pub async fn create_tag(
Err(err) => return Err(AppError::from(err)), Err(err) => return Err(AppError::from(err)),
} }
let tag: Tag = tags::table.find(new_tag.id).first(&mut conn)?; let tag: Tag = tags::table
Ok(Json(TagCatalogEntry { .find(new_tag.id)
.filter(tags::tenant_id.eq(tenant_id))
.first(&mut conn)
.one()?;
TagCatalogEntry {
id: tag.id, id: tag.id,
label: tag.label, label: tag.label,
color: tag.color, color: tag.color,
usage_count: 0, usage_count: 0,
})) }
.into_json()
} }
pub async fn update_tag( pub async fn update_tag(
State(state): State<AppState>,
Path(tag_id): Path<Uuid>, Path(tag_id): Path<Uuid>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
Json(body): Json<Value>, Json(body): Json<Value>,
) -> AppResult<Json<TagCatalogEntry>> { ) -> AppResult<Json<TagCatalogEntry>> {
let mut conn = state.db()?; let existing: Tag = tags::table
let existing: Tag = tags::table.find(tag_id).first(&mut conn)?; .find(tag_id)
.filter(tags::tenant_id.eq(tenant_id))
.first(&mut conn)
.one()?;
let label_class = classify_nullable(body.get("label")).map_err(AppError::bad_request)?; let 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)?; let color_class = classify_nullable(body.get("color")).map_err(AppError::bad_request)?;
@@ -116,12 +138,13 @@ pub async fn update_tag(
.filter(document_tags::tag_id.eq(tag_id)) .filter(document_tags::tag_id.eq(tag_id))
.select(count_star()) .select(count_star())
.first(&mut conn)?; .first(&mut conn)?;
return Ok(Json(TagCatalogEntry { return TagCatalogEntry {
id: existing.id, id: existing.id,
label: existing.label.clone(), label: existing.label.clone(),
color: existing.color.clone(), color: existing.color.clone(),
usage_count, usage_count,
})); }
.into_json();
} }
let mut new_label: Option<String> = None; let mut new_label: Option<String> = None;
@@ -140,6 +163,7 @@ pub async fn update_tag(
let duplicate = tags::table let duplicate = tags::table
.filter(tags::label.eq(trimmed)) .filter(tags::label.eq(trimmed))
.filter(tags::id.ne(tag_id)) .filter(tags::id.ne(tag_id))
.filter(tags::tenant_id.eq(tenant_id))
.first::<Tag>(&mut conn) .first::<Tag>(&mut conn)
.optional()?; .optional()?;
if duplicate.is_some() { if duplicate.is_some() {
@@ -174,6 +198,7 @@ pub async fn update_tag(
if !label_changed && !color_changed { if !label_changed && !color_changed {
let usage_count: i64 = document_tags::table let usage_count: i64 = document_tags::table
.filter(document_tags::tag_id.eq(tag_id)) .filter(document_tags::tag_id.eq(tag_id))
.filter(document_tags::tenant_id.eq(tenant_id))
.select(count_star()) .select(count_star())
.first(&mut conn)?; .first(&mut conn)?;
return Ok(Json(TagCatalogEntry { return Ok(Json(TagCatalogEntry {
@@ -191,32 +216,45 @@ pub async fn update_tag(
.map(|opt| opt.as_ref().map(|value| value.as_str())), .map(|opt| opt.as_ref().map(|value| value.as_str())),
}; };
diesel::update(tags::table.find(tag_id)) diesel::update(
.set(&changeset) tags::table
.execute(&mut conn)?; .find(tag_id)
.filter(tags::tenant_id.eq(tenant_id)),
)
.set(&changeset)
.execute(&mut conn)?;
let updated: Tag = tags::table.find(tag_id).first(&mut conn)?; let updated: Tag = tags::table
.find(tag_id)
.filter(tags::tenant_id.eq(tenant_id))
.first(&mut conn)
.one()?;
let usage_count: i64 = document_tags::table let usage_count: i64 = document_tags::table
.filter(document_tags::tag_id.eq(tag_id)) .filter(document_tags::tag_id.eq(tag_id))
.filter(document_tags::tenant_id.eq(tenant_id))
.select(count_star()) .select(count_star())
.first(&mut conn)?; .first(&mut conn)?;
Ok(Json(TagCatalogEntry { TagCatalogEntry {
id: updated.id, id: updated.id,
label: updated.label, label: updated.label,
color: updated.color, color: updated.color,
usage_count, usage_count,
})) }
.into_json()
} }
pub async fn delete_tag( pub async fn delete_tag(
State(state): State<AppState>,
Path(tag_id): Path<Uuid>, Path(tag_id): Path<Uuid>,
) -> AppResult<impl axum::response::IntoResponse> { TenantScopedConn {
let mut conn = state.db()?; mut conn,
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<StatusCode> {
let usage: i64 = document_tags::table let usage: i64 = document_tags::table
.filter(document_tags::tag_id.eq(tag_id)) .filter(document_tags::tag_id.eq(tag_id))
.filter(document_tags::tenant_id.eq(tenant_id))
.select(count_star()) .select(count_star())
.first(&mut conn)?; .first(&mut conn)?;
@@ -226,10 +264,15 @@ pub async fn delete_tag(
)); ));
} }
let deleted = diesel::delete(tags::table.find(tag_id)).execute(&mut conn)?; let deleted = diesel::delete(
tags::table
.find(tag_id)
.filter(tags::tenant_id.eq(tenant_id)),
)
.execute(&mut conn)?;
if deleted == 0 { if deleted == 0 {
return Err(AppError::not_found()); return Err(AppError::not_found());
} }
Ok(StatusCode::NO_CONTENT) no_content()
} }
+202 -121
View File
@@ -20,17 +20,26 @@ use crate::error::{AppError, AppResult};
use crate::models::{Document, DocumentVersion, Folder, User}; use crate::models::{Document, DocumentVersion, Folder, User};
use crate::schema::{ use crate::schema::{
document_versions::dsl as document_versions_dsl, documents::dsl as documents_dsl, document_versions::dsl as document_versions_dsl, documents::dsl as documents_dsl,
folders::dsl as folders_dsl, users::dsl as users_dsl, folders::dsl as folders_dsl, tenants::dsl as tenant_dsl,
user_memberships::dsl as memberships_dsl, users::dsl as users_dsl,
}; };
use crate::state::AppState; use crate::state::AppState;
use crate::utils::{http::inline_content_disposition, time::to_http_date};
const REALM: &str = "Papercrate WebDAV"; const REALM: &str = "Papercrate WebDAV";
const DOWNLOAD_URL_TTL_SECONDS: u64 = 300; const DOWNLOAD_URL_TTL_SECONDS: u64 = 300;
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
struct WebDavUser { struct TenantEntry {
tenant_id: Uuid,
slug: String,
}
#[derive(Clone, Debug)]
struct WebDavContext {
_user_id: Uuid, _user_id: Uuid,
_username: String, _username: String,
tenants: Vec<TenantEntry>,
} }
pub fn create_router() -> Router<AppState> { pub fn create_router() -> Router<AppState> {
@@ -68,7 +77,7 @@ async fn handle_propfind(
path: &str, path: &str,
headers: HeaderMap, headers: HeaderMap,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
let _user = match authenticate(state, &headers)? { let context = match authenticate(state, &headers)? {
Some(user) => user, Some(user) => user,
None => return Ok(unauthorized_response()), None => return Ok(unauthorized_response()),
}; };
@@ -79,25 +88,44 @@ async fn handle_propfind(
}; };
let segments = parse_segments(path)?; let segments = parse_segments(path)?;
let resolution = match resolve_path(state, &segments)? {
Some(resolved) => resolved,
None => return Ok(not_found_response()),
};
let resources = match resolution { let resources = if segments.is_empty() {
ResolvedPath::Root => { build_account_root_resources(&context.tenants, depth)
let contents = fetch_folder_contents(state, None)?; } else {
build_resources_for_folder(None, &[], &contents, depth) let (requested_slug, remainder) = segments.split_first().unwrap();
let tenant_entry = match context
.tenants
.iter()
.find(|entry| entry.slug.eq_ignore_ascii_case(requested_slug))
{
Some(entry) => TenantEntry {
tenant_id: entry.tenant_id,
slug: entry.slug.clone(),
},
None => return Ok(not_found_response()),
};
let resolution = match resolve_path(state, &tenant_entry, remainder)? {
Some(resolved) => resolved,
None => return Ok(not_found_response()),
};
match resolution {
ResolvedPath::TenantRoot { chain } => {
let contents = fetch_folder_contents(state, tenant_entry.tenant_id, None)?;
build_resources_for_folder(None, &chain, &contents, depth)
}
ResolvedPath::Folder { folder, chain } => {
let contents =
fetch_folder_contents(state, tenant_entry.tenant_id, Some(folder.id))?;
build_resources_for_folder(Some(&folder), &chain, &contents, depth)
}
ResolvedPath::Document {
document,
version,
chain,
} => build_resources_for_document(&chain, &document, &version),
} }
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) let body = render_multistatus(&resources)
@@ -118,13 +146,34 @@ async fn handle_get_or_head(
headers: HeaderMap, headers: HeaderMap,
method: Method, method: Method,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
let _user = match authenticate(state, &headers)? { let context = match authenticate(state, &headers)? {
Some(user) => user, Some(user) => user,
None => return Ok(unauthorized_response()), None => return Ok(unauthorized_response()),
}; };
let segments = parse_segments(path)?; let segments = parse_segments(path)?;
let resolution = match resolve_path(state, &segments)? { let (requested_slug, remainder) = match segments.split_first() {
Some(values) => values,
None => return Ok(method_not_allowed()),
};
let tenant_entry = match context
.tenants
.iter()
.find(|entry| entry.slug.eq_ignore_ascii_case(requested_slug))
{
Some(entry) => TenantEntry {
tenant_id: entry.tenant_id,
slug: entry.slug.clone(),
},
None => return Ok(not_found_response()),
};
if remainder.is_empty() {
return Ok(method_not_allowed());
}
let resolution = match resolve_path(state, &tenant_entry, remainder)? {
Some(resolved) => resolved, Some(resolved) => resolved,
None => return Ok(not_found_response()), None => return Ok(not_found_response()),
}; };
@@ -219,21 +268,29 @@ fn parse_segments(path: &str) -> AppResult<Vec<String>> {
fn fetch_folder_contents( fn fetch_folder_contents(
state: &AppState, state: &AppState,
tenant_id: Uuid,
folder_id: Option<Uuid>, folder_id: Option<Uuid>,
) -> AppResult<WebDavFolderContents> { ) -> AppResult<WebDavFolderContents> {
let mut conn = state.db()?; let mut conn = state.db_for_tenant(tenant_id)?;
let folder = match folder_id { let folder = match folder_id {
Some(id) => Some(folders_dsl::folders.find(id).first::<Folder>(&mut conn)?), Some(id) => Some(
folders_dsl::folders
.filter(folders_dsl::tenant_id.eq(tenant_id))
.find(id)
.first::<Folder>(&mut conn)?,
),
None => None, None => None,
}; };
let subfolders: Vec<Folder> = match folder_id { let subfolders: Vec<Folder> = match folder_id {
Some(id) => folders_dsl::folders Some(id) => folders_dsl::folders
.filter(folders_dsl::tenant_id.eq(tenant_id))
.filter(folders_dsl::parent_id.eq(Some(id))) .filter(folders_dsl::parent_id.eq(Some(id)))
.order(folders_dsl::name.asc()) .order(folders_dsl::name.asc())
.load(&mut conn)?, .load(&mut conn)?,
None => folders_dsl::folders None => folders_dsl::folders
.filter(folders_dsl::tenant_id.eq(tenant_id))
.filter(folders_dsl::parent_id.is_null()) .filter(folders_dsl::parent_id.is_null())
.order(folders_dsl::name.asc()) .order(folders_dsl::name.asc())
.load(&mut conn)?, .load(&mut conn)?,
@@ -241,6 +298,7 @@ fn fetch_folder_contents(
let mut docs_query = documents_dsl::documents let mut docs_query = documents_dsl::documents
.filter(documents_dsl::deleted_at.is_null()) .filter(documents_dsl::deleted_at.is_null())
.filter(documents_dsl::tenant_id.eq(tenant_id))
.into_boxed(); .into_boxed();
docs_query = match folder_id { docs_query = match folder_id {
@@ -290,8 +348,9 @@ async fn stream_document(
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
let range_header = headers.get(header::RANGE).cloned(); let range_header = headers.get(header::RANGE).cloned();
let url = state let storage = state.storage_for_tenant(document.tenant_id)?;
.storage
let url = storage
.presign_get_object( .presign_get_object(
&version.s3_key, &version.s3_key,
Duration::from_secs(DOWNLOAD_URL_TTL_SECONDS), Duration::from_secs(DOWNLOAD_URL_TTL_SECONDS),
@@ -338,7 +397,7 @@ async fn stream_document(
builder = builder.header("Accept-Ranges", "bytes"); builder = builder.header("Accept-Ranges", "bytes");
if let Some(disposition) = content_disposition(&document.filename) { if let Some(disposition) = inline_content_disposition(&document.filename) {
builder = builder.header(header::CONTENT_DISPOSITION, disposition); builder = builder.header(header::CONTENT_DISPOSITION, disposition);
} }
@@ -360,7 +419,7 @@ async fn stream_document(
.map_err(|err| AppError::internal(format!("failed to build response: {err}"))) .map_err(|err| AppError::internal(format!("failed to build response: {err}")))
} }
fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavUser>, AppError> { fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavContext>, AppError> {
tracing::debug!("webdav authenticate invoked"); tracing::debug!("webdav authenticate invoked");
let authorization = match headers.get(header::AUTHORIZATION) { let authorization = match headers.get(header::AUTHORIZATION) {
Some(value) => match value.to_str() { Some(value) => match value.to_str() {
@@ -405,7 +464,7 @@ fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavUs
}; };
tracing::debug!(%username, "attempting webdav login"); tracing::debug!(%username, "attempting webdav login");
let mut conn = state.db()?; let mut conn = state.db_unscoped()?;
let user: User = match users_dsl::users let user: User = match users_dsl::users
.filter(users_dsl::username.eq(username)) .filter(users_dsl::username.eq(username))
@@ -427,10 +486,27 @@ fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavUs
return Ok(None); return Ok(None);
} }
tracing::debug!(%username, "webdav login success"); let tenant_rows: Vec<(Uuid, String)> = memberships_dsl::user_memberships
Ok(Some(WebDavUser { .inner_join(tenant_dsl::tenants)
.filter(memberships_dsl::user_id.eq(user.id))
.select((tenant_dsl::id, tenant_dsl::slug))
.load(&mut conn)?;
if tenant_rows.is_empty() {
tracing::warn!(%username, "webdav user has no tenant memberships");
return Ok(None);
}
let tenants: Vec<TenantEntry> = tenant_rows
.into_iter()
.map(|(tenant_id, slug)| TenantEntry { tenant_id, slug })
.collect();
tracing::debug!(%username, tenant_count = tenants.len(), "webdav login success");
Ok(Some(WebDavContext {
_user_id: user.id, _user_id: user.id,
_username: user.username, _username: user.username,
tenants,
})) }))
} }
@@ -444,10 +520,10 @@ fn build_resources_for_folder(
let display_name = folder let display_name = folder
.map(|folder| folder.name.clone()) .map(|folder| folder.name.clone())
.unwrap_or_else(|| "/".to_string()); .unwrap_or_else(|| chain.last().cloned().unwrap_or_else(|| "/".to_string()));
let href = build_href(chain, true); let href = build_href(chain, true);
let last_modified = folder.map(|folder| format_http_date(folder.updated_at)); let last_modified = folder.map(|folder| to_http_date(folder.updated_at));
resources.push(DavResource { resources.push(DavResource {
href, href,
@@ -471,7 +547,7 @@ fn build_resources_for_folder(
is_collection: true, is_collection: true,
content_length: None, content_length: None,
content_type: None, content_type: None,
last_modified: Some(format_http_date(subfolder.updated_at)), last_modified: Some(to_http_date(subfolder.updated_at)),
}); });
} }
@@ -488,6 +564,37 @@ fn build_resources_for_folder(
resources resources
} }
fn build_account_root_resources(tenants: &[TenantEntry], depth: u8) -> Vec<DavResource> {
let mut resources = Vec::new();
resources.push(DavResource {
href: "/".to_string(),
display_name: "/".to_string(),
is_collection: true,
content_length: None,
content_type: None,
last_modified: None,
});
if depth == 0 {
return resources;
}
for tenant in tenants {
let href = build_href(&[tenant.slug.clone()], true);
resources.push(DavResource {
href,
display_name: tenant.slug.clone(),
is_collection: true,
content_length: None,
content_type: None,
last_modified: None,
});
}
resources
}
fn build_resources_for_document( fn build_resources_for_document(
chain: &[String], chain: &[String],
document: &Document, document: &Document,
@@ -509,7 +616,7 @@ fn document_to_resource(
is_collection: false, is_collection: false,
content_length: Some(version.size_bytes), content_length: Some(version.size_bytes),
content_type: document.content_type.clone(), content_type: document.content_type.clone(),
last_modified: Some(format_http_date(document.updated_at)), last_modified: Some(to_http_date(document.updated_at)),
} }
} }
@@ -590,32 +697,6 @@ fn render_multistatus(resources: &[DavResource]) -> Result<Vec<u8>, quick_xml::E
Ok(writer.into_inner()) 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 { struct WebDavFolderContents {
_folder: Option<Folder>, _folder: Option<Folder>,
subfolders: Vec<Folder>, subfolders: Vec<Folder>,
@@ -635,9 +716,10 @@ struct DavResource {
content_type: Option<String>, content_type: Option<String>,
last_modified: Option<String>, last_modified: Option<String>,
} }
enum ResolvedPath { enum ResolvedPath {
Root, TenantRoot {
chain: Vec<String>,
},
Folder { Folder {
folder: Folder, folder: Folder,
chain: Vec<String>, chain: Vec<String>,
@@ -649,37 +731,37 @@ enum ResolvedPath {
}, },
} }
fn resolve_path(state: &AppState, segments: &[String]) -> AppResult<Option<ResolvedPath>> { fn resolve_path(
if segments.is_empty() { state: &AppState,
return Ok(Some(ResolvedPath::Root)); tenant: &TenantEntry,
} segments: &[String],
) -> AppResult<Option<ResolvedPath>> {
let mut conn = state.db()?; let mut conn = state.db_for_tenant(tenant.tenant_id)?;
let mut parent_id: Option<Uuid> = None; let mut parent_id: Option<Uuid> = None;
let mut chain: Vec<String> = Vec::new(); let mut chain: Vec<String> = vec![tenant.slug.clone()];
let mut current_folder: Option<Folder> = None; let mut current_folder: Option<Folder> = None;
if segments.is_empty() {
return Ok(Some(ResolvedPath::TenantRoot { chain }));
}
for (index, segment) in segments.iter().enumerate() { for (index, segment) in segments.iter().enumerate() {
let is_last = index == segments.len() - 1; let is_last = index == segments.len() - 1;
match find_folder_by_name(&mut conn, parent_id, segment)? { if let Some(folder) = find_folder_by_name(&mut conn, tenant.tenant_id, parent_id, segment)?
Some(folder) => { {
if is_last { chain.push(folder.name.clone());
chain.push(folder.name.clone()); if is_last {
return Ok(Some(ResolvedPath::Folder { folder, chain })); return Ok(Some(ResolvedPath::Folder { folder, chain }));
}
parent_id = Some(folder.id);
chain.push(folder.name.clone());
current_folder = Some(folder);
continue;
} }
None => {} parent_id = Some(folder.id);
current_folder = Some(folder);
continue;
} }
if is_last { if is_last {
if let Some((document, version)) = if let Some((document, version)) =
find_document_by_filename(&mut conn, parent_id, segment)? find_document_by_filename(&mut conn, tenant.tenant_id, parent_id, segment)?
{ {
chain.push(document.filename.clone()); chain.push(document.filename.clone());
return Ok(Some(ResolvedPath::Document { return Ok(Some(ResolvedPath::Document {
@@ -691,26 +773,22 @@ fn resolve_path(state: &AppState, segments: &[String]) -> AppResult<Option<Resol
} }
if let Ok(uuid) = Uuid::parse_str(segment) { if let Ok(uuid) = Uuid::parse_str(segment) {
if let Some(folder) = folders_dsl::folders if let Some(folder) = find_folder_by_id(&mut conn, tenant.tenant_id, uuid)? {
.find(uuid)
.first::<Folder>(&mut conn)
.optional()?
{
if folder.parent_id != parent_id { if folder.parent_id != parent_id {
return Ok(None); return Ok(None);
} }
if !is_last { chain.push(folder.name.clone());
parent_id = Some(folder.id); if is_last {
chain.push(folder.name.clone());
current_folder = Some(folder);
continue;
} else {
chain.push(folder.name.clone());
return Ok(Some(ResolvedPath::Folder { folder, chain })); return Ok(Some(ResolvedPath::Folder { folder, chain }));
} }
parent_id = Some(folder.id);
current_folder = Some(folder);
continue;
} }
if let Some((document, version)) = find_document_by_id(&mut conn, uuid)? { if let Some((document, version)) =
find_document_by_id(&mut conn, tenant.tenant_id, uuid)?
{
if document.folder_id != parent_id { if document.folder_id != parent_id {
return Ok(None); return Ok(None);
} }
@@ -723,19 +801,6 @@ fn resolve_path(state: &AppState, segments: &[String]) -> AppResult<Option<Resol
} }
} }
if is_last {
if let Some((document, version)) =
find_document_by_filename(&mut conn, parent_id, segment)?
{
chain.push(document.filename.clone());
return Ok(Some(ResolvedPath::Document {
document,
version,
chain,
}));
}
}
return Ok(None); return Ok(None);
} }
@@ -744,32 +809,46 @@ fn resolve_path(state: &AppState, segments: &[String]) -> AppResult<Option<Resol
fn find_folder_by_name( fn find_folder_by_name(
conn: &mut PgConnection, conn: &mut PgConnection,
tenant_id: Uuid,
parent_id: Option<Uuid>, parent_id: Option<Uuid>,
name: &str, name: &str,
) -> AppResult<Option<Folder>> { ) -> AppResult<Option<Folder>> {
let result = match parent_id { let mut query = folders_dsl::folders
Some(parent) => folders_dsl::folders .filter(folders_dsl::tenant_id.eq(tenant_id))
.filter(folders_dsl::parent_id.eq(Some(parent))) .into_boxed();
.filter(folders_dsl::name.eq(name))
.first::<Folder>(conn) query = match parent_id {
.optional()?, Some(parent) => query.filter(folders_dsl::parent_id.eq(Some(parent))),
None => folders_dsl::folders None => query.filter(folders_dsl::parent_id.is_null()),
.filter(folders_dsl::parent_id.is_null())
.filter(folders_dsl::name.eq(name))
.first::<Folder>(conn)
.optional()?,
}; };
Ok(result) Ok(query
.filter(folders_dsl::name.eq(name))
.first::<Folder>(conn)
.optional()?)
}
fn find_folder_by_id(
conn: &mut PgConnection,
tenant_id: Uuid,
folder_id: Uuid,
) -> AppResult<Option<Folder>> {
Ok(folders_dsl::folders
.filter(folders_dsl::tenant_id.eq(tenant_id))
.find(folder_id)
.first::<Folder>(conn)
.optional()?)
} }
fn find_document_by_filename( fn find_document_by_filename(
conn: &mut PgConnection, conn: &mut PgConnection,
tenant_id: Uuid,
parent_id: Option<Uuid>, parent_id: Option<Uuid>,
filename: &str, filename: &str,
) -> AppResult<Option<(Document, DocumentVersion)>> { ) -> AppResult<Option<(Document, DocumentVersion)>> {
let mut query = documents_dsl::documents let mut query = documents_dsl::documents
.filter(documents_dsl::deleted_at.is_null()) .filter(documents_dsl::deleted_at.is_null())
.filter(documents_dsl::tenant_id.eq(tenant_id))
.filter(documents_dsl::filename.eq(filename)) .filter(documents_dsl::filename.eq(filename))
.into_boxed(); .into_boxed();
@@ -790,10 +869,12 @@ fn find_document_by_filename(
fn find_document_by_id( fn find_document_by_id(
conn: &mut PgConnection, conn: &mut PgConnection,
tenant_id: Uuid,
document_id: Uuid, document_id: Uuid,
) -> AppResult<Option<(Document, DocumentVersion)>> { ) -> AppResult<Option<(Document, DocumentVersion)>> {
if let Some(document) = documents_dsl::documents if let Some(document) = documents_dsl::documents
.filter(documents_dsl::deleted_at.is_null()) .filter(documents_dsl::deleted_at.is_null())
.filter(documents_dsl::tenant_id.eq(tenant_id))
.find(document_id) .find(document_id)
.first::<Document>(conn) .first::<Document>(conn)
.optional()? .optional()?
+50 -2
View File
@@ -8,6 +8,7 @@ diesel::table! {
metadata -> Jsonb, metadata -> Jsonb,
created_at -> Timestamptz, created_at -> Timestamptz,
updated_at -> Timestamptz, updated_at -> Timestamptz,
tenant_id -> Uuid,
} }
} }
@@ -18,6 +19,7 @@ diesel::table! {
ordinal -> Int4, ordinal -> Int4,
s3_key -> Text, s3_key -> Text,
metadata -> Jsonb, metadata -> Jsonb,
tenant_id -> Uuid,
} }
} }
@@ -30,6 +32,7 @@ diesel::table! {
metadata -> Jsonb, metadata -> Jsonb,
created_at -> Timestamptz, created_at -> Timestamptz,
cardinality -> Nullable<Int4>, cardinality -> Nullable<Int4>,
tenant_id -> Uuid,
} }
} }
@@ -41,6 +44,7 @@ diesel::table! {
role -> Varchar, role -> Varchar,
assigned_at -> Timestamptz, assigned_at -> Timestamptz,
assigned_by -> Nullable<Uuid>, assigned_by -> Nullable<Uuid>,
tenant_id -> Uuid,
} }
} }
@@ -50,6 +54,7 @@ diesel::table! {
tag_id -> Uuid, tag_id -> Uuid,
assigned_at -> Timestamptz, assigned_at -> Timestamptz,
assigned_by -> Nullable<Uuid>, assigned_by -> Nullable<Uuid>,
tenant_id -> Uuid,
} }
} }
@@ -66,6 +71,7 @@ diesel::table! {
created_at -> Timestamptz, created_at -> Timestamptz,
operations_summary -> Jsonb, operations_summary -> Jsonb,
metadata -> Jsonb, metadata -> Jsonb,
tenant_id -> Uuid,
} }
} }
@@ -87,6 +93,7 @@ diesel::table! {
#[max_length = 255] #[max_length = 255]
title -> Varchar, title -> Varchar,
current_version_id -> Uuid, current_version_id -> Uuid,
tenant_id -> Uuid,
} }
} }
@@ -98,6 +105,7 @@ diesel::table! {
parent_id -> Nullable<Uuid>, parent_id -> Nullable<Uuid>,
created_at -> Timestamptz, created_at -> Timestamptz,
updated_at -> Timestamptz, updated_at -> Timestamptz,
tenant_id -> Uuid,
} }
} }
@@ -112,6 +120,7 @@ diesel::table! {
last_error -> Nullable<Text>, last_error -> Nullable<Text>,
created_at -> Timestamptz, created_at -> Timestamptz,
updated_at -> Timestamptz, updated_at -> Timestamptz,
tenant_id -> Uuid,
} }
} }
@@ -125,6 +134,7 @@ diesel::table! {
revoked_at -> Nullable<Timestamptz>, revoked_at -> Nullable<Timestamptz>,
created_at -> Timestamptz, created_at -> Timestamptz,
updated_at -> Timestamptz, updated_at -> Timestamptz,
tenant_id -> Uuid,
} }
} }
@@ -136,6 +146,31 @@ diesel::table! {
#[max_length = 7] #[max_length = 7]
color -> Nullable<Varchar>, color -> Nullable<Varchar>,
created_at -> Timestamptz, 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,
} }
} }
@@ -146,23 +181,34 @@ diesel::table! {
username -> Varchar, username -> Varchar,
#[max_length = 255] #[max_length = 255]
password_hash -> Varchar, password_hash -> Varchar,
#[max_length = 16]
role -> Varchar,
created_at -> Timestamptz, created_at -> Timestamptz,
updated_at -> Timestamptz, updated_at -> Timestamptz,
} }
} }
diesel::joinable!(correspondents -> tenants (tenant_id));
diesel::joinable!(document_asset_objects -> document_assets (asset_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 -> document_versions (document_version_id));
diesel::joinable!(document_assets -> tenants (tenant_id));
diesel::joinable!(document_correspondents -> correspondents (correspondent_id)); diesel::joinable!(document_correspondents -> correspondents (correspondent_id));
diesel::joinable!(document_correspondents -> documents (document_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_correspondents -> users (assigned_by));
diesel::joinable!(document_tags -> documents (document_id)); diesel::joinable!(document_tags -> documents (document_id));
diesel::joinable!(document_tags -> tags (tag_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_tags -> users (assigned_by));
diesel::joinable!(document_versions -> tenants (tenant_id));
diesel::joinable!(documents -> folders (folder_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!(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::allow_tables_to_appear_in_same_query!( diesel::allow_tables_to_appear_in_same_query!(
correspondents, correspondents,
@@ -176,5 +222,7 @@ diesel::allow_tables_to_appear_in_same_query!(
jobs, jobs,
refresh_tokens, refresh_tokens,
tags, tags,
tenants,
user_memberships,
users, users,
); );
+41 -5
View File
@@ -4,43 +4,79 @@ use diesel::{
pg::PgConnection, pg::PgConnection,
r2d2::{ConnectionManager, PooledConnection}, r2d2::{ConnectionManager, PooledConnection},
}; };
use uuid::Uuid;
use crate::{ use crate::{
auth::jwt::JwtService, auth::jwt::JwtService,
config::AppConfig, config::AppConfig,
db::PgPool, db::PgPool,
error::{AppError, AppResult}, error::{AppError, AppResult},
storage::ObjectStorage, storage::{ObjectStorage, TenantStorage},
tenants::{apply_tenant_guc, TenantService},
}; };
type PgPooledConnection = PooledConnection<ConnectionManager<PgConnection>>; pub type PgPooledConnection = PooledConnection<ConnectionManager<PgConnection>>;
#[derive(Clone)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
pub pool: PgPool, pub pool: PgPool,
pub config: Arc<AppConfig>, pub config: Arc<AppConfig>,
pub storage: Arc<dyn ObjectStorage>, storage: Arc<dyn ObjectStorage>,
pub jwt: JwtService, pub jwt: JwtService,
pub tenants: TenantService,
} }
impl AppState { 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( pub fn new(
pool: PgPool, pool: PgPool,
config: AppConfig, config: AppConfig,
storage: Arc<dyn ObjectStorage>, storage: Arc<dyn ObjectStorage>,
jwt: JwtService, jwt: JwtService,
) -> Self { ) -> Self {
let config = Arc::new(config);
let tenants = TenantService::new(pool.clone());
Self { Self {
pool, pool,
config: Arc::new(config), config,
storage, storage,
jwt, jwt,
tenants,
} }
} }
pub fn db(&self) -> AppResult<PgPooledConnection> { pub fn db_for_tenant(&self, tenant_id: Uuid) -> AppResult<PgPooledConnection> {
debug_assert!(!tenant_id.is_nil(), "nil tenant_id passed to db_for_tenant");
let mut conn = self.db_unscoped()?;
apply_tenant_guc(&mut conn, tenant_id)?;
Ok(conn)
}
pub(crate) fn db_unscoped(&self) -> AppResult<PgPooledConnection> {
self.pool self.pool
.get() .get()
.map_err(|err| AppError::internal(format!("database pool error: {err}"))) .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}")))
}
} }
+54 -1
View File
@@ -1,11 +1,15 @@
use std::time::Duration; use std::time::Duration;
use anyhow::{Context, Result}; use std::sync::Arc;
use anyhow::{anyhow, Context, Result};
use async_trait::async_trait; use async_trait::async_trait;
use aws_sdk_s3::presigning::PresigningConfig; use aws_sdk_s3::presigning::PresigningConfig;
use aws_sdk_s3::primitives::ByteStream; use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::Client as S3Client; use aws_sdk_s3::Client as S3Client;
use crate::models::Tenant;
#[async_trait] #[async_trait]
pub trait ObjectStorage: Send + Sync + 'static { pub trait ObjectStorage: Send + Sync + 'static {
async fn put_object( async fn put_object(
@@ -119,3 +123,52 @@ impl ObjectStorage for S3Storage {
Ok(()) 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
}
}
+91
View File
@@ -0,0 +1,91 @@
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 })
}
}
+14
View File
@@ -0,0 +1,14 @@
use std::sync::Arc;
use anyhow::Result;
use crate::{config::AppConfig, state::AppState, utils::tracing::init_tracing};
/// Initialize tracing, load configuration, and build the shared `AppState`.
/// Optionally override the connection pool size for lightweight components.
pub async fn init_component(name: &str, pool_override: Option<u32>) -> Result<Arc<AppState>> {
init_tracing("info");
let config = AppConfig::load_and_log(name)?;
let state = AppState::initialize(config, pool_override).await?;
Ok(Arc::new(state))
}
+59
View File
@@ -0,0 +1,59 @@
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)
}
+22
View File
@@ -0,0 +1,22 @@
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
))
}
+7
View File
@@ -1 +1,8 @@
pub mod bootstrap;
pub mod db;
pub mod http;
pub mod json; pub mod json;
pub mod storage_paths;
pub mod time;
pub mod tracing;
pub mod validation;
+115
View File
@@ -0,0 +1,115 @@
//! 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")
);
}
}
+13
View File
@@ -0,0 +1,13 @@
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()
}
+14
View File
@@ -0,0 +1,14 @@
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();
}
+10
View File
@@ -0,0 +1,10 @@
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")))
}
}
+23 -4
View File
@@ -14,6 +14,7 @@ use crate::{
models::{Document, DocumentAsset, DocumentVersion}, models::{Document, DocumentAsset, DocumentVersion},
schema::{document_assets, document_versions, documents}, schema::{document_assets, document_versions, documents},
state::AppState, state::AppState,
storage::TenantStorage,
}; };
use super::{JobExecution, JobHandler}; use super::{JobExecution, JobHandler};
@@ -40,7 +41,12 @@ impl JobHandler for AnalyzeDocumentJob {
JOB_ANALYZE_DOCUMENT JOB_ANALYZE_DOCUMENT
} }
async fn handle(&self, state: Arc<AppState>, job: crate::models::Job) -> JobExecution { async fn handle(
&self,
state: Arc<AppState>,
job: crate::models::Job,
_storage: TenantStorage,
) -> JobExecution {
let payload: AnalyzePayload = match serde_json::from_value(job.payload.clone()) { let payload: AnalyzePayload = match serde_json::from_value(job.payload.clone()) {
Ok(payload) => payload, Ok(payload) => payload,
Err(err) => { Err(err) => {
@@ -51,7 +57,9 @@ impl JobHandler for AnalyzeDocumentJob {
}; };
let state_clone = state.clone(); let state_clone = state.clone();
match task::spawn_blocking(move || analyze_document(state_clone, payload)).await { let tenant_id = job.tenant_id;
match task::spawn_blocking(move || analyze_document(state_clone, tenant_id, payload)).await
{
Ok(Ok(execution)) => execution, Ok(Ok(execution)) => execution,
Ok(Err(err)) => { Ok(Err(err)) => {
warn!(job_id = %job.id, error = %err, "analyze job will retry"); warn!(job_id = %job.id, error = %err, "analyze job will retry");
@@ -71,8 +79,14 @@ impl JobHandler for AnalyzeDocumentJob {
} }
} }
fn analyze_document(state: Arc<AppState>, payload: AnalyzePayload) -> Result<JobExecution, String> { fn analyze_document(
let mut conn = state.db().map_err(|err| format!("{err:?}"))?; 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:?}"))?;
let version: DocumentVersion = document_versions::table let version: DocumentVersion = document_versions::table
.find(payload.document_version_id) .find(payload.document_version_id)
@@ -88,12 +102,15 @@ fn analyze_document(state: Arc<AppState>, payload: AnalyzePayload) -> Result<Job
.first(&mut conn) .first(&mut conn)
.map_err(|err| format!("{err:?}"))?; .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 ocr_supported = document_is_pdf(&document);
let existing_ocr: Option<DocumentAsset> = document_assets::table let existing_ocr: Option<DocumentAsset> = document_assets::table
.filter(document_assets::document_version_id.eq(payload.document_version_id)) .filter(document_assets::document_version_id.eq(payload.document_version_id))
.filter(document_assets::asset_type.eq(OCR_TEXT_ASSET_TYPE)) .filter(document_assets::asset_type.eq(OCR_TEXT_ASSET_TYPE))
.filter(document_assets::tenant_id.eq(tenant_id))
.first(&mut conn) .first(&mut conn)
.optional() .optional()
.map_err(|err| format!("{err:?}"))?; .map_err(|err| format!("{err:?}"))?;
@@ -129,6 +146,7 @@ fn analyze_document(state: Arc<AppState>, payload: AnalyzePayload) -> Result<Job
if supported { if supported {
let enqueue_result = enqueue_job( let enqueue_result = enqueue_job(
&mut conn, &mut conn,
tenant_id,
JOB_GENERATE_THUMBNAILS, JOB_GENERATE_THUMBNAILS,
json!({ json!({
"document_id": payload.document_id, "document_id": payload.document_id,
@@ -146,6 +164,7 @@ fn analyze_document(state: Arc<AppState>, payload: AnalyzePayload) -> Result<Job
if ocr_supported && !skip_ocr { if ocr_supported && !skip_ocr {
let enqueue_result = enqueue_job( let enqueue_result = enqueue_job(
&mut conn, &mut conn,
tenant_id,
JOB_GENERATE_OCR_TEXT, JOB_GENERATE_OCR_TEXT,
json!({ json!({
"document_id": payload.document_id, "document_id": payload.document_id,
+40 -11
View File
@@ -15,6 +15,7 @@ use crate::{
models::{Document, DocumentVersion}, models::{Document, DocumentVersion},
schema::{document_asset_objects, document_assets, document_versions, documents}, schema::{document_asset_objects, document_assets, document_versions, documents},
state::AppState, state::AppState,
storage::TenantStorage,
}; };
use super::{ocr::OCR_TEXT_ASSET_TYPE, JobExecution, JobHandler}; use super::{ocr::OCR_TEXT_ASSET_TYPE, JobExecution, JobHandler};
@@ -39,7 +40,12 @@ impl JobHandler for IndexDocumentTextJob {
JOB_INDEX_DOCUMENT_TEXT JOB_INDEX_DOCUMENT_TEXT
} }
async fn handle(&self, state: Arc<AppState>, job: crate::models::Job) -> JobExecution { async fn handle(
&self,
state: Arc<AppState>,
job: crate::models::Job,
storage: TenantStorage,
) -> JobExecution {
let payload: IndexPayload = match serde_json::from_value(job.payload.clone()) { let payload: IndexPayload = match serde_json::from_value(job.payload.clone()) {
Ok(payload) => payload, Ok(payload) => payload,
Err(err) => { Err(err) => {
@@ -52,16 +58,29 @@ impl JobHandler for IndexDocumentTextJob {
let quickwit_endpoint = match &state.config.quickwit_endpoint { let quickwit_endpoint = match &state.config.quickwit_endpoint {
Some(endpoint) => endpoint.clone(), Some(endpoint) => endpoint.clone(),
None => { None => {
warn!("quickwit endpoint missing; skipping indexing"); return JobExecution::Failed {
return JobExecution::Success; error: "quickwit endpoint missing".into(),
};
} }
}; };
let quickwit_index = match &state.config.quickwit_index { let tenant = match state.tenants.get_by_id(job.tenant_id) {
Some(index) => index.clone(), 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,
None => { None => {
warn!("quickwit index missing; skipping indexing"); return JobExecution::Failed {
return JobExecution::Success; error: "tenant quickwit index not configured".into(),
};
} }
}; };
@@ -95,7 +114,7 @@ impl JobHandler for IndexDocumentTextJob {
} }
let s3_key = context.text_s3_key.unwrap(); let s3_key = context.text_s3_key.unwrap();
let text = match state.storage.get_object(&s3_key).await { let text = match storage.get_object(&s3_key).await {
Ok(bytes) => match String::from_utf8(bytes) { Ok(bytes) => match String::from_utf8(bytes) {
Ok(text) => text, Ok(text) => text,
Err(err) => { Err(err) => {
@@ -129,6 +148,7 @@ impl JobHandler for IndexDocumentTextJob {
let payload = json!({ let payload = json!({
"document_id": context.document.id, "document_id": context.document.id,
"version_id": context.version.id, "version_id": context.version.id,
"tenant_id": job.tenant_id,
"title": context.document.title.to_lowercase(), "title": context.document.title.to_lowercase(),
"text": text.to_lowercase() "text": text.to_lowercase()
}); });
@@ -173,11 +193,11 @@ struct IndexContext {
} }
fn load_context(state: Arc<AppState>, payload: &IndexPayload) -> Result<IndexContext, String> { fn load_context(state: Arc<AppState>, payload: &IndexPayload) -> Result<IndexContext, String> {
let mut conn = state.db().map_err(|err| format!("{err:?}"))?; let mut base_conn = state.db_unscoped().map_err(|err| format!("{err:?}"))?;
let version: DocumentVersion = document_versions::table let version: DocumentVersion = document_versions::table
.find(payload.document_version_id) .find(payload.document_version_id)
.first(&mut conn) .first(&mut base_conn)
.map_err(|err| format!("{err:?}"))?; .map_err(|err| format!("{err:?}"))?;
if version.document_id != payload.document_id { if version.document_id != payload.document_id {
@@ -186,7 +206,14 @@ fn load_context(state: Arc<AppState>, payload: &IndexPayload) -> Result<IndexCon
let document: Document = documents::table let document: Document = documents::table
.find(payload.document_id) .find(payload.document_id)
.first(&mut conn) .first(&mut base_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:?}"))?; .map_err(|err| format!("{err:?}"))?;
let text_s3_key: Option<String> = document_asset_objects::table let text_s3_key: Option<String> = document_asset_objects::table
@@ -195,7 +222,9 @@ fn load_context(state: Arc<AppState>, payload: &IndexPayload) -> Result<IndexCon
) )
.filter(document_assets::document_version_id.eq(payload.document_version_id)) .filter(document_assets::document_version_id.eq(payload.document_version_id))
.filter(document_assets::asset_type.eq(OCR_TEXT_ASSET_TYPE)) .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::ordinal.eq(1))
.filter(document_asset_objects::tenant_id.eq(tenant_id))
.select(document_asset_objects::s3_key) .select(document_asset_objects::s3_key)
.first(&mut conn) .first(&mut conn)
.optional() .optional()
+21 -8
View File
@@ -8,6 +8,7 @@ use crate::{
jobs::{mark_job_failed, mark_job_succeeded, reserve_job, retry_job_after, JobQueueError}, jobs::{mark_job_failed, mark_job_succeeded, reserve_job, retry_job_after, JobQueueError},
models::Job, models::Job,
state::AppState, state::AppState,
storage::TenantStorage,
}; };
pub mod analyze; pub mod analyze;
@@ -25,7 +26,7 @@ pub enum JobExecution {
#[async_trait] #[async_trait]
pub trait JobHandler: Send + Sync { pub trait JobHandler: Send + Sync {
fn job_type(&self) -> &'static str; fn job_type(&self) -> &'static str;
async fn handle(&self, state: Arc<AppState>, job: Job) -> JobExecution; async fn handle(&self, state: Arc<AppState>, job: Job, storage: TenantStorage) -> JobExecution;
} }
pub struct Worker { pub struct Worker {
@@ -71,7 +72,7 @@ impl Worker {
return Ok(false); return Ok(false);
} }
let mut conn = match self.state.db() { let mut conn = match self.state.db_unscoped() {
Ok(conn) => conn, Ok(conn) => conn,
Err(err) => { Err(err) => {
error!(?err, "failed to obtain database connection in worker"); error!(?err, "failed to obtain database connection in worker");
@@ -84,10 +85,22 @@ impl Worker {
if let Some(job) = job_opt { if let Some(job) = job_opt {
if let Some(handler) = self.handlers.get(job.job_type.as_str()) { if let Some(handler) = self.handlers.get(job.job_type.as_str()) {
let result = handler.handle(self.state.clone(), job.clone()).await; let execution = match self.state.storage_for_tenant(job.tenant_id) {
match result { 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 {
JobExecution::Success => { JobExecution::Success => {
if let Ok(mut conn) = self.state.db() { if let Ok(mut conn) = self.state.db_unscoped() {
mark_job_succeeded(&mut conn, job.id)?; mark_job_succeeded(&mut conn, job.id)?;
info!(job_id = %job.id, job_type = %job.job_type, "job completed successfully"); info!(job_id = %job.id, job_type = %job.job_type, "job completed successfully");
} else { } else {
@@ -96,7 +109,7 @@ impl Worker {
} }
JobExecution::Retry { delay, error } => { JobExecution::Retry { delay, error } => {
warn!(job_id = %job.id, job_type = %job.job_type, %error, "job will retry"); warn!(job_id = %job.id, job_type = %job.job_type, %error, "job will retry");
if let Ok(mut conn) = self.state.db() { if let Ok(mut conn) = self.state.db_unscoped() {
retry_job_after(&mut conn, job.id, delay, &error)?; retry_job_after(&mut conn, job.id, delay, &error)?;
} else { } else {
error!("failed to requeue job for retry due to pool error"); error!("failed to requeue job for retry due to pool error");
@@ -104,7 +117,7 @@ impl Worker {
} }
JobExecution::Failed { error } => { JobExecution::Failed { error } => {
error!(job_id = %job.id, job_type = %job.job_type, %error, "job failed"); error!(job_id = %job.id, job_type = %job.job_type, %error, "job failed");
if let Ok(mut conn) = self.state.db() { if let Ok(mut conn) = self.state.db_unscoped() {
mark_job_failed(&mut conn, job.id, &error)?; mark_job_failed(&mut conn, job.id, &error)?;
} else { } else {
error!("failed to mark job failed due to pool error"); error!("failed to mark job failed due to pool error");
@@ -113,7 +126,7 @@ impl Worker {
} }
} else { } else {
error!(job_type = %job.job_type, "no handler registered for job type"); error!(job_type = %job.job_type, "no handler registered for job type");
if let Ok(mut conn) = self.state.db() { if let Ok(mut conn) = self.state.db_unscoped() {
mark_job_failed(&mut conn, job.id, "no handler registered")?; mark_job_failed(&mut conn, job.id, "no handler registered")?;
} else { } else {
error!("failed to mark job failed for missing handler due to pool error"); error!("failed to mark job failed for missing handler due to pool error");
+49 -18
View File
@@ -25,6 +25,8 @@ use crate::{
}, },
schema::{document_asset_objects, document_assets, document_versions, documents}, schema::{document_asset_objects, document_assets, document_versions, documents},
state::AppState, state::AppState,
storage::TenantStorage,
utils::storage_paths::document_asset_object_prefix,
}; };
use super::{JobExecution, JobHandler}; use super::{JobExecution, JobHandler};
@@ -54,7 +56,12 @@ impl JobHandler for GenerateOcrTextJob {
JOB_GENERATE_OCR_TEXT JOB_GENERATE_OCR_TEXT
} }
async fn handle(&self, state: Arc<AppState>, job: crate::models::Job) -> JobExecution { async fn handle(
&self,
state: Arc<AppState>,
job: crate::models::Job,
storage: TenantStorage,
) -> JobExecution {
let payload: OcrPayload = match serde_json::from_value(job.payload.clone()) { let payload: OcrPayload = match serde_json::from_value(job.payload.clone()) {
Ok(payload) => payload, Ok(payload) => payload,
Err(err) => { Err(err) => {
@@ -91,7 +98,7 @@ impl JobHandler for GenerateOcrTextJob {
return JobExecution::Success; return JobExecution::Success;
} }
let bytes = match state.storage.get_object(&context.version.s3_key).await { let bytes = match storage.get_object(&context.version.s3_key).await {
Ok(bytes) => bytes, Ok(bytes) => bytes,
Err(err) => { Err(err) => {
warn!(job_id = %job.id, error = %err, "failed to fetch document for ocr"); warn!(job_id = %job.id, error = %err, "failed to fetch document for ocr");
@@ -128,7 +135,7 @@ impl JobHandler for GenerateOcrTextJob {
if context.existing_asset.is_some() { if context.existing_asset.is_some() {
for object in &context.existing_objects { for object in &context.existing_objects {
if let Err(err) = state.storage.delete_object(&object.s3_key).await { 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"); warn!(job_id = %job.id, error = %err, s3_key = %object.s3_key, "failed to delete existing ocr asset object");
} }
} }
@@ -136,13 +143,14 @@ impl JobHandler for GenerateOcrTextJob {
let asset_id = Uuid::new_v4(); let asset_id = Uuid::new_v4();
let s3_key = format!( let s3_key = document_asset_object_prefix(
"documents/{}/v{}/assets/{}/{}", context.document.id,
context.document.id, context.version.version_number, OCR_TEXT_ASSET_TYPE, asset_id context.version.version_number,
OCR_TEXT_ASSET_TYPE,
asset_id,
); );
if let Err(err) = state if let Err(err) = storage
.storage
.put_object( .put_object(
&s3_key, &s3_key,
generation.text.into_bytes(), generation.text.into_bytes(),
@@ -165,11 +173,8 @@ impl JobHandler for GenerateOcrTextJob {
.await .await
{ {
Ok(Ok(())) => { Ok(Ok(())) => {
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");
if let Err(err) = enqueue_index_job(&state, &payload) {
warn!(job_id = %job.id, error = %err, "failed to enqueue index job");
}
} }
JobExecution::Success JobExecution::Success
} }
@@ -210,11 +215,11 @@ struct OcrGeneration {
} }
fn load_ocr_context(state: Arc<AppState>, payload: &OcrPayload) -> Result<OcrContext, String> { fn load_ocr_context(state: Arc<AppState>, payload: &OcrPayload) -> Result<OcrContext, String> {
let mut conn = state.db().map_err(|err| format!("{err:?}"))?; let mut base_conn = state.db_unscoped().map_err(|err| format!("{err:?}"))?;
let version: DocumentVersion = document_versions::table let version: DocumentVersion = document_versions::table
.find(payload.document_version_id) .find(payload.document_version_id)
.first(&mut conn) .first(&mut base_conn)
.map_err(|err| format!("{err:?}"))?; .map_err(|err| format!("{err:?}"))?;
if version.document_id != payload.document_id { if version.document_id != payload.document_id {
@@ -223,12 +228,20 @@ fn load_ocr_context(state: Arc<AppState>, payload: &OcrPayload) -> Result<OcrCon
let document: Document = documents::table let document: Document = documents::table
.find(payload.document_id) .find(payload.document_id)
.first(&mut conn) .first(&mut base_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:?}"))?; .map_err(|err| format!("{err:?}"))?;
let existing_asset: Option<DocumentAsset> = document_assets::table let existing_asset: Option<DocumentAsset> = document_assets::table
.filter(document_assets::document_version_id.eq(payload.document_version_id)) .filter(document_assets::document_version_id.eq(payload.document_version_id))
.filter(document_assets::asset_type.eq(OCR_TEXT_ASSET_TYPE)) .filter(document_assets::asset_type.eq(OCR_TEXT_ASSET_TYPE))
.filter(document_assets::tenant_id.eq(tenant_id))
.first(&mut conn) .first(&mut conn)
.optional() .optional()
.map_err(|err| format!("{err:?}"))?; .map_err(|err| format!("{err:?}"))?;
@@ -236,6 +249,7 @@ fn load_ocr_context(state: Arc<AppState>, payload: &OcrPayload) -> Result<OcrCon
let existing_objects: Vec<DocumentAssetObject> = if let Some(asset) = &existing_asset { let existing_objects: Vec<DocumentAssetObject> = if let Some(asset) = &existing_asset {
document_asset_objects::table document_asset_objects::table
.filter(document_asset_objects::asset_id.eq(asset.id)) .filter(document_asset_objects::asset_id.eq(asset.id))
.filter(document_asset_objects::tenant_id.eq(tenant_id))
.order(document_asset_objects::ordinal.asc()) .order(document_asset_objects::ordinal.asc())
.load(&mut conn) .load(&mut conn)
.map_err(|err| format!("{err:?}"))? .map_err(|err| format!("{err:?}"))?
@@ -389,7 +403,10 @@ fn persist_ocr_metadata(
s3_key: &str, s3_key: &str,
source: &'static str, source: &'static str,
) -> Result<(), String> { ) -> Result<(), String> {
let mut conn = state.db().map_err(|err| format!("{err:?}"))?; 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 { if let Some(existing_asset) = &context.existing_asset {
diesel::delete(document_assets::table.filter(document_assets::id.eq(existing_asset.id))) diesel::delete(document_assets::table.filter(document_assets::id.eq(existing_asset.id)))
@@ -407,6 +424,7 @@ fn persist_ocr_metadata(
"source": source, "source": source,
}), }),
cardinality: Some(1), cardinality: Some(1),
tenant_id,
}; };
diesel::insert_into(document_assets::table) diesel::insert_into(document_assets::table)
@@ -427,6 +445,7 @@ fn persist_ocr_metadata(
let existing_object_id: Option<Uuid> = document_asset_objects::table let existing_object_id: Option<Uuid> = document_asset_objects::table
.filter(document_asset_objects::asset_id.eq(asset_id)) .filter(document_asset_objects::asset_id.eq(asset_id))
.filter(document_asset_objects::ordinal.eq(1)) .filter(document_asset_objects::ordinal.eq(1))
.filter(document_asset_objects::tenant_id.eq(tenant_id))
.select(document_asset_objects::id) .select(document_asset_objects::id)
.first(&mut conn) .first(&mut conn)
.optional() .optional()
@@ -440,6 +459,7 @@ fn persist_ocr_metadata(
ordinal: 1, ordinal: 1,
s3_key: s3_key.to_string(), s3_key: s3_key.to_string(),
metadata: json!({}), metadata: json!({}),
tenant_id,
}; };
diesel::insert_into(document_asset_objects::table) diesel::insert_into(document_asset_objects::table)
@@ -460,9 +480,20 @@ fn persist_ocr_metadata(
} }
fn enqueue_index_job(state: &AppState, payload: &OcrPayload) -> Result<(), String> { fn enqueue_index_job(state: &AppState, payload: &OcrPayload) -> Result<(), String> {
let mut conn = state.db().map_err(|err| format!("{err:?}"))?; 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:?}"))?;
enqueue_job( enqueue_job(
&mut conn, &mut conn,
tenant_id,
JOB_INDEX_DOCUMENT_TEXT, JOB_INDEX_DOCUMENT_TEXT,
json!({ json!({
"document_id": payload.document_id, "document_id": payload.document_id,
+276 -150
View File
@@ -19,6 +19,8 @@ use crate::{
}, },
schema::{document_asset_objects, document_assets, document_versions, documents}, schema::{document_asset_objects, document_assets, document_versions, documents},
state::AppState, state::AppState,
storage::TenantStorage,
utils::storage_paths::document_asset_object_key,
}; };
use super::{analyze::determine_thumbnail_support, JobExecution, JobHandler}; use super::{analyze::determine_thumbnail_support, JobExecution, JobHandler};
@@ -52,7 +54,12 @@ impl JobHandler for GenerateThumbnailsJob {
JOB_GENERATE_THUMBNAILS JOB_GENERATE_THUMBNAILS
} }
async fn handle(&self, state: Arc<AppState>, job: crate::models::Job) -> JobExecution { async fn handle(
&self,
state: Arc<AppState>,
job: crate::models::Job,
storage: TenantStorage,
) -> JobExecution {
let payload: ThumbnailPayload = match serde_json::from_value(job.payload.clone()) { let payload: ThumbnailPayload = match serde_json::from_value(job.payload.clone()) {
Ok(p) => p, Ok(p) => p,
Err(err) => { Err(err) => {
@@ -88,7 +95,7 @@ impl JobHandler for GenerateThumbnailsJob {
return JobExecution::Success; return JobExecution::Success;
} }
let bytes = match state.storage.get_object(&initial.version.s3_key).await { let bytes = match storage.get_object(&initial.version.s3_key).await {
Ok(bytes) => bytes, Ok(bytes) => bytes,
Err(err) => { Err(err) => {
warn!(job_id = %job.id, error = %err, "thumbnail fetch failed; will retry"); warn!(job_id = %job.id, error = %err, "thumbnail fetch failed; will retry");
@@ -147,7 +154,7 @@ impl JobHandler for GenerateThumbnailsJob {
if initial.existing_preview.is_some() { if initial.existing_preview.is_some() {
for object in &initial.existing_preview_objects { for object in &initial.existing_preview_objects {
if let Err(err) = state.storage.delete_object(&object.s3_key).await { if let Err(err) = storage.delete_object(&object.s3_key).await {
warn!( warn!(
job_id = %job.id, job_id = %job.id,
error = %err, error = %err,
@@ -160,7 +167,7 @@ impl JobHandler for GenerateThumbnailsJob {
if initial.existing_thumbnail.is_some() { if initial.existing_thumbnail.is_some() {
for object in &initial.existing_thumbnail_objects { for object in &initial.existing_thumbnail_objects {
if let Err(err) = state.storage.delete_object(&object.s3_key).await { if let Err(err) = storage.delete_object(&object.s3_key).await {
warn!( warn!(
job_id = %job.id, job_id = %job.id,
error = %err, error = %err,
@@ -171,78 +178,108 @@ impl JobHandler for GenerateThumbnailsJob {
} }
} }
let thumbnail_asset_id = 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 preview_asset_id = Uuid::new_v4(); let preview_asset_id = 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 let thumbnail_asset_id = Uuid::new_v4();
.storage
.put_object( let mut preview_objects: Vec<AssetObjectPersistence> =
&preview_s3_key, Vec::with_capacity(generation.preview.objects.len());
generation.preview.image_bytes.clone(), for (index, image) in generation.preview.objects.iter().enumerate() {
Some("image/png".into()), if index + 1 > i32::MAX as usize {
None, return JobExecution::Failed {
) error: "too many preview objects".to_string(),
.await };
{ }
warn!(job_id = %job.id, error = %err, "failed to upload preview; retrying"); let ordinal = (index + 1) as i32;
return JobExecution::Retry { let s3_key = document_asset_object_key(
delay: Duration::from_secs(30), initial.document.id,
error: err.to_string(), 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,
});
} }
if let Err(err) = state let mut thumbnail_objects: Vec<AssetObjectPersistence> =
.storage Vec::with_capacity(generation.thumbnail.objects.len());
.put_object( for (index, image) in generation.thumbnail.objects.iter().enumerate() {
&thumbnail_s3_key, if index + 1 > i32::MAX as usize {
generation.thumbnail.image_bytes.clone(), return JobExecution::Failed {
Some("image/png".into()), error: "too many thumbnail objects".to_string(),
None, };
) }
.await let ordinal = (index + 1) as i32;
{ let s3_key = document_asset_object_key(
warn!(job_id = %job.id, error = %err, "failed to upload thumbnail; retrying"); initial.document.id,
return JobExecution::Retry { initial.version.version_number,
delay: Duration::from_secs(30), THUMBNAIL_ASSET_TYPE,
error: err.to_string(), 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(); let state_clone = state.clone();
match task::spawn_blocking(move || { match task::spawn_blocking(move || {
persist_assets_metadata( persist_assets_metadata(state_clone, &initial, &asset_persistences)
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 .await
{ {
@@ -283,24 +320,34 @@ struct GeneratedImage {
height: Option<i32>, height: Option<i32>,
} }
struct GeneratedAsset {
objects: Vec<GeneratedImage>,
}
struct GeneratedAssets { struct GeneratedAssets {
thumbnail: GeneratedImage, thumbnail: GeneratedAsset,
preview: GeneratedImage, preview: GeneratedAsset,
page_count: Option<u32>, page_count: Option<u32>,
} }
struct AssetPersistence<'a> { struct AssetObjectPersistence {
ordinal: i32,
s3_key: String,
width: Option<i32>,
height: Option<i32>,
}
struct AssetPersistence {
asset_type: &'static str, asset_type: &'static str,
asset_id: Uuid, asset_id: Uuid,
s3_key: &'a str, objects: Vec<AssetObjectPersistence>,
generated: &'a GeneratedImage,
} }
fn load_thumbnail_context( fn load_thumbnail_context(
state: Arc<AppState>, state: Arc<AppState>,
payload: &ThumbnailPayload, payload: &ThumbnailPayload,
) -> Result<ThumbnailContext, String> { ) -> Result<ThumbnailContext, String> {
let mut conn = state.db().map_err(|err| format!("{err:?}"))?; let mut conn = state.db_unscoped().map_err(|err| format!("{err:?}"))?;
let version: DocumentVersion = document_versions::table let version: DocumentVersion = document_versions::table
.find(payload.document_version_id) .find(payload.document_version_id)
@@ -316,12 +363,15 @@ fn load_thumbnail_context(
.first(&mut conn) .first(&mut conn)
.map_err(|err| format!("{err:?}"))?; .map_err(|err| format!("{err:?}"))?;
let tenant_id = document.tenant_id;
let existing_assets: Vec<DocumentAsset> = document_assets::table let existing_assets: Vec<DocumentAsset> = document_assets::table
.filter(document_assets::document_version_id.eq(payload.document_version_id)) .filter(document_assets::document_version_id.eq(payload.document_version_id))
.filter(document_assets::asset_type.eq_any(vec![ .filter(document_assets::asset_type.eq_any(vec![
THUMBNAIL_ASSET_TYPE.to_string(), THUMBNAIL_ASSET_TYPE.to_string(),
PREVIEW_ASSET_TYPE.to_string(), PREVIEW_ASSET_TYPE.to_string(),
])) ]))
.filter(document_assets::tenant_id.eq(tenant_id))
.load(&mut conn) .load(&mut conn)
.map_err(|err| format!("{err:?}"))?; .map_err(|err| format!("{err:?}"))?;
@@ -334,6 +384,7 @@ fn load_thumbnail_context(
THUMBNAIL_ASSET_TYPE => { THUMBNAIL_ASSET_TYPE => {
existing_thumbnail_objects = document_asset_objects::table existing_thumbnail_objects = document_asset_objects::table
.filter(document_asset_objects::asset_id.eq(asset.id)) .filter(document_asset_objects::asset_id.eq(asset.id))
.filter(document_asset_objects::tenant_id.eq(tenant_id))
.order(document_asset_objects::ordinal.asc()) .order(document_asset_objects::ordinal.asc())
.load(&mut conn) .load(&mut conn)
.map_err(|err| format!("{err:?}"))?; .map_err(|err| format!("{err:?}"))?;
@@ -342,6 +393,7 @@ fn load_thumbnail_context(
PREVIEW_ASSET_TYPE => { PREVIEW_ASSET_TYPE => {
existing_preview_objects = document_asset_objects::table existing_preview_objects = document_asset_objects::table
.filter(document_asset_objects::asset_id.eq(asset.id)) .filter(document_asset_objects::asset_id.eq(asset.id))
.filter(document_asset_objects::tenant_id.eq(tenant_id))
.order(document_asset_objects::ordinal.asc()) .order(document_asset_objects::ordinal.asc())
.load(&mut conn) .load(&mut conn)
.map_err(|err| format!("{err:?}"))?; .map_err(|err| format!("{err:?}"))?;
@@ -356,7 +408,25 @@ fn load_thumbnail_context(
return Err("thumbnail generation not supported for this document".into()); return Err("thumbnail generation not supported for this document".into());
} }
let skip = existing_thumbnail.is_some() && existing_preview.is_some() && !payload.force; 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;
Ok(ThumbnailContext { Ok(ThumbnailContext {
document, document,
@@ -373,18 +443,7 @@ fn generate_preview_and_thumbnail(
document: &Document, document: &Document,
bytes: &[u8], bytes: &[u8],
) -> Result<GeneratedAssets, String> { ) -> Result<GeneratedAssets, String> {
let is_pdf = document let is_pdf = document_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 { if is_pdf {
let pdf_assets = generate_pdf_assets(bytes)?; let pdf_assets = generate_pdf_assets(bytes)?;
@@ -403,7 +462,7 @@ fn generate_preview_and_thumbnail(
} }
} }
fn generate_image_assets(bytes: &[u8]) -> Result<(GeneratedImage, GeneratedImage), String> { fn generate_image_assets(bytes: &[u8]) -> Result<(GeneratedAsset, GeneratedAsset), String> {
let reader = ImageReader::new(Cursor::new(bytes)) let reader = ImageReader::new(Cursor::new(bytes))
.with_guessed_format() .with_guessed_format()
.map_err(|err| err.to_string())?; .map_err(|err| err.to_string())?;
@@ -425,12 +484,19 @@ fn generate_image_assets(bytes: &[u8]) -> Result<(GeneratedImage, GeneratedImage
let preview = encode_dynamic_image(preview_image)?; let preview = encode_dynamic_image(preview_image)?;
let thumbnail = encode_dynamic_image(thumbnail_image)?; let thumbnail = encode_dynamic_image(thumbnail_image)?;
Ok((preview, thumbnail)) Ok((
GeneratedAsset {
objects: vec![preview],
},
GeneratedAsset {
objects: vec![thumbnail],
},
))
} }
struct PdfGeneratedAssets { struct PdfGeneratedAssets {
preview: GeneratedImage, preview: GeneratedAsset,
thumbnail: GeneratedImage, thumbnail: GeneratedAsset,
page_count: u32, page_count: u32,
} }
@@ -443,11 +509,7 @@ fn generate_pdf_assets(bytes: &[u8]) -> Result<PdfGeneratedAssets, String> {
.map_err(|err| format!("load pdf: {err}"))?; .map_err(|err| format!("load pdf: {err}"))?;
let pages = document.pages(); let pages = document.pages();
let total_pages = pages.len(); let total_pages = pages.len() as usize;
let page = pages
.get(0)
.map_err(|err| format!("load first page: {err}"))?;
let render_config = PdfRenderConfig::new() let render_config = PdfRenderConfig::new()
.set_target_width(PREVIEW_WIDTH as i32) .set_target_width(PREVIEW_WIDTH as i32)
@@ -455,30 +517,44 @@ fn generate_pdf_assets(bytes: &[u8]) -> Result<PdfGeneratedAssets, String> {
.render_form_data(true) .render_form_data(true)
.rotate_if_landscape(PdfPageRenderRotation::None, true); .rotate_if_landscape(PdfPageRenderRotation::None, true);
let bitmap = page let mut preview_objects: Vec<GeneratedImage> = Vec::with_capacity(total_pages);
.render_with_config(&render_config) let mut thumbnail_objects: Vec<GeneratedImage> = Vec::with_capacity(total_pages);
.map_err(|err| format!("render pdf page: {err}"))?;
let preview_buffer = bitmap.as_image().to_rgb8(); for page_index in 0..total_pages {
let preview_image = image::DynamicImage::ImageRgb8(preview_buffer); 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 thumbnail_image = let bitmap = page
if preview_image.width() > THUMBNAIL_WIDTH || preview_image.height() > THUMBNAIL_HEIGHT { .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
{
preview_image.thumbnail(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT) preview_image.thumbnail(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT)
} else { } else {
preview_image.clone() preview_image.clone()
}; };
let preview = encode_dynamic_image(preview_image)?; preview_objects.push(encode_dynamic_image(preview_image)?);
let thumbnail = encode_dynamic_image(thumbnail_image)?; thumbnail_objects.push(encode_dynamic_image(thumbnail_image)?);
}
let page_count: u32 = total_pages let page_count: u32 = total_pages
.try_into() .try_into()
.map_err(|_| "page count exceeds supported range".to_string())?; .map_err(|_| "page count exceeds supported range".to_string())?;
Ok(PdfGeneratedAssets { Ok(PdfGeneratedAssets {
preview, preview: GeneratedAsset {
thumbnail, objects: preview_objects,
},
thumbnail: GeneratedAsset {
objects: thumbnail_objects,
},
page_count, page_count,
}) })
} }
@@ -499,9 +575,12 @@ fn encode_dynamic_image(image: image::DynamicImage) -> Result<GeneratedImage, St
fn persist_assets_metadata( fn persist_assets_metadata(
state: Arc<AppState>, state: Arc<AppState>,
context: &ThumbnailContext, context: &ThumbnailContext,
assets: &[AssetPersistence<'_>], assets: &[AssetPersistence],
) -> Result<(), String> { ) -> Result<(), String> {
let mut conn = state.db().map_err(|err| format!("{err:?}"))?; 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 { if let Some(existing_preview) = &context.existing_preview {
diesel::delete(document_assets::table.filter(document_assets::id.eq(existing_preview.id))) diesel::delete(document_assets::table.filter(document_assets::id.eq(existing_preview.id)))
@@ -518,6 +597,19 @@ fn persist_assets_metadata(
} }
for asset in assets { 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 { let new_asset = NewDocumentAsset {
id: asset.asset_id, id: asset.asset_id,
document_version_id: context.version.id, document_version_id: context.version.id,
@@ -526,7 +618,8 @@ fn persist_assets_metadata(
metadata: json!({ metadata: json!({
"generated_at": Utc::now().to_rfc3339(), "generated_at": Utc::now().to_rfc3339(),
}), }),
cardinality: Some(1), cardinality: Some(object_count),
tenant_id,
}; };
diesel::insert_into(document_assets::table) diesel::insert_into(document_assets::table)
@@ -544,47 +637,39 @@ fn persist_assets_metadata(
.execute(&mut conn) .execute(&mut conn)
.map_err(|err| format!("{err:?}"))?; .map_err(|err| format!("{err:?}"))?;
let existing_object_id: Option<Uuid> = document_asset_objects::table diesel::delete(
.filter(document_asset_objects::asset_id.eq(asset.asset_id)) document_asset_objects::table
.filter(document_asset_objects::ordinal.eq(1)) .filter(document_asset_objects::asset_id.eq(asset.asset_id))
.select(document_asset_objects::id) .filter(document_asset_objects::tenant_id.eq(tenant_id)),
.first(&mut conn) )
.optional() .execute(&mut conn)
.map_err(|err| format!("{err:?}"))?; .map_err(|err| format!("{err:?}"))?;
let object_id = existing_object_id.unwrap_or_else(Uuid::new_v4); 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 mut metadata_map = Map::new(); let object_metadata = Value::Object(metadata_map);
if let Some(width) = asset.generated.width {
metadata_map.insert("width".to_string(), Value::from(width)); 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:?}"))?;
} }
if let Some(height) = asset.generated.height {
metadata_map.insert("height".to_string(), Value::from(height));
}
let object_metadata = Value::Object(metadata_map);
let new_object = NewDocumentAssetObject {
id: object_id,
asset_id: asset.asset_id,
ordinal: 1,
s3_key: asset.s3_key.to_string(),
metadata: object_metadata,
};
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:?}"))?;
} }
Ok(()) Ok(())
@@ -596,11 +681,17 @@ fn persist_document_page_count(
document_version_id: Uuid, document_version_id: Uuid,
page_count: u32, page_count: u32,
) -> Result<(), String> { ) -> Result<(), String> {
let mut conn = state.db().map_err(|err| format!("{err:?}"))?; 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 existing_metadata: Value = document_versions::table let existing_metadata: Value = document_versions::table
.filter(document_versions::id.eq(document_version_id)) .filter(document_versions::id.eq(document_version_id))
.filter(document_versions::document_id.eq(document_id)) .filter(document_versions::document_id.eq(document_id))
.filter(document_versions::tenant_id.eq(tenant_id))
.select(document_versions::metadata) .select(document_versions::metadata)
.first(&mut conn) .first(&mut conn)
.map_err(|err| format!("{err:?}"))?; .map_err(|err| format!("{err:?}"))?;
@@ -620,7 +711,8 @@ fn persist_document_page_count(
diesel::update( diesel::update(
document_versions::table document_versions::table
.filter(document_versions::id.eq(document_version_id)) .filter(document_versions::id.eq(document_version_id))
.filter(document_versions::document_id.eq(document_id)), .filter(document_versions::document_id.eq(document_id))
.filter(document_versions::tenant_id.eq(tenant_id)),
) )
.set(document_versions::metadata.eq(updated)) .set(document_versions::metadata.eq(updated))
.execute(&mut conn) .execute(&mut conn)
@@ -628,3 +720,37 @@ fn persist_document_page_count(
Ok(()) 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
}
}
-2
View File
@@ -8,7 +8,6 @@ use serde::Deserialize;
#[derive(Deserialize)] #[derive(Deserialize)]
struct AuthenticatedUser { struct AuthenticatedUser {
username: String, username: String,
role: String,
} }
#[tokio::test] #[tokio::test]
@@ -27,7 +26,6 @@ async fn login_and_me_roundtrip() -> Result<()> {
let user: AuthenticatedUser = serde_json::from_slice(&body)?; let user: AuthenticatedUser = serde_json::from_slice(&body)?;
assert_eq!(user.username, "alice"); assert_eq!(user.username, "alice");
assert_eq!(user.role, "admin");
app.cleanup().await?; app.cleanup().await?;
Ok(()) Ok(())
+203 -10
View File
@@ -11,18 +11,20 @@ use axum::Router;
use backend::auth::jwt::JwtService; use backend::auth::jwt::JwtService;
use backend::config::AppConfig; use backend::config::AppConfig;
use backend::db::{self, PgPool}; use backend::db::{self, PgPool};
use backend::models::{Job, NewUser}; use backend::models::{Job, NewUser, NewUserMembership, Tenant};
use backend::routes; use backend::routes;
use backend::state::AppState; use backend::state::AppState;
use backend::storage::ObjectStorage; use backend::storage::ObjectStorage;
use diesel::connection::SimpleConnection; use diesel::connection::SimpleConnection;
use diesel::prelude::*; use diesel::prelude::*;
use diesel::OptionalExtension;
use diesel::PgConnection; use diesel::PgConnection;
use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness}; use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
use http_body_util::BodyExt; use http_body_util::BodyExt;
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use rand::rngs::OsRng; use rand::rngs::OsRng;
use serde::Serialize; use serde::{Deserialize, Serialize};
use serde_json;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tower::util::ServiceExt; use tower::util::ServiceExt;
use uuid::Uuid; use uuid::Uuid;
@@ -138,6 +140,7 @@ impl TestApp {
s3_bucket: "test-bucket".to_string(), s3_bucket: "test-bucket".to_string(),
quickwit_endpoint: None, quickwit_endpoint: None,
quickwit_index: 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)?; let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?;
@@ -149,16 +152,20 @@ impl TestApp {
let state = AppState::new(pool.clone(), config, storage_for_state, jwt); let state = AppState::new(pool.clone(), config, storage_for_state, jwt);
let router = routes::create_router(state.clone()); let router = routes::create_router(state.clone());
Ok(Self { let app = Self {
state, state,
router, router,
storage, storage,
}) };
app.ensure_default_tenant().await?;
Ok(app)
} }
pub async fn cleanup(&self) -> Result<()> { pub async fn cleanup(&self) -> Result<()> {
let pool = self.state.pool.clone(); let pool = self.state.pool.clone();
tokio::task::spawn_blocking(move || -> Result<()> { let _ = tokio::task::spawn_blocking(move || -> Result<()> {
let mut conn = pool let mut conn = pool
.get() .get()
.map_err(|err| anyhow!("failed to get cleanup connection: {err}"))?; .map_err(|err| anyhow!("failed to get cleanup connection: {err}"))?;
@@ -166,7 +173,10 @@ impl TestApp {
Ok(()) Ok(())
}) })
.await .await
.context("cleanup task panicked")? .context("cleanup task panicked")?;
self.ensure_default_tenant().await?;
Ok(())
} }
#[allow(dead_code)] #[allow(dead_code)]
@@ -174,27 +184,111 @@ impl TestApp {
self.storage.clone() 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> { pub async fn insert_user(&self, username: &str, password: &str, role: &str) -> Result<Uuid> {
let username = username.to_string(); let username = username.to_string();
let password = password.to_string(); let password = password.to_string();
let role = role.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| { self.with_conn(move |conn| {
let password_hash = hash_password(&password)?; let password_hash = hash_password(&password)?;
let user = NewUser { let user = NewUser {
id: Uuid::new_v4(), id: Uuid::new_v4(),
username, username,
password_hash, password_hash,
role,
}; };
diesel::insert_into(backend::schema::users::table) diesel::insert_into(backend::schema::users::table)
.values(&user) .values(&user)
.execute(conn) .execute(conn)
.context("failed to insert user")?; .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) Ok(user.id)
}) })
.await .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> { pub async fn login_token(&self, username: &str, password: &str) -> Result<String> {
#[derive(Serialize)] #[derive(Serialize)]
struct LoginPayload<'a> { struct LoginPayload<'a> {
@@ -217,11 +311,57 @@ impl TestApp {
); );
let body = body_to_vec(response.into_body()).await?; let body = body_to_vec(response.into_body()).await?;
#[derive(serde::Deserialize)] #[derive(Deserialize)]
struct LoginResponse { struct LoginResponse {
access_token: String, access_token: String,
} }
let parsed: LoginResponse = serde_json::from_slice(&body)?;
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 {
selection_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.selection_token),
)
.await?;
ensure!(
select_response.status() == StatusCode::OK,
"tenant selection failed with status {}",
select_response.status()
);
let select_body = body_to_vec(select_response.into_body()).await?;
let parsed: LoginResponse = serde_json::from_slice(&select_body)?;
Ok(parsed.access_token) Ok(parsed.access_token)
} }
@@ -338,6 +478,30 @@ impl TestApp {
data: &[u8], data: &[u8],
folder_id: Option<Uuid>, folder_id: Option<Uuid>,
token: &str, token: &str,
) -> Result<hyper::Response<Body>> {
self.upload_document_with_options(
path,
filename,
content_type,
data,
folder_id,
None,
None,
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>> { ) -> Result<hyper::Response<Body>> {
let boundary = format!("boundary-{}", Uuid::new_v4()); let boundary = format!("boundary-{}", Uuid::new_v4());
let mut body = Vec::new(); let mut body = Vec::new();
@@ -360,6 +524,20 @@ impl TestApp {
body.extend(b"\r\n"); body.extend(b"\r\n");
} }
if let Some(title_value) = title {
body.extend(format!("--{boundary}\r\n").as_bytes());
body.extend(b"Content-Disposition: form-data; name=\"title\"\r\n\r\n");
body.extend(title_value.as_bytes());
body.extend(b"\r\n");
}
if let Some(metadata_value) = metadata_json {
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");
}
body.extend(format!("--{boundary}--\r\n").as_bytes()); body.extend(format!("--{boundary}--\r\n").as_bytes());
let builder = Request::builder() let builder = Request::builder()
@@ -426,7 +604,22 @@ async fn prepare_database(pool: &PgPool) -> Result<()> {
fn truncate_all(conn: &mut PgConnection) -> Result<()> { fn truncate_all(conn: &mut PgConnection) -> Result<()> {
conn.batch_execute( conn.batch_execute(
"TRUNCATE TABLE document_tags, document_versions, documents, folders, tags, users RESTART IDENTITY CASCADE;", "TRUNCATE TABLE \
document_asset_objects, \
document_assets, \
document_correspondents, \
correspondents, \
document_tags, \
document_versions, \
documents, \
folders, \
jobs, \
refresh_tokens, \
tags, \
user_memberships, \
users, \
tenants \
RESTART IDENTITY CASCADE;",
) )
.context("failed to truncate tables")?; .context("failed to truncate tables")?;
Ok(()) Ok(())
+40 -93
View File
@@ -15,6 +15,7 @@ struct DocumentDetail {
struct DocumentInfo { struct DocumentInfo {
id: Uuid, id: Uuid,
title: String, title: String,
filename: String,
original_name: String, original_name: String,
deleted_at: Option<String>, deleted_at: Option<String>,
issued_at: Option<String>, issued_at: Option<String>,
@@ -186,9 +187,10 @@ async fn upload_and_list_document() -> Result<()> {
assert_eq!(current_version.size_bytes, file_bytes.len() as i64); assert_eq!(current_version.size_bytes, file_bytes.len() as i64);
assert!(current_version.assets.is_empty()); assert!(current_version.assets.is_empty());
let storage_key = app.storage_key_for(&current_version.s3_key).await?;
let stored = app let stored = app
.storage() .storage()
.get(&current_version.s3_key) .get(&storage_key)
.await .await
.expect("object stored"); .expect("object stored");
assert_eq!(stored.bytes, file_bytes); assert_eq!(stored.bytes, file_bytes);
@@ -239,6 +241,43 @@ async fn upload_and_list_document() -> Result<()> {
Ok(()) Ok(())
} }
#[tokio::test]
async fn upload_document_with_custom_title_sets_filename() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "passw0rd";
app.insert_user("nora", password, "admin").await?;
let token = app.login_token("nora", password).await?;
let file_bytes = b"example contract body".to_vec();
let title = "Vendor Contract";
let original_filename = "scan.pdf";
let upload = app
.upload_document_with_options(
"/api/documents",
original_filename,
"application/pdf",
&file_bytes,
None,
Some(title),
None,
&token,
)
.await?;
assert_eq!(upload.status(), StatusCode::CREATED);
let body = body_to_vec(upload.into_body()).await?;
let detail: DocumentDetail = serde_json::from_slice(&body)?;
assert_eq!(detail.document.title, title);
assert_eq!(detail.document.filename, format!("{title}.pdf"));
assert_eq!(detail.document.original_name, original_filename);
app.cleanup().await?;
Ok(())
}
#[tokio::test] #[tokio::test]
async fn duplicate_and_restore_document() -> Result<()> { async fn duplicate_and_restore_document() -> Result<()> {
let _lock = acquire_db_lock().await; let _lock = acquire_db_lock().await;
@@ -318,98 +357,6 @@ async fn duplicate_and_restore_document() -> Result<()> {
Ok(()) Ok(())
} }
#[tokio::test]
async fn bulk_reanalyze_documents() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "bulkpass";
app.insert_user("alex", password, "admin").await?;
let token = app.login_token("alex", password).await?;
app.clear_jobs().await?;
let first_bytes = b"first doc";
let first = app
.upload_document(
"/api/documents",
"first.txt",
"text/plain",
first_bytes,
None,
&token,
)
.await?;
assert_eq!(first.status(), StatusCode::CREATED);
let first_body = body_to_vec(first.into_body()).await?;
let first_detail: DocumentDetail = serde_json::from_slice(&first_body)?;
let second_bytes = b"second doc";
let second = app
.upload_document(
"/api/documents",
"second.txt",
"text/plain",
second_bytes,
None,
&token,
)
.await?;
assert_eq!(second.status(), StatusCode::CREATED);
let second_body = body_to_vec(second.into_body()).await?;
let second_detail: DocumentDetail = serde_json::from_slice(&second_body)?;
app.clear_jobs().await?;
let response = app
.post_json(
"/api/documents/reanalyze",
&serde_json::json!({}),
Some(&token),
)
.await?;
assert_eq!(response.status(), StatusCode::ACCEPTED);
let body = body_to_vec(response.into_body()).await?;
let bulk: BulkReanalyze = serde_json::from_slice(&body)?;
assert_eq!(bulk.queued, 2);
let jobs = app.jobs_by_type("analyze-document").await?;
assert_eq!(jobs.len(), 2);
let mut payload_docs = Vec::new();
for job in jobs {
let payload: AnalyzeJobPayload = serde_json::from_value(job.payload)?;
assert!(payload.force);
payload_docs.push((payload.document_id, payload.document_version_id));
}
let mut expected = vec![
(
first_detail.document.id,
first_detail
.document
.current_version
.as_ref()
.expect("first current version")
.id,
),
(
second_detail.document.id,
second_detail
.document
.current_version
.as_ref()
.expect("second current version")
.id,
),
];
payload_docs.sort();
expected.sort();
assert_eq!(payload_docs, expected);
app.cleanup().await?;
Ok(())
}
#[tokio::test] #[tokio::test]
async fn bulk_move_documents_to_folder() -> Result<()> { async fn bulk_move_documents_to_folder() -> Result<()> {
let _lock = acquire_db_lock().await; let _lock = acquire_db_lock().await;
+75 -12
View File
@@ -5,6 +5,7 @@ use axum::http::StatusCode;
use common::{acquire_db_lock, body_to_vec, TestApp}; use common::{acquire_db_lock, body_to_vec, TestApp};
use serde::Deserialize; use serde::Deserialize;
use serde::Serialize; use serde::Serialize;
use serde_json::json;
use uuid::Uuid; use uuid::Uuid;
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -16,6 +17,7 @@ struct FolderResponse {
struct FolderInfo { struct FolderInfo {
id: Uuid, id: Uuid,
name: String, name: String,
parent_id: Option<Uuid>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -42,14 +44,6 @@ struct EnsureFolderPath<'a> {
segments: &'a [&'a str], segments: &'a [&'a str],
} }
#[derive(Serialize)]
struct UpdateFolderRequest {
#[serde(skip_serializing_if = "Option::is_none")]
parent_id: Option<Option<Uuid>>,
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
}
#[derive(Serialize)] #[derive(Serialize)]
struct MoveDocumentRequest { struct MoveDocumentRequest {
folder_id: Option<Uuid>, folder_id: Option<Uuid>,
@@ -150,6 +144,78 @@ async fn folder_move_and_delete_flow() -> Result<()> {
Ok(()) Ok(())
} }
#[tokio::test]
async fn update_folder_parent_to_root() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "rootpass";
app.insert_user("root-admin", password, "admin").await?;
let token = app.login_token("root-admin", password).await?;
// Create a parent folder under root
let parent_resp = app
.post_json(
"/api/folders",
&CreateFolder {
name: "Parent",
parent_id: None,
},
Some(&token),
)
.await?;
assert_eq!(parent_resp.status(), StatusCode::OK);
let parent_body = body_to_vec(parent_resp.into_body()).await?;
let parent: FolderResponse = serde_json::from_slice(&parent_body)?;
// Create a child folder inside the parent
let child_resp = app
.post_json(
"/api/folders",
&CreateFolder {
name: "Child",
parent_id: Some(parent.folder.id),
},
Some(&token),
)
.await?;
assert_eq!(child_resp.status(), StatusCode::OK);
let child_body = body_to_vec(child_resp.into_body()).await?;
let child: FolderResponse = serde_json::from_slice(&child_body)?;
// Move the child back to the root by setting parent_id to null
let update_resp = app
.patch_json(
&format!("/api/folders/{}", child.folder.id),
&json!({ "parent_id": null }),
Some(&token),
)
.await?;
assert_eq!(update_resp.status(), StatusCode::NO_CONTENT);
// Fetch the child folder and ensure parent_id is now null
let updated_resp = app
.get(&format!("/api/folders/{}", child.folder.id), Some(&token))
.await?;
assert_eq!(updated_resp.status(), StatusCode::OK);
let updated_body = body_to_vec(updated_resp.into_body()).await?;
let updated_folder: FolderResponse = serde_json::from_slice(&updated_body)?;
assert!(updated_folder.folder.parent_id.is_none());
// Root contents should include the child folder by name
let root_contents = app.get("/api/folders/root/contents", Some(&token)).await?;
assert_eq!(root_contents.status(), StatusCode::OK);
let root_body = body_to_vec(root_contents.into_body()).await?;
let root: FolderContents = serde_json::from_slice(&root_body)?;
assert!(root
.subfolders
.iter()
.any(|folder| folder.id == child.folder.id));
app.cleanup().await?;
Ok(())
}
#[tokio::test] #[tokio::test]
async fn ensure_path_creates_nested_folders() -> Result<()> { async fn ensure_path_creates_nested_folders() -> Result<()> {
let _lock = acquire_db_lock().await; let _lock = acquire_db_lock().await;
@@ -267,10 +333,7 @@ async fn folder_rename_updates_name_and_child_paths() -> Result<()> {
let rename_resp = app let rename_resp = app
.patch_json( .patch_json(
&format!("/api/folders/{}", parent.folder.id), &format!("/api/folders/{}", parent.folder.id),
&UpdateFolderRequest { &json!({ "name": "Archive" }),
parent_id: None,
name: Some("Archive".to_string()),
},
Some(&token), Some(&token),
) )
.await?; .await?;
+33 -14
View File
@@ -10,19 +10,19 @@ services:
tmpfs: tmpfs:
- /var/lib/postgresql/data - /var/lib/postgresql/data
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U papercrate"] test: ["CMD-SHELL", "pg_isready -U papercrate -d papercrate_test"]
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 5 retries: 5
quickwit: quickwit-test:
image: quickwit/quickwit:0.8.2 image: quickwit/quickwit:0.8.2
command: ["run"] command: ["run"]
environment: environment:
QW_ENABLE_API_AUTH: "false" QW_ENABLE_API_AUTH: "false"
QW_DATA_DIR: /quickwit/data QW_DATA_DIR: /quickwit/data
ports: ports:
- "7280:7280" - "7281:7280"
volumes: volumes:
- quickwit_test_data:/quickwit/data - quickwit_test_data:/quickwit/data
healthcheck: healthcheck:
@@ -31,21 +31,40 @@ services:
timeout: 5s timeout: 5s
retries: 5 retries: 5
quickwit-create-index: admin-bootstrap:
image: quickwit/quickwit:0.8.2 build:
context: ./backend
depends_on: depends_on:
quickwit: postgres-test:
condition: service_healthy
quickwit-test:
condition: service_healthy condition: service_healthy
volumes:
- quickwit_test_data:/quickwit/data
- ./quickwit/documents-index.yaml:/tmp/documents-index.yaml:ro
environment: environment:
QW_DATA_DIR: /quickwit/data DATABASE_URL: postgres://papercrate:papercrate_test@postgres-test:5432/papercrate_test
QW_NODE_URI: http://quickwit:7280 DATABASE_MAX_POOL_SIZE: 1
entrypoint: ["/bin/sh", "-c"] S3_BUCKET: documents
JWT_SECRET: change-me-super-secret
QUICKWIT_ENDPOINT: http://quickwit-test:7280
DEFAULT_TENANT_SLUG: admin
entrypoint: []
command: > command: >
quickwit index list --node-uri http://quickwit:7280 2>/dev/null | grep -q '"documents"' \ /bin/sh -c "
|| quickwit index create --index-config /tmp/documents-index.yaml --node-uri http://quickwit:7280 echo 'Running database migrations' &&
diesel migration run &&
echo 'Ensuring tenant admin exists' &&
if papercrate-admin list-tenants | grep -q '^admin '; then
echo 'tenant admin already exists';
else
papercrate-admin create-tenant admin;
fi &&
echo 'Ensuring demo user credentials' &&
(papercrate-admin create-user admin adminadmin || papercrate-admin set-password admin adminadmin) &&
echo 'Ensuring demo membership' &&
papercrate-admin add-user-to-tenant admin admin admin &&
echo 'Ensuring Quickwit index for admin tenant' &&
papercrate-admin quickwit-create-index admin
"
user: root
restart: "no" restart: "no"
volumes: volumes:
+35 -6
View File
@@ -62,15 +62,44 @@ services:
timeout: 5s timeout: 5s
retries: 5 retries: 5
quickwit-create-index: admin-bootstrap:
image: quickwit/quickwit:0.8.2 build:
context: ./backend
depends_on: depends_on:
postgres:
condition: service_healthy
quickwit: quickwit:
condition: service_healthy condition: service_healthy
volumes: environment:
- quickwit_data:/quickwit/data DATABASE_URL: postgres://papercrate:papercrate_dev@postgres:5432/papercrate
- ./quickwit/documents-index.yaml:/tmp/documents-index.yaml:ro DATABASE_MAX_POOL_SIZE: 2
command: index create --endpoint=http://quickwit:7280 --index-config /tmp/documents-index.yaml AWS_ENDPOINT_URL: http://minio:9000
AWS_ACCESS_KEY_ID: minioadmin
AWS_SECRET_ACCESS_KEY: minioadmin
AWS_REGION: us-east-1
S3_BUCKET: documents
JWT_SECRET: change-me-super-secret
QUICKWIT_ENDPOINT: http://quickwit:7280
DEFAULT_TENANT_SLUG: admin
entrypoint: []
command: >
/bin/sh -c "
echo 'Running database migrations' &&
diesel migration run &&
echo 'Ensuring tenant admin exists' &&
if papercrate-admin list-tenants | grep -q '^admin '; then
echo 'tenant admin already exists';
else
papercrate-admin create-tenant admin;
fi &&
echo 'Ensuring demo user credentials' &&
(papercrate-admin create-user admin adminadmin || papercrate-admin set-password admin adminadmin) &&
echo 'Ensuring demo membership' &&
papercrate-admin add-user-to-tenant admin admin admin &&
echo 'Ensuring Quickwit index for admin tenant' &&
papercrate-admin quickwit-create-index admin
"
user: root
restart: "no" restart: "no"
volumes: volumes:
+5 -3
View File
@@ -17,8 +17,8 @@ Health
Documents Documents
--------- ---------
- GET /api/documents - List or search documents. Optional filters: `folder_id` (defaults to root when omitted), `include_deleted`, `include_descendants` (defaults to true when a `folder_id` is provided and no other override is supplied), `query` (Quickwit full-text), `tags` (comma-separated tag UUIDs), and `correspondents` (comma-separated correspondent UUIDs). Each entry includes tags, correspondent assignments, and current version info. - GET /api/documents - List or search documents. Optional filters: `folder_id` (defaults to root when omitted), `include_deleted`, `include_descendants` (defaults to true when a `folder_id` is provided and no other override is supplied), `query` (Quickwit full-text), `tags` (comma-separated tag UUIDs), and `correspondents` (comma-separated correspondent UUIDs). Each entry includes tags, correspondent assignments, and current version info.
- POST /api/documents - Upload a document via multipart form-data (`file`, optional metadata/folder fields). - GET /api/documents/check?checksum=<sha256> - Lightweight checksum preflight. Returns `exists=false` when no document with the supplied SHA-256 checksum is present; otherwise returns `exists=true` plus the current document metadata.
- POST /api/documents/reanalyze - Queue re-analysis for every non-deleted document. - POST /api/documents - Upload a document via multipart form-data. Required field: `file`. Optional fields: `title`, `folder_id`, JSON `metadata`, JSON array `tag_ids`, JSON array `correspondents` (each with `correspondent_id` and `role`), and `issued_at` (RFC3339). When `title` is supplied, the stored filename becomes `<title><original_extension>`. Include `skip_existing=true` to receive `204 No Content` instead of reusing a matching document.
- POST /api/documents/bulk/move - Move multiple documents to a target folder. - POST /api/documents/bulk/move - Move multiple documents to a target folder.
- POST /api/documents/bulk/tags - Add or remove tags across multiple documents. - POST /api/documents/bulk/tags - Add or remove tags across multiple documents.
- POST /api/documents/bulk/correspondents - Bulk correspondent actions. Default `action=add` replaces existing assignments for the provided roles before adding the supplied correspondents; `action=remove` drops the specified correspondent/role pairs. - POST /api/documents/bulk/correspondents - Bulk correspondent actions. Default `action=add` replaces existing assignments for the provided roles before adding the supplied correspondents; `action=remove` drops the specified correspondent/role pairs.
@@ -37,7 +37,7 @@ Document Assets
--------------- ---------------
- GET /api/documents/:id/assets - List generated assets for the current version. - GET /api/documents/:id/assets - List generated assets for the current version.
- POST /api/documents/:id/assets - Request (re)generation of document assets; accepts optional `force` query flag. - POST /api/documents/:id/assets - Request (re)generation of document assets; accepts optional `force` query flag.
- GET /api/documents/:id/assets/:asset_id - Fetch metadata and a pre-signed URL for a specific asset. - GET /api/assets/:asset_id - Fetch asset metadata plus a presigned URL for a range of objects (query params: `start` and `limit`, defaulting to the first object).
Downloads Downloads
--------- ---------
@@ -47,9 +47,11 @@ Folders
------- -------
- POST /api/folders - Create a folder (optionally under a parent). - POST /api/folders - Create a folder (optionally under a parent).
- POST /api/folders/path - Ensure a nested folder path exists, creating missing segments. - POST /api/folders/path - Ensure a nested folder path exists, creating missing segments.
- GET /api/folders/:id - Fetch folder metadata.
- GET /api/folders/:id/contents - List subfolders and documents inside a folder; use `root` for the workspace root. - GET /api/folders/:id/contents - List subfolders and documents inside a folder; use `root` for the workspace root.
- DELETE /api/folders/:id - Soft-delete a folder. - DELETE /api/folders/:id - Soft-delete a folder.
- PATCH /api/folders/:id - Update a folder's parent (`parent_id`) and/or rename it (`name`). - PATCH /api/folders/:id - Update a folder's parent (`parent_id`) and/or rename it (`name`).
- GET /api/docs/openapi.json - Generated OpenAPI specification (JSON).
Tags Tags
---- ----
+214 -21
View File
@@ -17,7 +17,116 @@ export const getAssetFromVersion = (currentVersion, assetType) => {
return getAssetFromGroup(currentVersion.assets, assetType); return getAssetFromGroup(currentVersion.assets, assetType);
}; };
export const resolveDocumentAssetUrl = (doc, type, { ensureAssetUrl, getAsset, ensureOptions } = {}) => { const normalizeAssetObjects = (objects) => {
if (!Array.isArray(objects)) {
return [];
}
return objects
.filter((entry) => Number.isInteger(entry?.ordinal))
.slice()
.sort((a, b) => a.ordinal - b.ordinal);
};
const mergeAssetObjects = (existingObjects, incomingObjects) => {
const merged = new Map();
normalizeAssetObjects(existingObjects).forEach((entry) => {
merged.set(entry.ordinal, { ...entry });
});
normalizeAssetObjects(incomingObjects).forEach((entry) => {
const current = merged.get(entry.ordinal) || {};
merged.set(entry.ordinal, { ...current, ...entry });
});
return [...merged.entries()]
.sort((a, b) => a[0] - b[0])
.map(([, value]) => value);
};
export class AssetView {
constructor(asset) {
this.asset = asset || null;
this._objectsRef = null;
this._sortedObjects = [];
}
getCardinality() {
if (!this.asset) {
return 0;
}
const reported = Number(this.asset.cardinality);
if (Number.isFinite(reported) && reported > 0) {
return reported;
}
const objectsCount = this.getObjects().length;
if (objectsCount > 0) {
return objectsCount;
}
return this.asset.metadata ? 1 : 0;
}
getObjects() {
if (!this.asset || !Array.isArray(this.asset.objects) || this.asset.objects.length === 0) {
return [];
}
if (this._objectsRef === this.asset.objects) {
return this._sortedObjects;
}
this._objectsRef = this.asset.objects;
this._sortedObjects = normalizeAssetObjects(this.asset.objects);
return this._sortedObjects;
}
getObject(ordinal = 1) {
const fromObjects = this.getObjects().find((entry) => entry.ordinal === ordinal);
if (fromObjects) {
return fromObjects;
}
if (ordinal === 1 && this.asset) {
if (this.asset.url || this.asset.metadata) {
return {
ordinal: 1,
url: this.asset.url || null,
metadata: this.asset.metadata || null,
expires_at: this.asset.expiresAt ?? null,
};
}
}
return null;
}
getPrimaryObject() {
return this.getObject(1);
}
getPrimaryMetadata() {
return this.getPrimaryObject()?.metadata || null;
}
getPrimaryUrl() {
return this.getPrimaryObject()?.url || null;
}
hasObject(ordinal) {
return Boolean(this.getObject(ordinal));
}
}
export const createAssetView = (asset) => new AssetView(asset);
export const resolveDocumentAssetUrl = (
doc,
type,
{ ensureAssetUrl, getAsset, ensureOptions, objectOrdinal = 1 } = {},
) => {
if (!doc || !type) { if (!doc || !type) {
return null; return null;
} }
@@ -25,15 +134,32 @@ export const resolveDocumentAssetUrl = (doc, type, { ensureAssetUrl, getAsset, e
if (!asset) { if (!asset) {
return null; return null;
} }
const view = createAssetView(asset);
const object = view.getObject(objectOrdinal);
const url = object?.url || (objectOrdinal === 1 ? view.getPrimaryUrl() : null);
const expiresAt = typeof object?.expires_at === 'number'
? object.expires_at
: objectOrdinal === 1 && typeof asset.expiresAt === 'number'
? asset.expiresAt
: null;
const now = Date.now(); const now = Date.now();
const expiresAt = typeof asset.expiresAt === 'number' ? asset.expiresAt : null; if (url && (!expiresAt || expiresAt > now)) {
const hasFreshUrl = asset.url && (!expiresAt || expiresAt > now); return url;
if (hasFreshUrl) {
return asset.url;
} }
if (doc.id && asset.id && typeof ensureAssetUrl === 'function') { if (doc.id && asset.id && typeof ensureAssetUrl === 'function') {
const force = Boolean(asset.url && expiresAt && expiresAt <= now); const force = Boolean(url && expiresAt && expiresAt <= now);
const options = ensureOptions ? { ...ensureOptions, force } : { force }; const options = {
force,
start: objectOrdinal,
limit: 1,
...(ensureOptions || {}),
};
if (!options.start) {
options.start = objectOrdinal;
}
if (!options.limit) {
options.limit = 1;
}
ensureAssetUrl(doc.id, asset, options).catch(() => {}); ensureAssetUrl(doc.id, asset, options).catch(() => {});
} }
return null; return null;
@@ -63,6 +189,10 @@ class AssetManager {
} }
const cached = this.assetCache.get(asset.id); const cached = this.assetCache.get(asset.id);
if (!cached) { if (!cached) {
const normalized = mergeAssetObjects(null, asset.objects);
if (normalized.length) {
return { ...asset, objects: normalized };
}
return asset; return asset;
} }
const merged = { ...cached, ...asset }; const merged = { ...cached, ...asset };
@@ -76,6 +206,10 @@ class AssetManager {
merged.expiresAt = cachedExpires; merged.expiresAt = cachedExpires;
} }
} }
const mergedObjects = mergeAssetObjects(cached.objects, asset.objects);
if (mergedObjects.length) {
merged.objects = mergedObjects;
}
return merged; return merged;
} }
@@ -172,24 +306,53 @@ class AssetManager {
return next; return next;
} }
ensureAsset(documentId, asset, { force = false } = {}) { ensureAsset(documentId, asset, { force = false, start = null, limit = null } = {}) {
if (!documentId || !asset?.id) { if (!documentId || !asset?.id) {
return Promise.resolve(asset || null); return Promise.resolve(asset || null);
} }
const assetExpiresAt = typeof asset.expiresAt === 'number' ? asset.expiresAt : null; const requestedStart = Number.isInteger(start) && start > 0 ? start : 1;
if (!force && asset?.url && (!assetExpiresAt || assetExpiresAt > Date.now())) { const requestedLimit = Number.isInteger(limit) && limit > 0 ? limit : 1;
this.rememberAsset(asset); const requestedEnd = requestedStart + requestedLimit - 1;
return Promise.resolve(asset);
}
const cached = this.assetCache.get(asset.id); const baseAsset = this.assetCache.get(asset.id) || asset;
const view = createAssetView(baseAsset);
const assetExpiresAt = typeof baseAsset.expiresAt === 'number' ? baseAsset.expiresAt : null;
const now = Date.now(); const now = Date.now();
if (!force && cached && cached.expiresAt && cached.expiresAt > now && cached.url) {
return Promise.resolve({ ...asset, ...cached }); const isOrdinalSatisfied = (ordinal) => {
const object = view.getObject(ordinal);
if (!object) {
return false;
}
if (!object.url) {
return false;
}
if (typeof object.expires_at === 'number') {
return object.expires_at > now;
}
if (ordinal === 1 && baseAsset.url && (!assetExpiresAt || assetExpiresAt > now)) {
return true;
}
return true;
};
let needsFetch = force;
if (!needsFetch) {
for (let ordinal = requestedStart; ordinal <= requestedEnd; ordinal += 1) {
if (!isOrdinalSatisfied(ordinal)) {
needsFetch = true;
break;
}
}
} }
const inflightKey = `${documentId}:${asset.id}`; if (!needsFetch) {
this.rememberAsset(baseAsset);
return Promise.resolve(baseAsset);
}
const inflightKey = `${documentId}:${asset.id}:${start ?? 'd'}:${limit ?? 'd'}`;
if (!force && this.assetInflight.has(inflightKey)) { if (!force && this.assetInflight.has(inflightKey)) {
return this.assetInflight.get(inflightKey); return this.assetInflight.get(inflightKey);
} }
@@ -198,14 +361,44 @@ class AssetManager {
return Promise.reject(new Error('AssetManager API client is not configured.')); return Promise.reject(new Error('AssetManager API client is not configured.'));
} }
const params = {};
if (Number.isInteger(start) && start > 0) {
params.start = start;
}
if (Number.isInteger(limit) && limit > 0) {
params.limit = limit;
}
const requestConfig = Object.keys(params).length ? { params } : undefined;
const request = this.api const request = this.api
.get(`/documents/${documentId}/assets/${asset.id}`) .get(`/assets/${asset.id}`, requestConfig)
.then(({ data }) => { .then(({ data }) => {
const incomingObjects = Array.isArray(data.objects) ? data.objects : [];
const cachedEntry = this.assetCache.get(asset.id) || baseAsset;
const mergedObjects = mergeAssetObjects(cachedEntry?.objects, incomingObjects);
const combined = { ...cachedEntry, ...asset, ...data, objects: mergedObjects };
const view = createAssetView(combined);
const primaryObject = view.getPrimaryObject();
const expiresAt = typeof primaryObject?.expires_at === 'number'
? primaryObject.expires_at
: Date.now() + this.assetPresignTtlMs;
const cardinality = (() => {
const reported = Number(data.cardinality ?? asset.cardinality ?? cachedEntry?.cardinality);
const objectsCount = mergedObjects.length;
if (Number.isFinite(reported) && reported > 0) {
return Math.max(reported, objectsCount) || null;
}
return objectsCount || null;
})();
const entry = { const entry = {
...asset, ...combined,
...data, cardinality,
expiresAt: Date.now() + this.assetPresignTtlMs, url: view.getPrimaryUrl(),
expiresAt,
}; };
this.rememberAsset(entry); this.rememberAsset(entry);
return entry; return entry;
}) })
+212 -54
View File
@@ -1,15 +1,25 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { DownloadIcon, EditIcon } from '../ui/icons'; import { DownloadIcon, EditIcon, ArrowLeftIcon, ArrowRightIcon } from '../ui/icons';
import { getTagColorStyle } from '../utils/colors'; import { getTagColorStyle } from '../utils/colors';
import { formatFileSize } from '../utils/format'; import { formatFileSize } from '../utils/format';
import { resolveDocumentAssetUrl } from '../asset_manager'; import { resolveDocumentAssetUrl, createAssetView } from '../asset_manager';
import { useAssetNavigator } from '../hooks/useAssetNavigator';
import { CORRESPONDENT_ROLES } from '../constants/correspondents'; import { CORRESPONDENT_ROLES } from '../constants/correspondents';
const MAX_PREVIEW_STACK_ITEMS = 15; const MAX_PREVIEW_STACK_ITEMS = 15;
const normalizeRole = (role) => (role || '').toLowerCase(); const normalizeRole = (role) => (role || '').toLowerCase();
const derivePreviewOrientation = (metadata) => {
const width = Number(metadata?.width);
const height = Number(metadata?.height);
if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) {
return width >= height ? 'landscape' : 'portrait';
}
return 'landscape';
};
const formatRoleLabel = (role) => { const formatRoleLabel = (role) => {
const normalized = normalizeRole(role); const normalized = normalizeRole(role);
if (!normalized) return 'Other'; if (!normalized) return 'Other';
@@ -435,20 +445,34 @@ const DetailPanel = ({
setOcrOpen(false); setOcrOpen(false);
}, []); }, []);
const singlePreviewNavigator = useAssetNavigator({
document: singleDoc,
assetType: 'preview',
ensureAssetUrl,
getAsset: getDocumentAsset,
prefetch: 3,
});
const makePreviewItem = useCallback( const makePreviewItem = useCallback(
(doc) => { (doc, ordinal = 1) => {
if (!doc) return null; if (!doc) return null;
const url = resolveDocumentAssetUrl(doc, 'preview', { const asset = getDocumentAsset(doc, 'preview');
ensureAssetUrl, const assetView = createAssetView(asset);
getAsset: getDocumentAsset, const object = assetView.getObject(ordinal);
}); let url = object?.url || null;
if (!url) {
url = resolveDocumentAssetUrl(doc, 'preview', {
ensureAssetUrl,
getAsset: getDocumentAsset,
ensureOptions: { start: ordinal, limit: 1 },
objectOrdinal: ordinal,
});
}
if (!url) { if (!url) {
return null; return null;
} }
const asset = getDocumentAsset(doc, 'preview'); const metadata = object?.metadata || assetView.getPrimaryMetadata() || {};
const width = Number(asset?.metadata?.width) || 0; const orientation = derivePreviewOrientation(metadata);
const height = Number(asset?.metadata?.height) || 0;
const orientation = width > 0 && height > 0 ? (width >= height ? 'landscape' : 'portrait') : 'landscape';
return { return {
id: doc.id, id: doc.id,
url, url,
@@ -475,32 +499,74 @@ const DetailPanel = ({
return ordered; return ordered;
}, [selectedDocuments]); }, [selectedDocuments]);
const stackTopDocument = stackDocuments[0] || null;
const stackPreviewNavigator = useAssetNavigator({
document: stackTopDocument,
assetType: 'preview',
ensureAssetUrl,
getAsset: getDocumentAsset,
prefetch: 3,
});
const singlePreviewItems = useMemo(() => { const singlePreviewItems = useMemo(() => {
if (!singleDoc) return []; if (!singleDoc) return [];
const item = makePreviewItem(singleDoc); const url = singlePreviewNavigator.currentUrl;
return item ? [item] : []; if (!url) {
}, [singleDoc, makePreviewItem]); return [];
const stackPreviews = useMemo(
() =>
stackDocuments
.map((doc) => makePreviewItem(doc))
.filter(Boolean),
[stackDocuments, makePreviewItem],
);
useEffect(() => {
if (!ensureAssetUrl) {
return;
} }
const orientation = derivePreviewOrientation(singlePreviewNavigator.currentMetadata);
return [
{
id: singleDoc.id,
url,
orientation,
alt: singleDoc.title || singleDoc.original_name || 'Document preview',
},
];
}, [singleDoc, singlePreviewNavigator.currentUrl, singlePreviewNavigator.currentMetadata]);
stackDocuments.forEach((doc) => { const stackPreviews = useMemo(() => {
resolveDocumentAssetUrl(doc, 'preview', { if (!stackDocuments.length) {
ensureAssetUrl, return [];
getAsset: getDocumentAsset, }
}); return stackDocuments
}); .map((doc) => {
}, [stackDocuments, ensureAssetUrl, getDocumentAsset]); if (!doc) return null;
if (stackTopDocument && doc.id === stackTopDocument.id) {
const url = stackPreviewNavigator.currentUrl;
if (!url) {
return null;
}
const orientation = derivePreviewOrientation(stackPreviewNavigator.currentMetadata);
return {
id: doc.id,
url,
orientation,
alt: doc.title || doc.original_name || 'Document preview',
};
}
return makePreviewItem(doc, 1);
})
.filter(Boolean);
}, [
stackDocuments,
stackTopDocument,
stackPreviewNavigator.currentUrl,
stackPreviewNavigator.currentMetadata,
makePreviewItem,
]);
const singleCardinality = singlePreviewNavigator.cardinality;
const singleEffectiveCardinality = singleCardinality || (singlePreviewNavigator.currentUrl ? 1 : 0);
const singleHasPreview = Boolean(singlePreviewNavigator.currentUrl);
const topDocId = stackTopDocument?.id || null;
const topCardinality = stackPreviewNavigator.cardinality;
const topEffectiveCardinality = topCardinality || (stackPreviewNavigator.currentUrl ? 1 : 0);
const topHasPreview = Boolean(stackPreviewNavigator.currentUrl);
const topOrdinal = stackPreviewNavigator.ordinal;
const topCanGoPrev = stackPreviewNavigator.canGoPrev;
const topCanGoNext = stackPreviewNavigator.canGoNext;
const bulkTagUnion = useMemo(() => { const bulkTagUnion = useMemo(() => {
if (!selectedDocuments.length) return []; if (!selectedDocuments.length) return [];
@@ -659,19 +725,64 @@ const DetailPanel = ({
const hasPageCount = Number.isFinite(pageCountValue) && pageCountValue >= 0; const hasPageCount = Number.isFinite(pageCountValue) && pageCountValue >= 0;
const metadata = const metadata =
singleDoc.metadata && Object.keys(singleDoc.metadata).length > 0 ? singleDoc.metadata : null; singleDoc.metadata && Object.keys(singleDoc.metadata).length > 0 ? singleDoc.metadata : null;
const effectiveCardinality = singleEffectiveCardinality;
const canGoPrev = singlePreviewNavigator.canGoPrev;
const canGoNext = singlePreviewNavigator.canGoNext;
const hasPreviewImage = singleHasPreview;
const interceptNavPointer = (event) => {
event.preventDefault();
event.stopPropagation();
};
return ( return (
<> <>
<div> <div className="preview-pane preview-pane--stack">
<div className="preview-pane preview-pane--stack"> <PreviewStack
<PreviewStack items={singlePreviewItems}
items={singlePreviewItems} maxItems={1}
maxItems={1} emptyMessage="Preview loading…"
emptyMessage="Preview loading…" onItemActivate={handlePreviewActivate}
onItemActivate={handlePreviewActivate} onOpenPreview={onOpenPreview}
onOpenPreview={onOpenPreview} activeItemId={activePreviewId}
activeItemId={activePreviewId} />
/> {hasPreviewImage && (effectiveCardinality > 1 || canGoPrev || canGoNext) ? (
</div> <div className="preview-pane__nav preview-pane__nav--overlay">
<button
type="button"
className="preview-pane__nav-button preview-pane__nav-button--prev"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
singlePreviewNavigator.goPrev();
}}
onPointerDown={interceptNavPointer}
onPointerUp={interceptNavPointer}
onMouseDown={interceptNavPointer}
onMouseUp={interceptNavPointer}
disabled={!canGoPrev}
aria-label="Previous preview"
>
<ArrowLeftIcon />
</button>
<button
type="button"
className="preview-pane__nav-button preview-pane__nav-button--next"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
singlePreviewNavigator.goNext();
}}
onPointerDown={interceptNavPointer}
onPointerUp={interceptNavPointer}
onMouseDown={interceptNavPointer}
onMouseUp={interceptNavPointer}
disabled={!canGoNext}
aria-label="Next preview"
>
<ArrowRightIcon />
</button>
</div>
) : null}
</div> </div>
<div className="doc-title-row"> <div className="doc-title-row">
{isEditingTitle ? ( {isEditingTitle ? (
@@ -871,19 +982,66 @@ const DetailPanel = ({
const renderBulk = () => { const renderBulk = () => {
const countLabel = `${selectedCount} document${selectedCount === 1 ? '' : 's'}`; const countLabel = `${selectedCount} document${selectedCount === 1 ? '' : 's'}`;
const sizeLabel = stackTotalSizeBytes ? formatFileSize(stackTotalSizeBytes) : '—'; const sizeLabel = stackTotalSizeBytes ? formatFileSize(stackTotalSizeBytes) : '—';
const topDoc = stackTopDocument;
const topDocIdLocal = topDocId;
const topOrdinal = stackPreviewNavigator.ordinal;
const topCardinalityLocal = topEffectiveCardinality;
const topHasPreview = Boolean(stackPreviewNavigator.currentUrl);
const topCanGoPrev = stackPreviewNavigator.canGoPrev;
const topCanGoNext = stackPreviewNavigator.canGoNext;
const interceptTopNavPointer = (event) => {
event.preventDefault();
event.stopPropagation();
};
return ( return (
<> <>
<div> <div className="preview-pane preview-pane--stack">
<div className="preview-pane preview-pane--stack"> <PreviewStack
<PreviewStack items={stackPreviews}
items={stackPreviews} emptyMessage="No previews available."
emptyMessage="No previews available." onItemActivate={handlePreviewActivate}
onItemActivate={handlePreviewActivate} onOpenPreview={onOpenPreview}
onOpenPreview={onOpenPreview} activeItemId={activePreviewId}
activeItemId={activePreviewId} />
/> {topDocIdLocal && topHasPreview && (topCardinalityLocal > 1 || topCanGoPrev || topCanGoNext) ? (
</div> <div className="preview-pane__nav preview-pane__nav--overlay">
<button
type="button"
className="preview-pane__nav-button preview-pane__nav-button--prev"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
stackPreviewNavigator.goPrev();
}}
onPointerDown={interceptTopNavPointer}
onPointerUp={interceptTopNavPointer}
onMouseDown={interceptTopNavPointer}
onMouseUp={interceptTopNavPointer}
disabled={!topCanGoPrev}
aria-label="Previous preview"
>
<ArrowLeftIcon />
</button>
<button
type="button"
className="preview-pane__nav-button preview-pane__nav-button--next"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
stackPreviewNavigator.goNext();
}}
onPointerDown={interceptTopNavPointer}
onPointerUp={interceptTopNavPointer}
onMouseDown={interceptTopNavPointer}
onMouseUp={interceptTopNavPointer}
disabled={!topCanGoNext}
aria-label="Next preview"
>
<ArrowRightIcon />
</button>
</div>
) : null}
</div> </div>
<h3 style={{ margin: 0 }}>{countLabel}</h3> <h3 style={{ margin: 0 }}>{countLabel}</h3>
<div className="meta"> <div className="meta">
+9 -7
View File
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { getAssetFromVersion, resolveDocumentAssetUrl } from '../asset_manager'; import { getAssetFromVersion, resolveDocumentAssetUrl, createAssetView } from '../asset_manager';
import { getTagColorStyle } from '../utils/colors'; import { getTagColorStyle } from '../utils/colors';
import { DownloadIcon, EditIcon, ViewListIcon, ViewGridIcon, FolderIcon } from '../ui/icons'; import { DownloadIcon, EditIcon, ViewListIcon, ViewGridIcon, FolderIcon } from '../ui/icons';
@@ -21,9 +21,14 @@ const DocumentThumbnailImage = ({
maxSize = LIST_ICON_SIZE, maxSize = LIST_ICON_SIZE,
}) => { }) => {
const resolvedMaxSize = Math.max(1, Math.round(maxSize || 1)); const resolvedMaxSize = Math.max(1, Math.round(maxSize || 1));
const thumbnailAsset = useMemo(() => getAssetFromVersion(document?.current_version, 'thumbnail'), [document?.current_version]); const thumbnailAsset = useMemo(
const assetWidth = Number(thumbnailAsset?.metadata?.width); () => getAssetFromVersion(document?.current_version, 'thumbnail'),
const assetHeight = Number(thumbnailAsset?.metadata?.height); [document?.current_version],
);
const thumbnailView = useMemo(() => createAssetView(thumbnailAsset), [thumbnailAsset]);
const primaryMetadata = thumbnailView.getPrimaryMetadata() || {};
const assetWidth = Number(primaryMetadata?.width);
const assetHeight = Number(primaryMetadata?.height);
const dimensions = useMemo(() => { const dimensions = useMemo(() => {
if (!Number.isFinite(assetWidth) || assetWidth <= 0 || !Number.isFinite(assetHeight) || assetHeight <= 0) { if (!Number.isFinite(assetWidth) || assetWidth <= 0 || !Number.isFinite(assetHeight) || assetHeight <= 0) {
@@ -358,7 +363,6 @@ const DocumentsTable = ({
<div <div
ref={scrollRef} ref={scrollRef}
className="documents-scroll" className="documents-scroll"
tabIndex={0}
onFocus={(event) => { onFocus={(event) => {
if (event.target === scrollRef.current) { if (event.target === scrollRef.current) {
onDocumentListFocus?.(); onDocumentListFocus?.();
@@ -499,7 +503,6 @@ const DocumentsTable = ({
} }
}} }}
role="button" role="button"
tabIndex={0}
draggable draggable
onDragStart={(event) => { onDragStart={(event) => {
event.stopPropagation(); event.stopPropagation();
@@ -736,7 +739,6 @@ const DocumentsTable = ({
} }
}} }}
role="button" role="button"
tabIndex={0}
draggable draggable
onDragStart={(event) => { onDragStart={(event) => {
event.stopPropagation(); event.stopPropagation();
+133
View File
@@ -0,0 +1,133 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { createAssetView } from '../asset_manager';
const clampOrdinalValue = (value, cardinality, defaultOrdinal) => {
const raw = Number.isFinite(value) ? value : defaultOrdinal;
let next = Math.max(1, Math.floor(raw));
if (cardinality && cardinality > 0) {
next = Math.min(next, cardinality);
}
return next;
};
export const useAssetNavigator = ({
document,
assetType,
ensureAssetUrl,
getAsset,
prefetch = 2,
defaultOrdinal = 1,
}) => {
const documentId = document?.id || null;
const asset = useMemo(() => {
if (!document || typeof getAsset !== 'function') {
return null;
}
return getAsset(document, assetType);
}, [document, assetType, getAsset]);
const view = useMemo(() => createAssetView(asset), [asset]);
const cardinality = view.getCardinality();
const [ordinal, setOrdinalInternal] = useState(defaultOrdinal);
useEffect(() => {
setOrdinalInternal(defaultOrdinal);
}, [documentId, assetType, defaultOrdinal]);
const setOrdinal = useCallback(
(next) => {
setOrdinalInternal((prev) => {
const target = typeof next === 'function' ? next(prev) : next;
return clampOrdinalValue(target, cardinality, defaultOrdinal);
});
},
[cardinality, defaultOrdinal],
);
const goPrev = useCallback(() => setOrdinal((value) => value - 1), [setOrdinal]);
const goNext = useCallback(() => setOrdinal((value) => value + 1), [setOrdinal]);
const objects = view.getObjects();
const currentObject = view.getObject(ordinal);
const currentUrl = currentObject?.url || view.getPrimaryUrl();
const currentMetadata = currentObject?.metadata || view.getPrimaryMetadata() || null;
const canGoPrev = ordinal > 1;
const canGoNext = cardinality ? ordinal < cardinality : true;
const ordinalsNeedingLoad = useMemo(() => {
const missing = [];
if (!asset) {
return missing;
}
const maxOrdinal = cardinality && cardinality > 0
? Math.min(cardinality, ordinal + Math.max(1, prefetch) - 1)
: ordinal + Math.max(1, prefetch) - 1;
for (let ord = ordinal; ord <= maxOrdinal; ord += 1) {
const object = view.getObject(ord);
if (!object?.url) {
missing.push(ord);
}
}
return missing;
}, [asset, view, ordinal, prefetch, cardinality]);
const fetchStart = ordinalsNeedingLoad.length ? ordinalsNeedingLoad[0] : null;
const fetchEnd = ordinalsNeedingLoad.length ? ordinalsNeedingLoad[ordinalsNeedingLoad.length - 1] : null;
const fetchLimit = fetchStart && fetchEnd ? fetchEnd - fetchStart + 1 : null;
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!documentId || !asset || !ensureAssetUrl) {
setLoading(false);
return;
}
if (!fetchStart || !fetchLimit) {
setLoading(false);
return;
}
let cancelled = false;
setLoading(true);
ensureAssetUrl(documentId, asset, {
start: fetchStart,
limit: fetchLimit,
})
.catch(() => {})
.finally(() => {
if (!cancelled) {
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [documentId, asset, ensureAssetUrl, fetchStart, fetchLimit]);
return {
document,
documentId,
asset,
assetType,
ordinal,
setOrdinal,
goPrev,
goNext,
canGoPrev,
canGoNext,
cardinality,
currentObject,
currentUrl,
currentMetadata,
objects,
isLoading: loading,
};
};
export default useAssetNavigator;
+241 -86
View File
@@ -21,7 +21,7 @@ import {
matchPath, matchPath,
} from 'react-router-dom'; } from 'react-router-dom';
import './styles.css'; import './styles.css';
import AssetManager, { getAssetFromVersion, resolveDocumentAssetUrl } from './asset_manager'; import AssetManager, { getAssetFromVersion, resolveDocumentAssetUrl, createAssetView } from './asset_manager';
import useApiError from './hooks/useApiError'; import useApiError from './hooks/useApiError';
import SkeuomorphicWorkspace from './skeuomorphic_ws'; import SkeuomorphicWorkspace from './skeuomorphic_ws';
import DetailPanel from './detail/DetailPanel'; import DetailPanel from './detail/DetailPanel';
@@ -60,6 +60,7 @@ const initialAppState = {
token: STORED_TOKEN, token: STORED_TOKEN,
error: null, error: null,
isRefreshing: false, isRefreshing: false,
tenantSelection: null,
}; };
const AppStateContext = React.createContext(null); const AppStateContext = React.createContext(null);
@@ -68,11 +69,42 @@ const AppDispatchContext = React.createContext(null);
const appStateReducer = (state, action) => { const appStateReducer = (state, action) => {
switch (action.type) { switch (action.type) {
case 'LOGIN_REQUEST': case 'LOGIN_REQUEST':
return { ...state, status: 'authenticating', error: null }; return { ...state, status: 'authenticating', error: null, tenantSelection: null };
case 'LOGIN_SUCCESS': case 'LOGIN_SUCCESS':
return { ...state, status: 'authenticated', token: action.token, error: null }; return {
...state,
status: 'authenticated',
token: action.token,
error: null,
tenantSelection: null,
};
case 'LOGIN_FAILURE': case 'LOGIN_FAILURE':
return { status: 'logged-out', token: '', error: action.error || null, isRefreshing: false }; return {
status: 'logged-out',
token: '',
error: action.error || null,
isRefreshing: false,
tenantSelection: null,
};
case 'TENANT_SELECTION_REQUIRED':
return {
status: 'selecting-tenant',
token: '',
error: null,
isRefreshing: false,
tenantSelection: {
selectionToken: action.selectionToken,
tenants: action.tenants,
},
};
case 'CLEAR_TENANT_SELECTION':
return {
status: 'logged-out',
token: '',
error: null,
isRefreshing: false,
tenantSelection: null,
};
case 'BOOTSTRAP_START': case 'BOOTSTRAP_START':
return { ...state, status: 'bootstrapping', error: null }; return { ...state, status: 'bootstrapping', error: null };
case 'BOOTSTRAP_SUCCESS': case 'BOOTSTRAP_SUCCESS':
@@ -87,11 +119,18 @@ const appStateReducer = (state, action) => {
token: action.token, token: action.token,
isRefreshing: false, isRefreshing: false,
status: state.status === 'logged-out' ? 'authenticated' : state.status, status: state.status === 'logged-out' ? 'authenticated' : state.status,
tenantSelection: null,
}; };
case 'TOKEN_REFRESH_FAILURE': case 'TOKEN_REFRESH_FAILURE':
return { status: 'logged-out', token: '', error: action.error || null, isRefreshing: false }; return {
status: 'logged-out',
token: '',
error: action.error || null,
isRefreshing: false,
tenantSelection: null,
};
case 'LOGOUT': case 'LOGOUT':
return { status: 'logged-out', token: '', error: null, isRefreshing: false }; return { status: 'logged-out', token: '', error: null, isRefreshing: false, tenantSelection: null };
case 'RESET_ERROR': case 'RESET_ERROR':
return { ...state, error: null }; return { ...state, error: null };
default: default:
@@ -194,35 +233,79 @@ const DropOverlay = ({ active, folderName }) => (
</div> </div>
); );
const LoginView = ({ onSubmit, status }) => ( const LoginView = ({
<div className="login-screen"> onSubmit,
<div className="login-card"> status,
<h1>Papercrate</h1> tenantSelection,
<p>Authenticate to manage your documents.</p> onSelectTenant,
<form onSubmit={onSubmit}> onCancelSelection,
<label htmlFor="username">Username</label> selectingTenantId,
<input }) => {
id="username" const hasTenantSelection = Boolean(tenantSelection?.tenants?.length);
name="username"
placeholder="admin" return (
autoComplete="username" <div className="login-screen">
required <div className="login-card">
/> <h1>Papercrate</h1>
<label htmlFor="password">Password</label> {hasTenantSelection ? (
<input <div className="login-card__selection">
id="password" <p>Select a tenant to finish signing in.</p>
name="password" <div className="login-card__tenant-list">
type="password" {tenantSelection.tenants.map((tenant) => (
placeholder="••••••" <button
autoComplete="current-password" key={tenant.tenant_id}
required type="button"
/> onClick={() => onSelectTenant?.(tenant)}
<button type="submit">Sign in</button> disabled={Boolean(selectingTenantId)}
</form> className={
<StatusBanner status={status} /> selectingTenantId === tenant.tenant_id
? 'login-card__tenant-button is-loading'
: 'login-card__tenant-button'
}
>
{tenant.slug}
</button>
))}
</div>
<button
type="button"
className="login-card__back-button"
onClick={() => onCancelSelection?.()}
disabled={Boolean(selectingTenantId)}
>
Use a different account
</button>
</div>
) : (
<>
<p>Authenticate to manage your documents.</p>
<form onSubmit={onSubmit}>
<label htmlFor="username">Username</label>
<input
id="username"
name="username"
placeholder="admin"
autoComplete="username"
required
/>
<label htmlFor="password">Password</label>
<input
id="password"
name="password"
type="password"
placeholder="••••••"
autoComplete="current-password"
required
/>
<button type="submit">Sign in</button>
</form>
</>
)}
<StatusBanner status={status} />
</div>
</div> </div>
</div> );
); };
@@ -413,14 +496,37 @@ const AppLayout = () => {
const isAssetEquivalent = (lhs, rhs) => { const isAssetEquivalent = (lhs, rhs) => {
if (!lhs || !rhs) return false; if (!lhs || !rhs) return false;
const lhsView = createAssetView(lhs);
const rhsView = createAssetView(rhs);
const lhsPrimaryMetadata = lhsView.getPrimaryMetadata() || lhs?.metadata;
const rhsPrimaryMetadata = rhsView.getPrimaryMetadata() || rhs?.metadata;
const lhsCardinality = lhsView.getCardinality() || lhs?.cardinality || null;
const rhsCardinality = rhsView.getCardinality() || rhs?.cardinality || null;
const lhsObjects = lhsView.getObjects();
const rhsObjects = rhsView.getObjects();
const objectsComparable = lhsObjects.length === rhsObjects.length
&& lhsObjects.every((entry, index) => {
const other = rhsObjects[index];
if (!other) return false;
if (entry.ordinal !== other.ordinal) return false;
if (entry.url && other.url && entry.url === other.url) {
return true;
}
if (!entry.url && !other.url) {
return JSON.stringify(entry.metadata || null) === JSON.stringify(other.metadata || null);
}
return entry.url === other.url;
});
return ( return (
lhs.id === rhs.id && lhs.id === rhs.id &&
lhs.url === rhs.url && lhs.url === rhs.url &&
lhs?.metadata?.width === rhs?.metadata?.width && lhsPrimaryMetadata?.width === rhsPrimaryMetadata?.width &&
lhs?.metadata?.height === rhs?.metadata?.height && lhsPrimaryMetadata?.height === rhsPrimaryMetadata?.height &&
lhs.mime_type === rhs.mime_type && lhs.mime_type === rhs.mime_type &&
lhs.asset_type === rhs.asset_type && lhs.asset_type === rhs.asset_type &&
lhs.created_at === rhs.created_at lhs.created_at === rhs.created_at &&
lhsCardinality === rhsCardinality &&
objectsComparable
); );
}; };
@@ -580,7 +686,7 @@ const AppLayout = () => {
}, []); }, []);
useEffect(() => { useEffect(() => {
if (appStatus === 'logged-out') { if (appStatus === 'logged-out' || appStatus === 'selecting-tenant') {
resetWorkspaceState(); resetWorkspaceState();
} }
}, [appStatus, resetWorkspaceState]); }, [appStatus, resetWorkspaceState]);
@@ -2352,13 +2458,17 @@ const AppLayout = () => {
); );
const ensureAssetUrl = useCallback( const ensureAssetUrl = useCallback(
async (documentId, asset, { force = false } = {}) => { async (documentId, asset, { force = false, start = null, limit = null } = {}) => {
if (!documentId || !asset?.id) { if (!documentId || !asset?.id) {
return null; return null;
} }
try { try {
const entry = await assetManager.ensureAsset(documentId, asset, { force }); const entry = await assetManager.ensureAsset(documentId, asset, {
force,
start,
limit,
});
if (!entry) { if (!entry) {
return null; return null;
@@ -3919,19 +4029,32 @@ const AppLayout = () => {
setLoading(true); setLoading(true);
appDispatch({ type: 'LOGIN_REQUEST' }); appDispatch({ type: 'LOGIN_REQUEST' });
const { data } = await api.post('/auth/login', payload); const { data } = await api.post('/auth/login', payload);
appDispatch({ type: 'LOGIN_SUCCESS', token: data.access_token });
setStatusMessage('Login successful.', 'success'); if (data?.selection_token && Array.isArray(data?.tenants)) {
} catch (error) { appDispatch({
appDispatch({ type: 'TENANT_SELECTION_REQUIRED',
type: 'LOGIN_FAILURE', selectionToken: data.selection_token,
error: error?.response?.data?.error || 'Login failed. Check credentials.', tenants: data.tenants,
}); });
notifyApiError(error, 'Login failed. Check credentials.'); setStatusMessage('Select a tenant to continue.', 'info');
} finally { return;
setLoading(false); }
}
}, if (!data?.access_token) {
[appDispatch, notifyApiError, setStatusMessage], throw new Error('Invalid login response.');
}
appDispatch({ type: 'LOGIN_SUCCESS', token: data.access_token });
setStatusMessage('Login successful.', 'success');
} catch (error) {
const message = error?.response?.data?.error || 'Login failed. Check credentials.';
appDispatch({ type: 'LOGIN_FAILURE', error: message });
notifyApiError(error, message);
} finally {
setLoading(false);
}
},
[appDispatch, notifyApiError, setStatusMessage],
); );
const handleLogout = useCallback(async () => { const handleLogout = useCallback(async () => {
@@ -3947,26 +4070,6 @@ const AppLayout = () => {
} }
}, [appDispatch, setStatusMessage]); }, [appDispatch, setStatusMessage]);
const handleBulkReanalyze = useCallback(async () => {
try {
setLoading(true);
const { data } = await api.post('/documents/reanalyze');
const total = data?.queued ?? 0;
const suffix = total === 1 ? '' : 's';
setStatusMessage(
`Queued re-analysis for ${total} document${suffix}.`,
'success',
);
} catch (error) {
const message =
error.response?.data?.error || 'Failed to queue document re-analysis.';
notifyApiError(error, message);
} finally {
setLoading(false);
}
}, [notifyApiError, setStatusMessage]);
const folderClickHandlers = { const folderClickHandlers = {
onToggle: async (folderId) => { onToggle: async (folderId) => {
const node = folderNodes.get(folderId); const node = folderNodes.get(folderId);
@@ -4477,7 +4580,6 @@ const AppLayout = () => {
status, status,
setStatusMessage, setStatusMessage,
dropOverlayState, dropOverlayState,
handleBulkReanalyze,
handleLogout, handleLogout,
sidebarProps, sidebarProps,
tags, tags,
@@ -4515,7 +4617,6 @@ const AppLayout = () => {
status, status,
setStatusMessage, setStatusMessage,
dropOverlayState, dropOverlayState,
handleBulkReanalyze,
handleLogout, handleLogout,
sidebarProps, sidebarProps,
tags, tags,
@@ -4549,7 +4650,7 @@ const AppLayout = () => {
], ],
); );
if (appStatus === 'logged-out' || appStatus === 'authenticating') { if (['logged-out', 'authenticating', 'selecting-tenant'].includes(appStatus)) {
return ( return (
<Navigate <Navigate
to="/account/login" to="/account/login"
@@ -4634,13 +4735,6 @@ const AppLayout = () => {
</div> </div>
)} )}
<div className="app-bar__actions"> <div className="app-bar__actions">
<button
className="secondary"
type="button"
onClick={handleBulkReanalyze}
>
Re-analyze All
</button>
<button className="secondary" onClick={handleLogout}> <button className="secondary" onClick={handleLogout}>
Log out Log out
</button> </button>
@@ -4728,10 +4822,11 @@ const DocumentsRoute = () => {
}; };
const LoginRoute = () => { const LoginRoute = () => {
const { status: appStatus } = useAppState(); const { status: appStatus, tenantSelection } = useAppState();
const appDispatch = useAppDispatch(); const appDispatch = useAppDispatch();
const location = useLocation(); const location = useLocation();
const [status, setStatus] = useState(null); const [status, setStatus] = useState(null);
const [selectingTenantId, setSelectingTenantId] = useState(null);
const setStatusMessage = useCallback((message, variant = 'info') => { const setStatusMessage = useCallback((message, variant = 'info') => {
setStatus(message ? { message, variant } : null); setStatus(message ? { message, variant } : null);
@@ -4766,6 +4861,20 @@ const LoginRoute = () => {
try { try {
appDispatch({ type: 'LOGIN_REQUEST' }); appDispatch({ type: 'LOGIN_REQUEST' });
const { data } = await api.post('/auth/login', payload); const { data } = await api.post('/auth/login', payload);
if (data?.selection_token && Array.isArray(data?.tenants)) {
appDispatch({
type: 'TENANT_SELECTION_REQUIRED',
selectionToken: data.selection_token,
tenants: data.tenants,
});
setStatusMessage('Select a tenant to continue.', 'info');
return;
}
if (!data?.access_token) {
throw new Error('Invalid login response.');
}
appDispatch({ type: 'LOGIN_SUCCESS', token: data.access_token }); appDispatch({ type: 'LOGIN_SUCCESS', token: data.access_token });
setStatusMessage('Login successful.', 'success'); setStatusMessage('Login successful.', 'success');
} catch (error) { } catch (error) {
@@ -4777,6 +4886,45 @@ const LoginRoute = () => {
[appDispatch, notifyLoginError, setStatusMessage], [appDispatch, notifyLoginError, setStatusMessage],
); );
const handleTenantSelect = useCallback(
async (tenant) => {
if (!tenantSelection?.selectionToken || !tenant?.tenant_id) {
return;
}
try {
setSelectingTenantId(tenant.tenant_id);
const { data } = await api.post(
'/auth/select-tenant',
{ tenant_id: tenant.tenant_id },
{
headers: {
Authorization: `Bearer ${tenantSelection.selectionToken}`,
},
},
);
if (!data?.access_token) {
throw new Error('Invalid tenant selection response.');
}
appDispatch({ type: 'LOGIN_SUCCESS', token: data.access_token });
setStatusMessage('Login successful.', 'success');
} catch (error) {
const message = error?.response?.data?.error || 'Failed to finalize login.';
notifyLoginError(error, message, 'error');
} finally {
setSelectingTenantId(null);
}
},
[appDispatch, notifyLoginError, setStatusMessage, tenantSelection],
);
const handleCancelSelection = useCallback(() => {
appDispatch({ type: 'CLEAR_TENANT_SELECTION' });
setStatusMessage(null);
}, [appDispatch, setStatusMessage]);
const redirectTarget = useMemo(() => { const redirectTarget = useMemo(() => {
const target = location.state?.from; const target = location.state?.from;
if (typeof target === 'string' && target.startsWith('/')) { if (typeof target === 'string' && target.startsWith('/')) {
@@ -4785,13 +4933,20 @@ const LoginRoute = () => {
return '/documents'; return '/documents';
}, [location.state]); }, [location.state]);
if (appStatus !== 'logged-out' && appStatus !== 'authenticating') { if (!['logged-out', 'authenticating', 'selecting-tenant'].includes(appStatus)) {
return <Navigate to={redirectTarget} replace />; return <Navigate to={redirectTarget} replace />;
} }
return ( return (
<div className="app-shell"> <div className="app-shell">
<LoginView onSubmit={handleLogin} status={status} /> <LoginView
onSubmit={handleLogin}
status={status}
tenantSelection={tenantSelection}
onSelectTenant={handleTenantSelect}
onCancelSelection={handleCancelSelection}
selectingTenantId={selectingTenantId}
/>
</div> </div>
); );
}; };
-1
View File
@@ -275,7 +275,6 @@ const Sidebar = ({
<span <span
className={className} className={className}
role="button" role="button"
tabIndex={0}
onClick={handleSelect} onClick={handleSelect}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') { if (event.key === 'Enter' || event.key === ' ') {
+54
View File
@@ -189,6 +189,59 @@
user-select: none; user-select: none;
} }
.skeuo-card__nav {
position: absolute;
bottom: calc(3em * 0.707 * var(--nav-scale));
left: 50%;
transform: translateX(-50%) scale(calc(0.707 * var(--nav-scale, 1)));
transform-origin: center;
display: flex;
gap: 2rem;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
}
.skeuo-item__card:hover .skeuo-card__nav {
opacity: 1;
pointer-events: auto;
}
.skeuo-card__nav-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 3.3em;
height: 3.3em;
padding: 0.36em;
border-radius: 3.3em;
border: none;
background: rgba(0, 0, 0, 0.55);
color: #fff;
cursor: pointer;
transition: background 0.15s ease, opacity 0.15s ease;
}
.skeuo-card__nav-button:hover:not([disabled]) {
background: rgba(0, 0, 0, 0.75);
}
.skeuo-card__nav-button:disabled {
opacity: 0.4;
cursor: default;
}
.skeuo-card__nav-button:focus-visible {
outline: 2px solid var(--accent, #2684ff);
outline-offset: 2px;
}
.skeuo-card__nav-button svg {
width: 100%;
height: 100%;
}
.skeuo-item__tags .skeuo-tag.is-tear-pending { .skeuo-item__tags .skeuo-tag.is-tear-pending {
opacity: 0.4; opacity: 0.4;
} }
@@ -210,6 +263,7 @@ body.skeuo-cursor-remove * {
justify-content: center; justify-content: center;
box-shadow: 0 24px 72px rgba(0, 0, 0, 0.24); box-shadow: 0 24px 72px rgba(0, 0, 0, 0.24);
overflow: hidden; overflow: hidden;
--nav-scale: 1;
} }
.skeuo-item__card--empty { .skeuo-item__card--empty {
+115 -31
View File
@@ -6,7 +6,9 @@ import React, {
useRef, useRef,
useState, useState,
} from 'react'; } from 'react';
import { resolveDocumentAssetUrl } from './asset_manager'; import { resolveDocumentAssetUrl, createAssetView } from './asset_manager';
import { useAssetNavigator } from './hooks/useAssetNavigator';
import { ArrowLeftIcon, ArrowRightIcon } from './ui/icons';
import { getReadableTextColor } from './utils/colors'; import { getReadableTextColor } from './utils/colors';
import './skeuomorphic_ws.css'; import './skeuomorphic_ws.css';
@@ -43,6 +45,107 @@ const resolveTagKey = (tag) => {
return key != null ? String(key) : null; return key != null ? String(key) : null;
}; };
const SkeuoPreviewCard = ({
doc,
title,
ensureAssetUrl,
getDocumentAsset,
navScale = 1,
prefetch = 3,
}) => {
const navigator = useAssetNavigator({
document: doc,
assetType: 'preview',
ensureAssetUrl,
getAsset: getDocumentAsset,
prefetch,
});
const { currentUrl, cardinality, canGoPrev, canGoNext } = navigator;
const hasPreview = Boolean(currentUrl);
const cardClasses = ['skeuo-item__card'];
if (!hasPreview) cardClasses.push('skeuo-item__card--empty');
const showNav = hasPreview && (cardinality > 1 || canGoPrev || canGoNext);
const navStyle = useMemo(() => ({ '--nav-scale': navScale }), [navScale]);
return (
<div className={cardClasses.join(' ')}>
{hasPreview ? (
<img src={currentUrl} alt={title} />
) : (
<div className="skeuo-item__empty">
<div className="skeuo-item__placeholder">DOC</div>
<div className="skeuo-item__title" title={title}>
{title}
</div>
</div>
)}
{showNav ? (
<div className="skeuo-card__nav" style={navStyle}>
<button
type="button"
className="skeuo-card__nav-button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
navigator.goPrev();
}}
onPointerDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onPointerUp={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onMouseUp={(event) => {
event.preventDefault();
event.stopPropagation();
}}
disabled={!canGoPrev}
aria-label="Previous preview"
>
<ArrowLeftIcon />
</button>
<button
type="button"
className="skeuo-card__nav-button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
navigator.goNext();
}}
onPointerDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onPointerUp={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onMouseUp={(event) => {
event.preventDefault();
event.stopPropagation();
}}
disabled={!canGoNext}
aria-label="Next preview"
>
<ArrowRightIcon />
</button>
</div>
) : null}
</div>
);
};
const generateInitialLayout = ( const generateInitialLayout = (
entries, entries,
{ {
@@ -502,8 +605,10 @@ const SkeuomorphicWorkspace = ({
(doc) => { (doc) => {
if (!doc) return null; if (!doc) return null;
const asset = resolvePreviewAsset(doc); const asset = resolvePreviewAsset(doc);
const width = asset?.metadata?.width; const view = createAssetView(asset);
const height = asset?.metadata?.height; const metadata = view.getPrimaryMetadata() || {};
const width = metadata?.width;
const height = metadata?.height;
if (typeof width === 'number' && typeof height === 'number') { if (typeof width === 'number' && typeof height === 'number') {
return { width, height }; return { width, height };
} }
@@ -512,17 +617,6 @@ const SkeuomorphicWorkspace = ({
[resolvePreviewAsset], [resolvePreviewAsset],
); );
const resolvePreviewUrl = useCallback(
(doc) => {
if (!doc) return null;
return resolveDocumentAssetUrl(doc, 'preview', {
ensureAssetUrl,
getAsset: getDocumentAsset,
});
},
[ensureAssetUrl, getDocumentAsset],
);
useEffect(() => { useEffect(() => {
if (!ensureAssetUrl) { if (!ensureAssetUrl) {
return; return;
@@ -1489,9 +1583,6 @@ const SkeuomorphicWorkspace = ({
const totalScale = zoomScale > 0 ? zoomScale : 1; const totalScale = zoomScale > 0 ? zoomScale : 1;
const inverseTagScale = totalScale > 0 ? 1 / totalScale : 1; const inverseTagScale = totalScale > 0 ? 1 / totalScale : 1;
const tagsStyle = { '--tag-scale': inverseTagScale }; const tagsStyle = { '--tag-scale': inverseTagScale };
const previewUrl = resolvePreviewUrl(doc);
const imageUrl = previewUrl;
const hasPreview = Boolean(imageUrl);
const title = doc.title || doc.original_name || 'Document'; const title = doc.title || doc.original_name || 'Document';
const dragging = draggingId === doc.id; const dragging = draggingId === doc.id;
const tags = Array.isArray(doc.tags) ? doc.tags : []; const tags = Array.isArray(doc.tags) ? doc.tags : [];
@@ -1508,8 +1599,6 @@ const SkeuomorphicWorkspace = ({
if (dropActive) itemClasses.push('is-tag-target'); if (dropActive) itemClasses.push('is-tag-target');
if (dropPending) itemClasses.push('is-tag-pending'); if (dropPending) itemClasses.push('is-tag-pending');
if (!matchesFilter) itemClasses.push('is-filtered-out'); if (!matchesFilter) itemClasses.push('is-filtered-out');
const cardClasses = ['skeuo-item__card'];
if (!hasPreview) cardClasses.push('skeuo-item__card--empty');
const docTagTokens = docTagKeys.join(' '); const docTagTokens = docTagKeys.join(' ');
return ( return (
<div <div
@@ -1545,18 +1634,13 @@ const SkeuomorphicWorkspace = ({
}} }}
> >
<div className="skeuo-item__body" style={bodyStyle}> <div className="skeuo-item__body" style={bodyStyle}>
<div className={cardClasses.join(' ')}> <SkeuoPreviewCard
{hasPreview ? ( doc={doc}
<img src={imageUrl} alt={title} /> title={title}
) : ( ensureAssetUrl={ensureAssetUrl}
<div className="skeuo-item__empty"> getDocumentAsset={getDocumentAsset}
<div className="skeuo-item__placeholder">DOC</div> navScale={inverseTagScale}
<div className="skeuo-item__title" title={title}> />
{title}
</div>
</div>
)}
</div>
{tags.length > 0 && ( {tags.length > 0 && (
<div className="skeuo-item__tags" aria-hidden="true" style={tagsStyle}> <div className="skeuo-item__tags" aria-hidden="true" style={tagsStyle}>
{tags.map((tag) => { {tags.map((tag) => {
+136 -6
View File
@@ -393,7 +393,7 @@ button.icon-button.ghost:hover:not([disabled]) {
.documents-main { .documents-main {
flex: 1; flex: 1;
display: grid; display: grid;
grid-template-columns: 20rem minmax(0, 1fr) 20rem; grid-template-columns: 20rem minmax(0, 1fr) 30rem;
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
} }
@@ -751,7 +751,6 @@ button.icon-button.ghost:hover:not([disabled]) {
} }
.column + .column { .column + .column {
border-left: none;
padding-left: 1.25rem; padding-left: 1.25rem;
} }
@@ -847,7 +846,6 @@ button.icon-button.ghost:hover:not([disabled]) {
color: inherit; color: inherit;
transition: background 0.12s ease, color 0.12s ease; transition: background 0.12s ease, color 0.12s ease;
position: relative; position: relative;
display: inline-block;
} }
.folder-row span.name { .folder-row span.name {
@@ -1511,6 +1509,7 @@ button.icon-button.ghost:hover:not([disabled]) {
position: relative; position: relative;
padding: 1.25rem; padding: 1.25rem;
overflow-y: auto; overflow-y: auto;
border-left: 1px solid var(--border);
} }
.detail-panel .column-body { .detail-panel .column-body {
@@ -1591,6 +1590,7 @@ button.icon-button.ghost:hover:not([disabled]) {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
overflow: visible; overflow: visible;
position: relative;
} }
.thumbnail-preview { .thumbnail-preview {
@@ -1673,7 +1673,7 @@ button.icon-button.ghost:hover:not([disabled]) {
.preview-stack--stacked { .preview-stack--stacked {
width: 100%; width: 100%;
min-height: 320px; min-height: 420px;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -1729,11 +1729,75 @@ button.icon-button.ghost:hover:not([disabled]) {
box-shadow: box-shadow:
inset 0 0 0 999px var(--accent-elevated), inset 0 0 0 999px var(--accent-elevated),
0 6px 18px var(--accent-elevated-strong); 0 6px 18px var(--accent-elevated-strong);
filter: saturate(118%) brightness(1.05);
} }
.preview-pane--stack { .preview-pane--stack {
min-height: 260px; min-height: 460px;
position: relative;
--preview-nav-scale: 1;
}
.preview-pane__nav-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.2em;
height: 2.2em;
padding: 0.45em;
border-radius: 999px;
border: none;
background: rgba(0, 0, 0, 0.55);
color: #fff;
cursor: pointer;
transition: background 0.15s ease, opacity 0.15s ease;
pointer-events: auto;
}
.preview-pane__nav-button:hover:not([disabled]) {
background: rgba(0, 0, 0, 0.75);
}
.preview-pane__nav-button:disabled {
opacity: 0.4;
cursor: default;
}
.preview-pane__nav-button:focus-visible {
outline: 2px solid var(--accent, #2684ff);
outline-offset: 2px;
}
.preview-pane__nav {
margin-top: 0.5rem;
display: flex;
justify-content: center;
gap: 0.5rem;
}
.preview-pane__nav--overlay {
position: absolute;
bottom: 0.75rem;
left: 50%;
transform: translateX(-50%) scale(var(--preview-nav-scale, 1));
margin-top: 0;
pointer-events: none;
z-index: 20;
opacity: 0;
transition: opacity 0.2s ease;
}
.preview-pane__nav--overlay .preview-pane__nav-button {
pointer-events: auto;
transform: scale(calc(1 / var(--preview-nav-scale, 1)));
}
.preview-pane--stack:hover .preview-pane__nav--overlay,
.skeuo-item__card:hover .preview-pane__nav--overlay {
opacity: 1;
}
.skeuo-card__nav {
bottom: 0.5rem;
} }
.bulk-tags { .bulk-tags {
@@ -1986,6 +2050,68 @@ form.inline {
.login-card .status-banner { .login-card .status-banner {
margin-bottom: 0; margin-bottom: 0;
} }
.login-card__selection {
display: flex;
flex-direction: column;
gap: 0.9rem;
}
.login-card__tenant-list {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.login-card__tenant-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
padding: 0.65rem 0.75rem;
border-radius: 0.6rem;
border: 1px solid var(--border);
background: var(--surface);
color: inherit;
font-weight: 600;
font-size: 0.95rem;
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease, transform 0.15s ease;
}
.login-card__tenant-button:hover:not([disabled]) {
background: var(--surface-hover, rgba(255, 255, 255, 0.1));
border-color: var(--border-strong, var(--border));
}
.login-card__tenant-button:disabled {
opacity: 0.6;
cursor: wait;
}
.login-card__tenant-button.is-loading {
opacity: 0.6;
}
.login-card__back-button {
align-self: flex-start;
background: none;
border: none;
padding: 0;
color: var(--accent);
font-size: 0.85rem;
cursor: pointer;
text-decoration: underline;
}
.login-card__back-button:hover {
text-decoration: none;
}
.login-card__back-button:disabled {
opacity: 0.6;
cursor: default;
}
.column-toolbar .filter-bar { .column-toolbar .filter-bar {
margin-bottom: 0; margin-bottom: 0;
} }
@@ -2024,3 +2150,7 @@ form.inline {
width: 0.9rem; width: 0.9rem;
height: 0.9rem; height: 0.9rem;
} }
.preview-pane__nav-button svg {
width: 100%;
height: 100%;
}
+20
View File
@@ -7,6 +7,8 @@ import {
IconTrash, IconTrash,
IconLayoutList, IconLayoutList,
IconLayoutGrid, IconLayoutGrid,
IconArrowLeft,
IconArrowRight,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import FolderSvg from '../assets/folder.svg'; import FolderSvg from '../assets/folder.svg';
@@ -100,6 +102,24 @@ export const ViewGridIcon = ({ className, size = '1em', stroke = 1.6, ...rest })
/> />
); );
export const ArrowLeftIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<IconArrowLeft
className={composeClassName('icon', className)}
size={size}
stroke={stroke}
{...rest}
/>
);
export const ArrowRightIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<IconArrowRight
className={composeClassName('icon', className)}
size={size}
stroke={stroke}
{...rest}
/>
);
export default { export default {
ChevronIcon, ChevronIcon,
TrashIcon, TrashIcon,
@@ -1,34 +0,0 @@
{{- if and .Values.quickwit.enabled .Values.quickwit.indexJob.enabled .Values.quickwit.indexJob.indexConfig.create }}
{{- $cfg := .Values.quickwit.indexJob.indexConfig -}}
{{- $config := dict "value" "" -}}
{{- if $cfg.file }}
{{- with $.Files.Get $cfg.file -}}
{{- $_ := set $config "value" . -}}
{{- end -}}
{{- end -}}
{{- if and (not (index $config "value")) $cfg.contents }}
{{- $_ := set $config "value" $cfg.contents -}}
{{- end -}}
{{- if eq (trim (default "" (index $config "value"))) "" -}}
{{- fail "Quickwit index configuration requires either indexConfig.file or indexConfig.contents" -}}
{{- end }}
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "papercrate.fullname" . }}-quickwit-index
labels:
{{- include "papercrate.labels" . | nindent 4 }}
app.kubernetes.io/component: quickwit
app.kubernetes.io/part-of: quickwit-index
data:
{{ $cfg.key }}: |
{{ index $config "value" | indent 4 }}
{{- $ctx := dict "storageUri" (.Values.quickwit.env.storageUri | default "") -}}
{{- if and (eq (index $ctx "storageUri") "") .Values.global.s3.bucket -}}
{{- $_ := set $ctx "storageUri" (printf "s3://%s/quickwit" .Values.global.s3.bucket) -}}
{{- end -}}
{{- $storageUri := index $ctx "storageUri" -}}
{{- if $storageUri }}
{{ printf "index_uri: %s/documents" (trimSuffix "/" $storageUri) | indent 4 }}
{{- end }}
{{- end }}
@@ -1,78 +0,0 @@
{{- if and .Values.quickwit.enabled .Values.quickwit.indexJob.enabled }}
apiVersion: batch/v1
kind: Job
metadata:
name: {{ include "papercrate.fullname" . }}-quickwit-index
labels:
{{- include "papercrate.labels" . | nindent 4 }}
app.kubernetes.io/component: quickwit-index
annotations:
"helm.sh/hook": post-install,post-upgrade
"helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
spec:
backoffLimit: {{ .Values.quickwit.indexJob.backoffLimit }}
template:
metadata:
labels:
{{- include "papercrate.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: quickwit-index
spec:
restartPolicy: Never
{{- if or .Values.global.imagePullSecrets .Values.imagePullSecrets }}
imagePullSecrets:
{{- range .Values.global.imagePullSecrets }}
- name: {{ . }}
{{- end }}
{{- range .Values.imagePullSecrets }}
- name: {{ . }}
{{- end }}
{{- end }}
{{- $qw := dict "storageUri" .Values.quickwit.env.storageUri "metastoreUri" .Values.quickwit.env.metastoreUri -}}
{{- if and (not (index $qw "storageUri")) .Values.global.s3.bucket -}}
{{- $_ := set $qw "storageUri" (printf "s3://%s/quickwit" .Values.global.s3.bucket) -}}
{{- end -}}
{{- if not (index $qw "storageUri") -}}
{{- fail "quickwit.env.storageUri must be set, or provide global.s3.bucket" -}}
{{- end -}}
{{- if and (not (index $qw "metastoreUri")) .Values.postgres.enabled -}}
{{- $dbUser := required "postgres.auth.username is required when postgres.enabled" .Values.postgres.auth.username -}}
{{- $dbPass := required "postgres.auth.password is required when postgres.enabled" .Values.postgres.auth.password -}}
{{- $dbHost := printf "%s-postgres" (include "papercrate.fullname" .) -}}
{{- $_ := set $qw "metastoreUri" (printf "postgres://%s:%s@%s:5432/quickwit" $dbUser $dbPass $dbHost) -}}
{{- end -}}
{{- if not (index $qw "metastoreUri") -}}
{{- fail "quickwit.env.metastoreUri must be set when not using bundled Postgres" -}}
{{- end }}
containers:
- name: quickwit-index
image: {{ include "papercrate.quickwit.image" . }}:{{ include "papercrate.image.tag" (dict "image" .Values.quickwit.image "Chart" .Chart) }}
imagePullPolicy: {{ .Values.quickwit.image.pullPolicy }}
command:
- quickwit
- index
- create
args:
- --index-config=/config/{{ .Values.quickwit.indexJob.indexConfig.key }}
- --endpoint=http://{{ include "papercrate.fullname" . }}-quickwit:{{ .Values.quickwit.service.port }}
{{- if or .Values.quickwit.envFrom .Values.global.s3.credentialsSecret }}
envFrom:
{{- with .Values.global.s3.credentialsSecret }}
- secretRef:
name: {{ . }}
{{- end }}
{{- range .Values.quickwit.envFrom }}
- {{ toYaml . | nindent 12 | trim }}
{{- end }}
{{- end }}
{{- if or .Values.quickwit.indexJob.indexConfig.existingConfigMap .Values.quickwit.indexJob.indexConfig.create }}
volumeMounts:
- name: index-config
mountPath: /config
{{- end }}
{{- if or .Values.quickwit.indexJob.indexConfig.existingConfigMap .Values.quickwit.indexJob.indexConfig.create }}
volumes:
- name: index-config
configMap:
name: {{ if .Values.quickwit.indexJob.indexConfig.existingConfigMap }}{{ .Values.quickwit.indexJob.indexConfig.existingConfigMap }}{{ else }}{{ include "papercrate.fullname" . }}-quickwit-index{{ end }}
{{- end }}
{{- end }}
-10
View File
@@ -141,16 +141,6 @@ quickwit:
nodeSelector: {} nodeSelector: {}
tolerations: [] tolerations: []
affinity: {} affinity: {}
indexJob:
enabled: true
backoffLimit: 3
indexConfig:
existingConfigMap: ""
key: documents-index.yaml
create: true
file: files/quickwit/documents-index.yaml
contents: ""
imagePullSecrets: [] imagePullSecrets: []
nameOverride: "" nameOverride: ""
fullnameOverride: "" fullnameOverride: ""
-26
View File
@@ -1,26 +0,0 @@
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: 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]