78 Commits
Author SHA1 Message Date
nils a3c6a7c633 license 2025-10-17 18:06:08 +02:00
nils fc3d6cb826 foo 2025-10-17 18:03:00 +02:00
nils f9e798f3b3 include_documents for /contents 2025-10-17 18:01:07 +02:00
nils 831ec1d989 fix 2025-10-17 17:56:09 +02:00
nils 757536c277 remove path_cache 2025-10-17 17:42:20 +02:00
nils d003f0783d count pages per version 2025-10-17 17:34:23 +02:00
nils 5666dc4a4b style 2025-10-17 17:19:45 +02:00
nils c2b0351598 ocr view 2025-10-16 18:03:31 +02:00
nils a8a3d95c40 lowercase before index 2025-10-16 17:30:41 +02:00
nils 97e800bfd5 view ocr text 2025-10-16 17:10:23 +02:00
nils d7c65ed551 fix
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 19s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 47s
2025-10-16 16:22:58 +02:00
nils 9405f07f06 style
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 3m24s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 3m43s
2025-10-16 16:15:36 +02:00
nils 41f17733a5 grid view
ci / docker (backend, backend/Dockerfile, backend) (push) Has been cancelled
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Has started running
2025-10-16 16:03:51 +02:00
nils 3019172b88 backend 2025-10-16 15:10:21 +02:00
nils b741c181e0 tags drags
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 21s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 47s
2025-10-16 13:51:12 +02:00
nils e45574779b fixes 2025-10-16 12:32:03 +02:00
nils fecb7af3f6 sidebar tags style 2025-10-16 12:22:19 +02:00
nils e1e046f846 fixes
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 8m18s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 8m17s
2025-10-16 12:07:42 +02:00
nils d1f0a2d48b search
ci / docker (backend, backend/Dockerfile, backend) (push) Has been cancelled
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Has been cancelled
2025-10-16 11:59:36 +02:00
nils b9057f6d56 frontend: separate views
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 14m53s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 14m51s
2025-10-16 11:16:44 +02:00
nils 44b59edcb3 backend: reduce number of concurrent db connections 2025-10-16 11:10:53 +02:00
nils 05dffec42e design
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 22s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 47s
2025-10-16 10:38:13 +02:00
nils aef34791f3 a lot of stuff
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 14m29s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 14m28s
2025-10-16 00:38:03 +02:00
nils 5b420a6b62 update filename on title change
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Failing after 13m52s
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 13m57s
2025-10-15 22:00:14 +02:00
nils 939f248499 unique (folder_id, title) for documents 2025-10-15 21:56:20 +02:00
nils a8d362ff89 index documents (folder_id, title) 2025-10-15 21:36:19 +02:00
nils 068f96880b tags, folder rename, other stuff 2025-10-15 21:32:17 +02:00
nils b5119a8e6f frontend: desk layout
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 18s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 45s
2025-10-15 19:52:15 +02:00
nils 6b89913efd design
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 18s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 43s
2025-10-15 17:46:18 +02:00
nils 01c8599853 changes
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 18s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 46s
2025-10-15 17:19:26 +02:00
nils 43d27cfd27 thumbnails
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 18s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 45s
2025-10-15 16:56:34 +02:00
nils 2d2e046c9d dragging 2025-10-15 16:50:45 +02:00
nils abee49a335 fixes 2025-10-15 16:16:37 +02:00
nils 5b5c1c4c0b design
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 22s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 47s
2025-10-15 15:03:19 +02:00
nils 5c8cd98aac correspondents
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 14m8s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 14m7s
2025-10-15 00:20:54 +02:00
nils 3db221cce4 tag deletion 2025-10-14 23:35:29 +02:00
nils 60939306e7 frontend: size format 2025-10-14 23:23:26 +02:00
nils 80161fe86e frontend 2025-10-14 23:20:42 +02:00
nils 4c36ea2e9a backend: correspondents 2025-10-14 23:20:42 +02:00
nils 32432026db backend: health without auth 2025-10-14 22:37:20 +02:00
nils 8a9f3644a1 split
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 19s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 46s
2025-10-14 15:49:33 +02:00
nils d7f221850f design
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 19s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 1m1s
2025-10-14 12:21:39 +02:00
nils 4d5f7b4ccd desk view 2025-10-14 10:27:13 +02:00
nils 9aa43d2753 7
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 12m50s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 12m49s
2025-10-14 02:43:38 +02:00
nils c17a9484ca a lot of things
ci / docker (backend, backend/Dockerfile, backend) (push) Failing after 9m32s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 9m48s
2025-10-14 00:56:25 +02:00
nils 9060905e7a assets 2025-10-13 14:49:37 +02:00
nils 159577dff9 backend: generate high resolution preview 2025-10-13 02:39:02 +02:00
nils 86c75cea8e frontend: keyboard nav 2025-10-12 18:56:10 +02:00
nils 3f0c607383 frontend api fix 2025-10-12 18:55:58 +02:00
nils 1cc230985f frontend: fix document deletion
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 16s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 33s
2025-10-12 16:54:42 +02:00
nils 9cd69dc00c frontend: fix uploads
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 19s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 33s
2025-10-12 16:01:28 +02:00
nils 111d2cada3 helm chart 2025-10-12 15:14:01 +02:00
nils a55a476d97 backend
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 14m2s
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 14m20s
2025-10-12 14:08:44 +02:00
nils 6724634870 backend 2025-10-12 14:07:10 +02:00
nils 797a641263 frontend 2025-10-12 14:07:02 +02:00
nils a2d7caa6e4 rm libpdfium from git 2025-10-12 13:53:16 +02:00
nils 351d635f39 frontend api url
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 15s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 32s
2025-10-12 13:40:27 +02:00
nils 9147442822 fix dockerfile
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Failing after 6s
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 28s
2025-10-12 13:10:04 +02:00
nils 6987fe1bfe frontend: login route
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 4m50s
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 4m52s
2025-10-12 12:23:45 +02:00
nils 4b74ee54b0 rename backend binary
ci / docker (backend, backend/Dockerfile, backend) (push) Has been cancelled
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Has been cancelled
2025-10-12 12:13:46 +02:00
nils dc22b12df3 rename to papercrate 2025-10-12 02:35:56 +02:00
nils d9a36a99a7 dockerfiles, TagsWorkspace 2025-10-12 01:59:26 +02:00
nils f30e455c2d patch tags 2025-10-12 01:52:30 +02:00
nils 1b950a8f9a fix breadcrumbs 2025-10-11 22:47:31 +02:00
nils 3a5d7607e3 debouncing delay 2025-10-11 22:17:26 +02:00
nils 7233e3a534 basic search 2025-10-11 17:05:12 +02:00
nils 0a669064dd quickwit 2025-10-11 16:42:25 +02:00
nils cd27f36e3d ocr 2025-10-11 14:39:58 +02:00
nils 6f6abef5d2 logout 2025-10-11 13:36:27 +02:00
nils a21853c874 preview stack, refresh token, design 2025-10-11 13:12:05 +02:00
nils 6850918e3d 6 2025-10-11 11:54:57 +02:00
nils 41a9e8f76c preview-stack 2025-10-11 11:09:38 +02:00
nils 30f3fe015b 4 2025-10-10 22:26:03 +02:00
nils 5fd4a41e64 frontend: preview 2025-10-10 18:59:19 +02:00
nils dfadc8ba23 3 2025-10-10 15:53:38 +02:00
nils ddce0e39b3 2 2025-10-10 09:39:58 +02:00
nils 4a428b9af6 tests 2025-10-09 22:41:04 +02:00
nils 768f8cb21c 1 2025-10-09 22:16:29 +02:00
100 changed files with 2930 additions and 6829 deletions
-10
View File
@@ -1,15 +1,5 @@
# Papercrate # Papercrate
## Local Development
Use the provided `papercrate.tmux` to spin up the full stack in one tmux session:
```bash
tmux -f papercrate.tmux attach
```
This creates windows for the compose stack, frontend dev server, backend API, and background worker using the repository-relative paths defined in the tmux file. Detach with `Ctrl+b d` and reattach later with the same command.
## Backend Integration Tests ## Backend Integration Tests
Integration tests require a running Postgres instance (and, optionally, Quickwit for OCR indexing). The repository includes a lightweight compose file for local runs: Integration tests require a running Postgres instance (and, optionally, Quickwit for OCR indexing). The repository includes a lightweight compose file for local runs:
-1
View File
@@ -44,7 +44,6 @@ 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"
# Error handling # Error handling
thiserror = "1.0" thiserror = "1.0"
+1 -2
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 --bin admin RUN cargo build --release --bin backend --bin worker --bin webdav
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,7 +53,6 @@ RUN apt-get update \
COPY --from=builder /app/target/release/backend /usr/local/bin/papercrate-backend COPY --from=builder /app/target/release/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
@@ -0,0 +1,19 @@
use argon2::{
password_hash::{PasswordHasher, SaltString},
Argon2,
};
use rand::thread_rng;
use std::env;
fn main() {
let password = env::args()
.nth(1)
.expect("Usage: cargo run --example hash_password <password>");
let salt = SaltString::generate(&mut thread_rng());
let argon2 = Argon2::default();
let hash = argon2
.hash_password(password.as_bytes(), &salt)
.expect("hashing failed")
.to_string();
println!("{}", hash);
}
@@ -0,0 +1,11 @@
DROP INDEX IF EXISTS idx_document_tags_tag;
DROP TABLE IF EXISTS document_tags;
DROP INDEX IF EXISTS idx_document_versions_document;
DROP TABLE IF EXISTS document_versions;
DROP INDEX IF EXISTS idx_documents_deleted_at;
DROP INDEX IF EXISTS idx_documents_folder;
DROP TABLE IF EXISTS documents;
DROP INDEX IF EXISTS idx_folders_parent;
DROP TABLE IF EXISTS folders;
DROP TABLE IF EXISTS tags;
DROP TABLE IF EXISTS users;
@@ -0,0 +1,77 @@
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE TABLE users (
id UUID PRIMARY KEY,
username VARCHAR(100) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
role VARCHAR(16) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE folders (
id UUID PRIMARY KEY,
name VARCHAR(255) NOT NULL,
parent_id UUID REFERENCES folders(id) ON DELETE SET NULL,
path_cache VARCHAR(1000),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT folders_parent_name_unique UNIQUE (parent_id, name)
);
CREATE INDEX idx_folders_parent ON folders(parent_id);
CREATE TABLE documents (
id UUID PRIMARY KEY,
filename VARCHAR(255) NOT NULL,
original_name VARCHAR(255) NOT NULL,
content_type VARCHAR(100),
folder_id UUID REFERENCES folders(id) ON DELETE SET NULL,
current_version INTEGER NOT NULL,
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb
);
CREATE INDEX idx_documents_folder ON documents(folder_id);
CREATE INDEX idx_documents_deleted_at ON documents(deleted_at);
CREATE TABLE document_versions (
id UUID PRIMARY KEY,
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
version_number INTEGER NOT NULL,
s3_key VARCHAR(500) NOT NULL,
size_bytes BIGINT NOT NULL,
checksum VARCHAR(64) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
operations_summary JSONB NOT NULL DEFAULT '{}'::jsonb,
CONSTRAINT document_versions_unique_version UNIQUE (document_id, version_number)
);
CREATE INDEX idx_document_versions_document ON document_versions(document_id);
CREATE TABLE tags (
id UUID PRIMARY KEY,
label VARCHAR(100) NOT NULL UNIQUE,
color VARCHAR(7),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE document_tags (
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(),
assigned_by UUID REFERENCES users(id),
PRIMARY KEY (document_id, tag_id)
);
CREATE INDEX idx_document_tags_tag ON document_tags(tag_id);
INSERT INTO users (id, username, password_hash, role)
VALUES (
gen_random_uuid(),
'admin',
'$argon2id$v=19$m=19456,t=2,p=1$UMkfsNut028fmZupy9JoQg$/YFvGQoEZ2hhMiDCyv68ZROF97GcwAxxRwRgwSbpX5U',
'admin'
);
@@ -0,0 +1,5 @@
DROP TRIGGER IF EXISTS trg_jobs_updated_at ON jobs;
DROP FUNCTION IF EXISTS touch_jobs_updated_at;
DROP INDEX IF EXISTS idx_jobs_job_type;
DROP INDEX IF EXISTS idx_jobs_status_run_after;
DROP TABLE IF EXISTS jobs;
@@ -0,0 +1,28 @@
CREATE TABLE jobs (
id UUID PRIMARY KEY,
job_type TEXT NOT NULL,
payload JSONB NOT NULL,
status TEXT NOT NULL DEFAULT 'queued',
attempts INTEGER NOT NULL DEFAULT 0,
run_after TIMESTAMPTZ NOT NULL DEFAULT now(),
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT jobs_status_check CHECK (status IN ('queued', 'processing', 'succeeded', 'failed'))
);
CREATE INDEX idx_jobs_status_run_after ON jobs (status, run_after);
CREATE INDEX idx_jobs_job_type ON jobs (job_type);
CREATE OR REPLACE FUNCTION touch_jobs_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_jobs_updated_at
BEFORE UPDATE ON jobs
FOR EACH ROW
EXECUTE FUNCTION touch_jobs_updated_at();
@@ -0,0 +1,3 @@
DROP INDEX IF EXISTS idx_document_assets_type;
DROP INDEX IF EXISTS idx_document_assets_version;
DROP TABLE IF EXISTS document_assets;
@@ -0,0 +1,15 @@
CREATE TABLE document_assets (
id UUID PRIMARY KEY,
document_version_id UUID NOT NULL REFERENCES document_versions(id) ON DELETE CASCADE,
asset_type TEXT NOT NULL,
s3_key TEXT NOT NULL,
mime_type TEXT NOT NULL,
width INTEGER,
height INTEGER,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT document_assets_unique UNIQUE (document_version_id, asset_type)
);
CREATE INDEX idx_document_assets_version ON document_assets(document_version_id);
CREATE INDEX idx_document_assets_type ON document_assets(asset_type);
@@ -0,0 +1,4 @@
DROP INDEX IF EXISTS folders_parent_name_unique_idx;
ALTER TABLE folders
ADD CONSTRAINT folders_parent_name_unique UNIQUE (parent_id, name);
@@ -0,0 +1,5 @@
ALTER TABLE folders
DROP CONSTRAINT IF EXISTS folders_parent_name_unique;
CREATE UNIQUE INDEX folders_parent_name_unique_idx
ON folders (COALESCE(parent_id, '00000000-0000-0000-0000-000000000000'::uuid), name);
@@ -0,0 +1,2 @@
ALTER TABLE documents
DROP COLUMN issued_at;
@@ -0,0 +1,2 @@
ALTER TABLE documents
ADD COLUMN issued_at TIMESTAMPTZ;
@@ -0,0 +1,2 @@
ALTER TABLE documents
DROP COLUMN name;
@@ -0,0 +1,11 @@
ALTER TABLE documents
ADD COLUMN name VARCHAR(255);
UPDATE documents
SET name = CASE
WHEN filename ~ '\\.[^./]+$' THEN regexp_replace(filename, '\\.[^./]+$', '')
ELSE filename
END;
ALTER TABLE documents
ALTER COLUMN name SET NOT NULL;
@@ -0,0 +1,2 @@
ALTER TABLE documents
RENAME COLUMN title TO name;
@@ -0,0 +1,2 @@
ALTER TABLE documents
RENAME COLUMN name TO title;
@@ -0,0 +1 @@
DROP TABLE refresh_tokens;
@@ -0,0 +1,13 @@
CREATE TABLE refresh_tokens (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL,
issued_at TIMESTAMPTZ NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id);
CREATE INDEX idx_refresh_tokens_token_hash ON refresh_tokens(token_hash);
@@ -0,0 +1,13 @@
-- Restore width/height columns and repopulate from metadata where available.
ALTER TABLE document_assets
ADD COLUMN width INTEGER,
ADD COLUMN height INTEGER;
UPDATE document_assets
SET width = (metadata->>'width')::INTEGER
WHERE metadata ? 'width';
UPDATE document_assets
SET height = (metadata->>'height')::INTEGER
WHERE metadata ? 'height';
@@ -0,0 +1,15 @@
-- Backfill existing width/height values into metadata then drop the columns.
UPDATE document_assets
SET metadata = metadata || jsonb_build_object('width', width)
WHERE width IS NOT NULL
AND NOT (metadata ? 'width');
UPDATE document_assets
SET metadata = metadata || jsonb_build_object('height', height)
WHERE height IS NOT NULL
AND NOT (metadata ? 'height');
ALTER TABLE document_assets
DROP COLUMN width,
DROP COLUMN height;
@@ -0,0 +1,17 @@
ALTER TABLE documents ADD COLUMN current_version INT4;
UPDATE documents AS d
SET current_version = dv.version_number
FROM document_versions AS dv
WHERE dv.id = d.current_version_id;
ALTER TABLE documents
ALTER COLUMN current_version SET NOT NULL;
DROP INDEX IF EXISTS idx_documents_current_version_id;
ALTER TABLE documents
DROP CONSTRAINT IF EXISTS documents_current_version_fk;
ALTER TABLE documents
DROP COLUMN current_version_id;
@@ -0,0 +1,21 @@
ALTER TABLE documents ADD COLUMN current_version_id UUID;
UPDATE documents AS d
SET current_version_id = dv.id
FROM document_versions AS dv
WHERE dv.document_id = d.id
AND dv.version_number = d.current_version;
ALTER TABLE documents
ALTER COLUMN current_version_id SET NOT NULL;
ALTER TABLE documents
ADD CONSTRAINT documents_current_version_fk
FOREIGN KEY (current_version_id)
REFERENCES document_versions(id)
DEFERRABLE INITIALLY DEFERRED;
CREATE INDEX idx_documents_current_version_id
ON documents(current_version_id);
ALTER TABLE documents DROP COLUMN current_version;
@@ -0,0 +1,5 @@
DROP INDEX IF EXISTS idx_document_correspondents_role;
DROP INDEX IF EXISTS idx_document_correspondents_correspondent;
DROP INDEX IF EXISTS idx_document_correspondents_document;
DROP TABLE IF EXISTS document_correspondents;
DROP TABLE IF EXISTS correspondents;
@@ -0,0 +1,27 @@
CREATE TABLE correspondents (
id UUID PRIMARY KEY,
name VARCHAR(255) NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT correspondents_name_unique UNIQUE (name)
);
CREATE TABLE document_correspondents (
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
correspondent_id UUID NOT NULL REFERENCES correspondents(id) ON DELETE CASCADE,
role VARCHAR(32) NOT NULL,
assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(),
assigned_by UUID REFERENCES users(id),
PRIMARY KEY (document_id, correspondent_id, role),
CONSTRAINT document_correspondents_role_check CHECK (role IN ('sender', 'receiver', 'other'))
);
CREATE INDEX idx_document_correspondents_document
ON document_correspondents(document_id);
CREATE INDEX idx_document_correspondents_correspondent
ON document_correspondents(correspondent_id);
CREATE INDEX idx_document_correspondents_role
ON document_correspondents(role);
@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS idx_documents_folder_filename;
DROP INDEX IF EXISTS idx_documents_folder_title;
@@ -0,0 +1,13 @@
CREATE INDEX idx_documents_folder_title
ON documents (
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
title
)
WHERE deleted_at IS NULL;
CREATE INDEX idx_documents_folder_filename
ON documents (
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
filename
)
WHERE deleted_at IS NULL;
@@ -0,0 +1,16 @@
DROP INDEX IF EXISTS documents_unique_folder_filename;
DROP INDEX IF EXISTS idx_documents_folder_title;
CREATE INDEX idx_documents_folder_title
ON documents (
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
title
)
WHERE deleted_at IS NULL;
CREATE INDEX idx_documents_folder_filename
ON documents (
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
filename
)
WHERE deleted_at IS NULL;
@@ -0,0 +1,18 @@
DROP INDEX IF EXISTS idx_documents_folder_title;
DROP INDEX IF EXISTS idx_documents_folder_filename;
DROP INDEX IF EXISTS documents_unique_folder_title;
DROP INDEX IF EXISTS documents_unique_folder_filename;
CREATE INDEX idx_documents_folder_title
ON documents (
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
title
)
WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX documents_unique_folder_filename
ON documents (
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
filename
)
WHERE deleted_at IS NULL;
@@ -0,0 +1,2 @@
ALTER TABLE document_versions
DROP COLUMN metadata;
@@ -0,0 +1,2 @@
ALTER TABLE document_versions
ADD COLUMN metadata JSONB NOT NULL DEFAULT '{}'::jsonb;
@@ -0,0 +1,2 @@
ALTER TABLE folders
ADD COLUMN path_cache VARCHAR(1000);
@@ -0,0 +1,2 @@
ALTER TABLE folders
DROP COLUMN path_cache;
@@ -1,21 +0,0 @@
DROP TRIGGER IF EXISTS trg_jobs_updated_at ON jobs;
DROP FUNCTION IF EXISTS touch_jobs_updated_at();
ALTER TABLE documents DROP CONSTRAINT IF EXISTS documents_current_version_fk;
DROP TABLE IF EXISTS document_asset_objects;
DROP TABLE IF EXISTS document_assets;
DROP TABLE IF EXISTS document_versions;
DROP TABLE IF EXISTS document_tags;
DROP TABLE IF EXISTS document_correspondents;
DROP TABLE IF EXISTS correspondents;
DROP TABLE IF EXISTS documents;
DROP TABLE IF EXISTS folders;
DROP TABLE IF EXISTS tags;
DROP TABLE IF EXISTS jobs;
DROP TABLE IF EXISTS refresh_tokens;
DROP TABLE IF EXISTS user_memberships;
DROP TABLE IF EXISTS users;
DROP TABLE IF EXISTS tenants;
DROP EXTENSION IF EXISTS "pgcrypto";
@@ -1,244 +0,0 @@
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE TABLE tenants (
id UUID PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
storage_root TEXT,
quickwit_index TEXT,
status TEXT NOT NULL DEFAULT 'active',
config JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX tenants_storage_root_unique
ON tenants (storage_root)
WHERE storage_root IS NOT NULL;
CREATE UNIQUE INDEX tenants_quickwit_index_unique
ON tenants (quickwit_index)
WHERE quickwit_index IS NOT NULL;
CREATE TABLE users (
id UUID PRIMARY KEY,
username VARCHAR(100) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE user_memberships (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
role TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_id, tenant_id)
);
CREATE INDEX user_memberships_tenant_id_idx ON user_memberships (tenant_id);
CREATE INDEX user_memberships_user_id_idx ON user_memberships (user_id);
CREATE TABLE folders (
id UUID PRIMARY KEY,
name VARCHAR(255) NOT NULL,
parent_id UUID REFERENCES folders(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
tenant_id UUID NOT NULL REFERENCES tenants(id)
);
CREATE INDEX idx_folders_parent ON folders(parent_id);
CREATE INDEX folders_tenant_id_idx ON folders(tenant_id);
CREATE UNIQUE INDEX folders_parent_name_unique_idx
ON folders (COALESCE(parent_id, '00000000-0000-0000-0000-000000000000'::uuid), name);
CREATE TABLE documents (
id UUID PRIMARY KEY,
filename VARCHAR(255) NOT NULL,
original_name VARCHAR(255) NOT NULL,
content_type VARCHAR(100),
folder_id UUID REFERENCES folders(id) ON DELETE SET NULL,
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
issued_at TIMESTAMPTZ,
title VARCHAR(255) NOT NULL,
current_version_id UUID NOT NULL,
tenant_id UUID NOT NULL REFERENCES tenants(id)
);
CREATE INDEX idx_documents_folder ON documents (folder_id);
CREATE INDEX idx_documents_deleted_at ON documents (deleted_at);
CREATE INDEX documents_tenant_id_idx ON documents (tenant_id);
CREATE INDEX idx_documents_current_version_id ON documents (current_version_id);
CREATE INDEX idx_documents_folder_title
ON documents (
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
title
)
WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX documents_unique_folder_filename
ON documents (
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
filename
)
WHERE deleted_at IS NULL;
CREATE TABLE document_versions (
id UUID PRIMARY KEY,
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
version_number INT NOT NULL,
s3_key VARCHAR(500) NOT NULL,
size_bytes BIGINT NOT NULL,
checksum VARCHAR(64) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
operations_summary JSONB NOT NULL DEFAULT '{}'::jsonb,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
tenant_id UUID NOT NULL REFERENCES tenants(id),
CONSTRAINT document_versions_unique_version UNIQUE (document_id, version_number)
);
CREATE INDEX idx_document_versions_document ON document_versions (document_id);
CREATE INDEX document_versions_tenant_id_idx ON document_versions (tenant_id);
ALTER TABLE documents
ADD CONSTRAINT documents_current_version_fk
FOREIGN KEY (current_version_id)
REFERENCES document_versions(id)
DEFERRABLE INITIALLY DEFERRED;
CREATE TABLE tags (
id UUID PRIMARY KEY,
label VARCHAR(100) NOT NULL UNIQUE,
color VARCHAR(7),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
tenant_id UUID NOT NULL REFERENCES tenants(id)
);
CREATE INDEX tags_tenant_id_idx ON tags (tenant_id);
CREATE TABLE document_tags (
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
assigned_by UUID REFERENCES users(id),
tenant_id UUID NOT NULL REFERENCES tenants(id),
PRIMARY KEY (document_id, tag_id)
);
CREATE INDEX idx_document_tags_tag ON document_tags (tag_id);
CREATE INDEX document_tags_tenant_id_idx ON document_tags (tenant_id);
CREATE TABLE correspondents (
id UUID PRIMARY KEY,
name VARCHAR(255) NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
CONSTRAINT correspondents_name_unique UNIQUE (name)
);
CREATE INDEX correspondents_tenant_id_idx ON correspondents (tenant_id);
CREATE TABLE document_correspondents (
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
correspondent_id UUID NOT NULL REFERENCES correspondents(id) ON DELETE CASCADE,
role VARCHAR(32) NOT NULL,
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
assigned_by UUID REFERENCES users(id),
tenant_id UUID NOT NULL REFERENCES tenants(id),
PRIMARY KEY (document_id, correspondent_id, role),
CONSTRAINT document_correspondents_role_check CHECK (role IN ('sender', 'receiver', 'other'))
);
CREATE INDEX idx_document_correspondents_document ON document_correspondents (document_id);
CREATE INDEX idx_document_correspondents_correspondent ON document_correspondents (correspondent_id);
CREATE INDEX idx_document_correspondents_role ON document_correspondents (role);
CREATE INDEX document_correspondents_tenant_id_idx ON document_correspondents (tenant_id);
CREATE TABLE document_assets (
id UUID PRIMARY KEY,
document_version_id UUID NOT NULL REFERENCES document_versions(id) ON DELETE CASCADE,
asset_type TEXT NOT NULL,
mime_type TEXT NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
cardinality INTEGER,
tenant_id UUID NOT NULL REFERENCES tenants(id),
CONSTRAINT document_assets_unique UNIQUE (document_version_id, asset_type),
CONSTRAINT document_assets_cardinality_positive CHECK (cardinality IS NULL OR cardinality >= 1)
);
CREATE INDEX idx_document_assets_version ON document_assets (document_version_id);
CREATE INDEX idx_document_assets_type ON document_assets (asset_type);
CREATE INDEX document_assets_tenant_id_idx ON document_assets (tenant_id);
CREATE TABLE document_asset_objects (
id UUID PRIMARY KEY,
asset_id UUID NOT NULL REFERENCES document_assets(id) ON DELETE CASCADE,
ordinal INTEGER NOT NULL,
s3_key TEXT NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
tenant_id UUID NOT NULL REFERENCES tenants(id),
CONSTRAINT document_asset_objects_ordinal_positive CHECK (ordinal >= 1),
CONSTRAINT document_asset_objects_asset_ordinal_unique UNIQUE (asset_id, ordinal)
);
CREATE INDEX idx_document_asset_objects_asset_ordinal
ON document_asset_objects (asset_id, ordinal);
CREATE INDEX document_asset_objects_tenant_id_idx
ON document_asset_objects (tenant_id);
CREATE TABLE jobs (
id UUID PRIMARY KEY,
job_type TEXT NOT NULL,
payload JSONB NOT NULL,
status TEXT NOT NULL DEFAULT 'queued',
attempts INTEGER NOT NULL DEFAULT 0,
run_after TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
CONSTRAINT jobs_status_check CHECK (status IN ('queued', 'processing', 'succeeded', 'failed'))
);
CREATE INDEX idx_jobs_status_run_after ON jobs (status, run_after);
CREATE INDEX idx_jobs_job_type ON jobs (job_type);
CREATE INDEX jobs_tenant_id_idx ON jobs (tenant_id);
CREATE OR REPLACE FUNCTION touch_jobs_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_jobs_updated_at
BEFORE UPDATE ON jobs
FOR EACH ROW
EXECUTE FUNCTION touch_jobs_updated_at();
CREATE TABLE refresh_tokens (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL,
issued_at TIMESTAMPTZ NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
tenant_id UUID NOT NULL REFERENCES tenants(id)
);
CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens (user_id);
CREATE INDEX idx_refresh_tokens_token_hash ON refresh_tokens (token_hash);
CREATE INDEX refresh_tokens_tenant_id_idx ON refresh_tokens (tenant_id);
+4 -46
View File
@@ -15,8 +15,6 @@ 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 {
@@ -29,18 +27,16 @@ 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, tenant_id: Uuid, username: &str) -> Result<String> { pub fn generate_token(&self, user_id: Uuid, username: &str, role: &str) -> Result<String> {
let now = Utc::now(); let 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,
@@ -58,18 +54,12 @@ impl JwtService {
Ok(data.claims) Ok(data.claims)
} }
pub fn generate_download_token( pub fn generate_download_token(&self, document_id: Uuid, user_id: Uuid) -> Result<String> {
&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,
@@ -86,35 +76,13 @@ 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,
@@ -125,16 +93,6 @@ 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,
+4 -50
View File
@@ -6,17 +6,13 @@ 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::{ use crate::{error::AppError, state::AppState};
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 tenant_id: uuid::Uuid, pub role: String,
} }
#[async_trait] #[async_trait]
@@ -27,10 +23,6 @@ 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
@@ -41,48 +33,10 @@ impl FromRequestParts<AppState> for AuthenticatedUser {
.verify_token(bearer.token()) .verify_token(bearer.token())
.map_err(|_| AppError::unauthorized())?; .map_err(|_| AppError::unauthorized())?;
let user = AuthenticatedUser { Ok(AuthenticatedUser {
user_id: claims.sub, user_id: claims.sub,
username: claims.username, username: claims.username,
tenant_id: claims.tenant_id, role: claims.role,
};
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
@@ -1,707 +0,0 @@
use std::env;
use std::sync::Arc;
use anyhow::{anyhow, bail, Context, Result};
use argon2::{
password_hash::{PasswordHasher, SaltString},
Argon2,
};
use diesel::{dsl::exists, prelude::*, select};
use once_cell::sync::Lazy;
use reqwest::{Client, Method, StatusCode};
use serde_json::json;
use uuid::Uuid;
use backend::{
config::AppConfig,
db::{self, PgPool},
jobs::{enqueue_job, JOB_ANALYZE_DOCUMENT},
models::{DocumentAsset, DocumentAssetObject, NewUser, NewUserMembership, Tenant, User},
s3,
schema::{
document_asset_objects, document_assets, documents, tenants, user_memberships, users,
},
storage::{ObjectStorage, S3Storage, TenantStorage},
utils::tracing::init_tracing,
};
use rand::rngs::OsRng;
static QUICKWIT_INDEX_TEMPLATE: Lazy<serde_json::Value> = Lazy::new(|| {
json!({
"version": "0.8",
"index_id": "documents",
"doc_mapping": {
"tokenizers": [
{
"name": "substring",
"type": "ngram",
"min_gram": 2,
"max_gram": 20,
"prefix_only": false
}
],
"field_mappings": [
{ "name": "tenant_id", "type": "text", "stored": true },
{ "name": "document_id", "type": "text", "stored": true },
{ "name": "version_id", "type": "text", "stored": true },
{ "name": "title", "type": "text", "tokenizer": "substring", "stored": true },
{ "name": "text", "type": "text", "tokenizer": "substring", "record": "position" }
]
},
"search_settings": {
"default_search_fields": ["title", "text"]
}
})
});
#[derive(Debug)]
enum Command {
CreateUser {
username: String,
password: String,
},
SetPassword {
username: String,
password: String,
},
ListUsers,
DeleteUser {
username: String,
},
CreateTenant {
slug: String,
storage_root: Option<String>,
quickwit_index: Option<String>,
},
DeleteTenant {
slug: String,
},
AddUserToTenant {
username: String,
slug: String,
role: Option<String>,
},
RemoveUserFromTenant {
username: String,
slug: String,
},
ReanalyzeDocuments {
slug: String,
},
ListTenants,
DeleteAssets(String),
QuickwitCreate(String),
QuickwitDelete(String),
}
impl Command {
fn usage() -> &'static str {
"Usage: admin\n\
create-user <username> <password>\n\
set-password <username> <password>\n\
list-users\n\
delete-user <username>\n\
create-tenant <slug> [storage_root] [quickwit_index]\n\
delete-tenant <slug>\n\
add-user-to-tenant <username> <slug> [role]\n\
remove-user-from-tenant <username> <slug>\n\
reanalyze-documents <slug>\n\
list-tenants\n\
delete-assets <slug>\n\
quickwit-create-index <slug>\n\
quickwit-delete-index <slug>"
}
fn parse() -> Result<Self> {
let mut args = env::args().skip(1);
match args.next().as_deref() {
Some("create-user") => Ok(Self::CreateUser {
username: args.next().ok_or_else(|| anyhow!("username required"))?,
password: args.next().ok_or_else(|| anyhow!("password required"))?,
}),
Some("set-password") => Ok(Self::SetPassword {
username: args.next().ok_or_else(|| anyhow!("username required"))?,
password: args.next().ok_or_else(|| anyhow!("password required"))?,
}),
Some("list-users") => Ok(Self::ListUsers),
Some("delete-user") => Ok(Self::DeleteUser {
username: args.next().ok_or_else(|| anyhow!("username required"))?,
}),
Some("create-tenant") => Ok(Self::CreateTenant {
slug: args.next().ok_or_else(|| anyhow!("tenant slug required"))?,
storage_root: args.next(),
quickwit_index: args.next(),
}),
Some("delete-tenant") => Ok(Self::DeleteTenant {
slug: args.next().ok_or_else(|| anyhow!("tenant slug required"))?,
}),
Some("add-user-to-tenant") => Ok(Self::AddUserToTenant {
username: args.next().ok_or_else(|| anyhow!("username required"))?,
slug: args.next().ok_or_else(|| anyhow!("tenant slug required"))?,
role: args.next(),
}),
Some("remove-user-from-tenant") => Ok(Self::RemoveUserFromTenant {
username: args.next().ok_or_else(|| anyhow!("username required"))?,
slug: args.next().ok_or_else(|| anyhow!("tenant slug required"))?,
}),
Some("reanalyze-documents") => Ok(Self::ReanalyzeDocuments {
slug: args.next().ok_or_else(|| anyhow!("tenant slug required"))?,
}),
Some("list-tenants") => Ok(Self::ListTenants),
Some("delete-assets") => Ok(Self::DeleteAssets(
args.next().ok_or_else(|| anyhow!("tenant slug required"))?,
)),
Some("quickwit-create-index") => Ok(Self::QuickwitCreate(
args.next().ok_or_else(|| anyhow!("tenant slug required"))?,
)),
Some("quickwit-delete-index") => Ok(Self::QuickwitDelete(
args.next().ok_or_else(|| anyhow!("tenant slug required"))?,
)),
_ => Err(anyhow!(Self::usage())),
}
}
}
#[tokio::main]
async fn main() -> Result<()> {
init_tracing("info");
let command = Command::parse()?;
let config = AppConfig::load_and_log("admin")?;
let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?;
match command {
Command::CreateUser { username, password } => create_user(&pool, &username, &password)?,
Command::SetPassword { username, password } => set_password(&pool, &username, &password)?,
Command::ListUsers => list_users(&pool)?,
Command::DeleteUser { username } => delete_user(&pool, &username)?,
Command::CreateTenant {
slug,
storage_root,
quickwit_index,
} => create_tenant(&pool, &slug, storage_root, quickwit_index)?,
Command::DeleteTenant { slug } => delete_tenant(&pool, &slug)?,
Command::AddUserToTenant {
username,
slug,
role,
} => add_user_to_tenant(&pool, &username, &slug, role.as_deref())?,
Command::RemoveUserFromTenant { username, slug } => {
remove_user_from_tenant(&pool, &username, &slug)?
}
Command::ReanalyzeDocuments { slug } => reanalyze_documents(&pool, &slug)?,
Command::ListTenants => list_tenants(&pool)?,
Command::DeleteAssets(slug) => delete_assets_for_tenant(&config, &pool, &slug).await?,
Command::QuickwitCreate(slug) => {
quickwit_index(&config, &pool, &slug, Method::POST).await?
}
Command::QuickwitDelete(slug) => {
quickwit_index(&config, &pool, &slug, Method::DELETE).await?
}
}
Ok(())
}
fn create_user(pool: &PgPool, username: &str, password: &str) -> Result<()> {
if username.trim().is_empty() {
bail!("username must not be empty");
}
if password.is_empty() {
bail!("password must not be empty");
}
let mut conn = pool.get().context("failed to get database connection")?;
let exists: bool =
select(exists(users::table.filter(users::username.eq(username)))).get_result(&mut conn)?;
if exists {
bail!("user '{}' already exists", username);
}
let password_hash = hash_password(password)?;
let new_user = NewUser {
id: Uuid::new_v4(),
username: username.to_string(),
password_hash,
};
diesel::insert_into(users::table)
.values(&new_user)
.execute(&mut conn)?;
println!("created user '{}' (id: {})", username, new_user.id);
Ok(())
}
fn set_password(pool: &PgPool, username: &str, password: &str) -> Result<()> {
if password.is_empty() {
bail!("password must not be empty");
}
let mut conn = pool.get().context("failed to get database connection")?;
let password_hash = hash_password(password)?;
let updated = diesel::update(users::table.filter(users::username.eq(username)))
.set(users::password_hash.eq(password_hash))
.execute(&mut conn)?;
if updated == 0 {
bail!("user '{}' not found", username);
}
println!("updated password for '{}'", username);
Ok(())
}
fn hash_password(password: &str) -> Result<String> {
let salt = SaltString::generate(&mut OsRng);
let hash = Argon2::default()
.hash_password(password.as_bytes(), &salt)
.map_err(|err| anyhow!(err))?;
Ok(hash.to_string())
}
fn list_users(pool: &PgPool) -> Result<()> {
let mut conn = pool.get().context("failed to get database connection")?;
let users_list: Vec<User> = users::table.order(users::username.asc()).load(&mut conn)?;
if users_list.is_empty() {
println!("No users found.");
return Ok(());
}
for user in users_list {
let memberships: Vec<(Uuid, String, String)> = user_memberships::table
.inner_join(tenants::table)
.filter(user_memberships::user_id.eq(user.id))
.select((tenants::id, tenants::slug, user_memberships::role))
.order((tenants::slug.asc(), user_memberships::role.asc()))
.load(&mut conn)?;
if memberships.is_empty() {
println!("{} ({})", user.username, user.id);
} else {
let details: Vec<String> = memberships
.into_iter()
.map(|(_, slug, role)| format!("{}: {}", slug, role))
.collect();
println!("{} ({}) -> {}", user.username, user.id, details.join(", "));
}
}
Ok(())
}
fn delete_user(pool: &PgPool, username: &str) -> Result<()> {
let mut conn = pool.get().context("failed to get database connection")?;
let user: User = users::table
.filter(users::username.eq(username))
.first(&mut conn)
.optional()?
.ok_or_else(|| anyhow!("user '{}' not found", username))?;
diesel::delete(user_memberships::table.filter(user_memberships::user_id.eq(user.id)))
.execute(&mut conn)?;
diesel::delete(users::table.filter(users::id.eq(user.id))).execute(&mut conn)?;
println!("deleted user '{}'", username);
Ok(())
}
fn create_tenant(
pool: &PgPool,
slug: &str,
storage_root_arg: Option<String>,
quickwit_index_arg: Option<String>,
) -> Result<()> {
if slug.trim().is_empty() {
bail!("tenant slug must not be empty");
}
let mut conn = pool.get().context("failed to get database connection")?;
let exists: bool =
select(exists(tenants::table.filter(tenants::slug.eq(slug)))).get_result(&mut conn)?;
if exists {
bail!("tenant '{}' already exists", slug);
}
let id = Uuid::new_v4();
let storage_root = storage_root_arg
.map(|mut s| {
if s.is_empty() {
format!("tenants/{}/", id)
} else {
if !s.ends_with('/') {
s.push('/');
}
s
}
})
.unwrap_or_else(|| format!("tenants/{}/", id));
let quickwit_index = quickwit_index_arg.unwrap_or_else(|| format!("documents-{}", id));
diesel::insert_into(tenants::table)
.values((
tenants::id.eq(id),
tenants::slug.eq(slug),
tenants::storage_root.eq(Some(storage_root.clone())),
tenants::quickwit_index.eq(Some(quickwit_index.clone())),
tenants::status.eq("active"),
tenants::config.eq(serde_json::json!({})),
))
.execute(&mut conn)?;
println!(
"created tenant '{}' with id {}, storage_root '{}', quickwit_index '{}'",
slug, id, storage_root, quickwit_index
);
Ok(())
}
fn delete_tenant(pool: &PgPool, slug: &str) -> Result<()> {
let mut conn = pool.get().context("failed to get database connection")?;
let tenant: Tenant = tenants::table
.filter(tenants::slug.eq(slug))
.first(&mut conn)
.optional()?
.ok_or_else(|| anyhow!("tenant '{}' not found", slug))?;
let member_exists: bool = select(exists(
user_memberships::table.filter(user_memberships::tenant_id.eq(tenant.id)),
))
.get_result(&mut conn)?;
if member_exists {
bail!("tenant '{}' still has user memberships", slug);
}
diesel::delete(tenants::table.filter(tenants::id.eq(tenant.id))).execute(&mut conn)?;
println!("deleted tenant '{}'", slug);
Ok(())
}
fn add_user_to_tenant(pool: &PgPool, username: &str, slug: &str, role: Option<&str>) -> Result<()> {
let mut conn = pool.get().context("failed to get database connection")?;
let user: User = users::table
.filter(users::username.eq(username))
.first(&mut conn)
.optional()?
.ok_or_else(|| anyhow!("user '{}' not found", username))?;
let tenant: Tenant = tenants::table
.filter(tenants::slug.eq(slug))
.first(&mut conn)
.optional()?
.ok_or_else(|| anyhow!("tenant '{}' not found", slug))?;
let membership = NewUserMembership {
id: Uuid::new_v4(),
user_id: user.id,
tenant_id: tenant.id,
role: role.unwrap_or("user").to_string(),
};
diesel::insert_into(user_memberships::table)
.values(&membership)
.on_conflict((user_memberships::user_id, user_memberships::tenant_id))
.do_update()
.set(user_memberships::role.eq(&membership.role))
.execute(&mut conn)?;
println!(
"added user '{}' to tenant '{}' with role '{}'",
username, slug, membership.role
);
Ok(())
}
fn remove_user_from_tenant(pool: &PgPool, username: &str, slug: &str) -> Result<()> {
let mut conn = pool.get().context("failed to get database connection")?;
let user: User = users::table
.filter(users::username.eq(username))
.first(&mut conn)
.optional()?
.ok_or_else(|| anyhow!("user '{}' not found", username))?;
let tenant: Tenant = tenants::table
.filter(tenants::slug.eq(slug))
.first(&mut conn)
.optional()?
.ok_or_else(|| anyhow!("tenant '{}' not found", slug))?;
let removed = diesel::delete(
user_memberships::table
.filter(user_memberships::user_id.eq(user.id))
.filter(user_memberships::tenant_id.eq(tenant.id)),
)
.execute(&mut conn)?;
if removed == 0 {
println!("user '{}' was not a member of tenant '{}'", username, slug);
} else {
println!("removed user '{}' from tenant '{}'", username, slug);
}
Ok(())
}
fn reanalyze_documents(pool: &PgPool, slug: &str) -> Result<()> {
let mut conn = pool.get().context("failed to get database connection")?;
let tenant: Tenant = tenants::table
.filter(tenants::slug.eq(slug))
.first(&mut conn)
.optional()?
.ok_or_else(|| anyhow!("tenant '{}' not found", slug))?;
let targets: Vec<(Uuid, Uuid)> = documents::table
.filter(documents::tenant_id.eq(tenant.id))
.filter(documents::deleted_at.is_null())
.select((documents::id, documents::current_version_id))
.load(&mut conn)?;
if targets.is_empty() {
println!("tenant '{}' has no active documents", slug);
return Ok(());
}
let mut queued = 0usize;
for (document_id, version_id) in targets {
enqueue_job(
&mut conn,
tenant.id,
JOB_ANALYZE_DOCUMENT,
serde_json::json!({
"document_id": document_id,
"document_version_id": version_id,
"force": true,
}),
None,
)
.map_err(|err| anyhow!("failed to enqueue analyze job: {}", err))?;
queued += 1;
}
println!(
"queued {} documents for re-analysis in tenant '{}'",
queued, slug
);
Ok(())
}
fn list_tenants(pool: &PgPool) -> Result<()> {
let mut conn = pool.get().context("failed to get database connection")?;
let tenants: Vec<Tenant> = tenants::table
.order(tenants::slug.asc())
.load(&mut conn)
.context("failed to load tenants")?;
if tenants.is_empty() {
println!("No tenants found.");
return Ok(());
}
for tenant in tenants {
println!("{} ({})", tenant.slug, tenant.id);
}
Ok(())
}
async fn delete_assets_for_tenant(
config: &AppConfig,
pool: &PgPool,
tenant_slug: &str,
) -> Result<()> {
let s3_client = s3::build_client(config).await?;
let storage: Arc<dyn ObjectStorage> =
Arc::new(S3Storage::new(s3_client, config.s3_bucket.clone()));
let mut conn = pool.get().context("failed to get database connection")?;
let tenant: Tenant = tenants::table
.filter(tenants::slug.eq(tenant_slug))
.first(&mut conn)
.optional()
.context("failed to load tenant")?
.ok_or_else(|| anyhow!("tenant '{}' not found", tenant_slug))?;
let tenant_storage = TenantStorage::new(Arc::clone(&storage), &tenant)
.with_context(|| format!("missing storage root for tenant {}", tenant.slug))?;
let assets: Vec<DocumentAsset> = document_assets::table
.filter(document_assets::tenant_id.eq(tenant.id))
.load(&mut conn)
.with_context(|| format!("failed to load assets for tenant {}", tenant.slug))?;
if assets.is_empty() {
println!("Tenant {}: no assets", tenant.slug);
return Ok(());
}
println!(
"Tenant {} ({}): deleting {} assets…",
tenant.slug,
tenant.id,
assets.len()
);
let asset_ids: Vec<Uuid> = assets.iter().map(|asset| asset.id).collect();
let objects: Vec<DocumentAssetObject> = document_asset_objects::table
.filter(document_asset_objects::tenant_id.eq(tenant.id))
.filter(document_asset_objects::asset_id.eq_any(&asset_ids))
.load(&mut conn)
.with_context(|| format!("failed to load asset objects for tenant {}", tenant.slug))?;
for object in &objects {
if let Err(err) = tenant_storage.delete_object(&object.s3_key).await {
eprintln!(
"Failed to delete object {} (tenant {}): {err}",
object.s3_key, tenant.slug
);
}
}
diesel::delete(
document_asset_objects::table
.filter(document_asset_objects::tenant_id.eq(tenant.id))
.filter(document_asset_objects::asset_id.eq_any(&asset_ids)),
)
.execute(&mut conn)
.with_context(|| format!("failed to remove asset objects for tenant {}", tenant.slug))?;
diesel::delete(document_assets::table.filter(document_assets::tenant_id.eq(tenant.id)))
.execute(&mut conn)
.with_context(|| format!("failed to remove asset records for tenant {}", tenant.slug))?;
println!("Tenant {}: asset records deleted.", tenant.slug);
Ok(())
}
async fn quickwit_index(
config: &AppConfig,
pool: &PgPool,
slug: &str,
method: Method,
) -> Result<()> {
let endpoint = config
.quickwit_endpoint
.as_ref()
.ok_or_else(|| anyhow!("quickwit endpoint not configured"))?;
let mut conn = pool.get().context("failed to get database connection")?;
let tenant: Tenant = tenants::table
.filter(tenants::slug.eq(slug))
.first(&mut conn)
.optional()
.context("failed to query tenants")?
.ok_or_else(|| anyhow!("tenant '{}' not found", slug))?;
let client = Client::new();
let index_id = format!("documents-{}", tenant.id);
let base_endpoint = endpoint.trim_end_matches('/');
match method {
Method::POST => {
let payload = render_index_template(&index_id);
let response = client
.post(format!("{}/api/v1/indexes", base_endpoint))
.header("content-type", "application/json")
.body(payload)
.send()
.await
.context("failed to send create index request")?;
match response.status() {
status if status.is_success() => {
diesel::update(tenants::table.filter(tenants::id.eq(tenant.id)))
.set(tenants::quickwit_index.eq(Some(index_id.clone())))
.execute(&mut conn)
.context("failed to update tenant quickwit_index")?;
println!(
"Tenant '{}' quickwit index set to '{}'.",
tenant.slug, index_id
);
}
StatusCode::CONFLICT => {
let lookup = client
.get(format!("{}/api/v1/indexes/{}", base_endpoint, index_id))
.send()
.await
.context("failed to verify existing quickwit index")?;
let lookup_status = lookup.status();
if !lookup_status.is_success() {
let body = lookup.text().await.unwrap_or_default();
bail!(
"quickwit reported conflict but index lookup failed with status {}: {}",
lookup_status,
body
);
}
diesel::update(tenants::table.filter(tenants::id.eq(tenant.id)))
.set(tenants::quickwit_index.eq(Some(index_id.clone())))
.execute(&mut conn)
.context("failed to update tenant quickwit_index")?;
println!(
"Tenant '{}' quickwit index set to '{}'.",
tenant.slug, index_id
);
}
status => {
let body = response.text().await.unwrap_or_default();
bail!(
"quickwit create index failed with status {}: {}",
status,
body
);
}
}
}
Method::DELETE => {
let response = client
.delete(format!("{}/api/v1/indexes/{}", base_endpoint, index_id))
.send()
.await
.context("failed to send delete index request")?;
match response.status() {
status if status.is_success() || status == StatusCode::NOT_FOUND => {
diesel::update(tenants::table.filter(tenants::id.eq(tenant.id)))
.set(tenants::quickwit_index.eq::<Option<String>>(None))
.execute(&mut conn)
.context("failed to clear tenant quickwit_index")?;
println!("Tenant '{}' quickwit index cleared.", tenant.slug);
}
status => {
let body = response.text().await.unwrap_or_default();
bail!(
"quickwit delete index failed with status {}: {}",
status,
body
);
}
}
}
_ => unreachable!(),
}
Ok(())
}
fn render_index_template(index_id: &str) -> String {
let mut template = QUICKWIT_INDEX_TEMPLATE.clone();
if let Some(obj) = template.as_object_mut() {
obj.insert(
"index_id".to_string(),
serde_json::Value::String(index_id.to_string()),
);
}
template.to_string()
}
+75
View File
@@ -0,0 +1,75 @@
use std::env;
use anyhow::{Context, Result};
use diesel::prelude::*;
use backend::{
config::AppConfig,
db,
models::DocumentAsset,
s3,
schema::document_assets,
storage::{ObjectStorage, S3Storage},
};
#[tokio::main]
async fn main() -> Result<()> {
let mut args = env::args().skip(1);
match args.next().as_deref() {
Some("delete-assets") => delete_all_assets().await?,
Some(cmd) => {
eprintln!("Unknown command: {cmd}\nUsage: maintenance delete-assets");
std::process::exit(1);
}
None => {
eprintln!("Usage: maintenance delete-assets");
std::process::exit(1);
}
}
Ok(())
}
async fn delete_all_assets() -> Result<()> {
let config = AppConfig::from_env()?;
tracing::info!(
component = "maintenance",
database_url = %config.redacted_database_url(),
pool_size = config.database_max_pool_size,
s3_bucket = %config.s3_bucket,
"loaded backend configuration"
);
let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?;
let s3_client = s3::build_client(&config).await?;
let storage = S3Storage::new(s3_client, config.s3_bucket.clone());
let mut conn = pool.get().context("failed to get database connection")?;
let assets: Vec<DocumentAsset> = document_assets::table
.load(&mut conn)
.context("failed to load document assets")?;
if assets.is_empty() {
println!("No assets found.");
return Ok(());
}
println!("Deleting {} assets…", assets.len());
for asset in &assets {
if let Err(err) = storage.delete_object(&asset.s3_key).await {
eprintln!(
"Failed to delete object {} from storage: {err}",
asset.s3_key
);
}
}
diesel::delete(document_assets::table)
.execute(&mut conn)
.context("failed to remove asset records")?;
println!("Asset records deleted.");
Ok(())
}
+41 -9
View File
@@ -1,24 +1,47 @@
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::{routes::webdav, utils::bootstrap::init_component}; use backend::auth::jwt::JwtService;
use backend::config::AppConfig;
use backend::db;
use backend::routes::webdav;
use backend::s3::build_client;
use backend::state::AppState;
use backend::storage::S3Storage;
#[tokio::main] #[tokio::main]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
let state = init_component("webdav", None).await?; dotenv::dotenv().ok();
let webdav_host = state.config.webdav_host.clone(); init_tracing();
let webdav_port = state.config.webdav_port;
let config = AppConfig::from_env()?;
tracing::info!( tracing::info!(
component = "webdav", component = "webdav",
webdav_host = %webdav_host, database_url = %config.redacted_database_url(),
webdav_port, pool_size = config.database_max_pool_size,
"starting webdav server" server_host = %config.server_host,
server_port = config.server_port,
webdav_host = %config.webdav_host,
webdav_port = config.webdav_port,
quickwit_enabled = config.quickwit_endpoint.is_some(),
s3_bucket = %config.s3_bucket,
"loaded backend configuration"
); );
let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?;
let s3_client = build_client(&config).await?;
let storage = Arc::new(S3Storage::new(s3_client, config.s3_bucket.clone()));
let jwt = JwtService::from_config(&config)?;
let listen_addr: SocketAddr = format!("{}:{}", webdav_host, webdav_port).parse()?; let state = AppState::new(pool, config, storage, jwt);
let router = webdav::create_router().with_state(state.as_ref().clone()); let listen_addr: SocketAddr = {
let config = state.config.clone();
format!("{}:{}", config.webdav_host, config.webdav_port).parse()?
};
let router = webdav::create_router().with_state(state);
let listener = TcpListener::bind(listen_addr).await?; let listener = TcpListener::bind(listen_addr).await?;
tracing::info!("listening for WebDAV on {}", listen_addr); tracing::info!("listening for WebDAV on {}", listen_addr);
@@ -26,3 +49,12 @@ 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();
}
+33 -4
View File
@@ -1,13 +1,33 @@
use std::time::Duration; use std::{sync::Arc, time::Duration};
use tokio::signal; use tokio::signal;
use tracing_subscriber::EnvFilter;
use backend::{default_handlers, utils::bootstrap::init_component, Worker}; use backend::{
auth::jwt::JwtService, config::AppConfig, db, default_handlers, s3::build_client,
state::AppState, storage::S3Storage, Worker,
};
#[tokio::main] #[tokio::main]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
let state = init_component("worker", Some(1)).await?; dotenv::dotenv().ok();
tracing::info!(component = "worker", "starting worker process"); init_tracing();
let config = AppConfig::from_env()?;
tracing::info!(
component = "worker",
database_url = %config.redacted_database_url(),
pool_size = 1,
quickwit_enabled = config.quickwit_endpoint.is_some(),
s3_bucket = %config.s3_bucket,
"loaded backend configuration"
);
let pool = db::init_pool_with_size(&config.database_url, 1)?;
let s3_client = build_client(&config).await?;
let storage = Arc::new(S3Storage::new(s3_client, config.s3_bucket.clone()));
let jwt = JwtService::from_config(&config)?;
let state = Arc::new(AppState::new(pool, config, storage, jwt));
let worker = Worker::new(state, default_handlers(), Duration::from_secs(2)); let worker = Worker::new(state, default_handlers(), Duration::from_secs(2));
tokio::select! { tokio::select! {
@@ -19,3 +39,12 @@ 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();
}
-18
View File
@@ -30,24 +30,9 @@ 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")
@@ -94,8 +79,6 @@ 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,
@@ -121,7 +104,6 @@ impl AppConfig {
s3_bucket, s3_bucket,
quickwit_endpoint, quickwit_endpoint,
quickwit_index, quickwit_index,
default_tenant_slug,
}) })
} }
-2
View File
@@ -30,7 +30,6 @@ 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>,
@@ -41,7 +40,6 @@ 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)
-1
View File
@@ -9,7 +9,6 @@ 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};
+38 -9
View File
@@ -1,28 +1,57 @@
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::{routes, utils::bootstrap::init_component}; use backend::auth::jwt::JwtService;
use backend::config::AppConfig;
use backend::db;
use backend::routes;
use backend::s3::build_client;
use backend::state::AppState;
use backend::storage::S3Storage;
#[tokio::main] #[tokio::main]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
let state = init_component("api", None).await?; dotenv::dotenv().ok();
let server_host = state.config.server_host.clone(); init_tracing();
let server_port = state.config.server_port;
let config = AppConfig::from_env()?;
tracing::info!( tracing::info!(
component = "api", component = "api",
server_host = %server_host, database_url = %config.redacted_database_url(),
server_port, pool_size = config.database_max_pool_size,
"starting api server" server_host = %config.server_host,
server_port = config.server_port,
quickwit_enabled = config.quickwit_endpoint.is_some(),
s3_bucket = %config.s3_bucket,
"loaded backend configuration"
); );
let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?;
let s3_client = build_client(&config).await?;
let storage = Arc::new(S3Storage::new(s3_client, config.s3_bucket.clone()));
let jwt = JwtService::from_config(&config)?;
let router = routes::create_router(state.as_ref().clone()); let state = AppState::new(pool, config, storage, jwt);
let addr: SocketAddr = format!("{}:{}", server_host, server_port).parse()?; let router = routes::create_router(state.clone());
let addr: SocketAddr =
format!("{}:{}", state.config.server_host, state.config.server_port).parse()?;
let listener = TcpListener::bind(addr).await?; 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();
}
+4 -81
View File
@@ -4,48 +4,13 @@ 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,
} }
@@ -56,6 +21,7 @@ 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)]
@@ -66,7 +32,6 @@ 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)]
@@ -75,7 +40,6 @@ 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)]
@@ -94,7 +58,6 @@ 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)]
@@ -109,7 +72,6 @@ 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)]
@@ -125,7 +87,6 @@ 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)]
@@ -139,7 +100,6 @@ 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)]
@@ -149,11 +109,10 @@ pub struct DocumentAsset {
pub id: Uuid, pub id: Uuid,
pub document_version_id: Uuid, pub document_version_id: Uuid,
pub asset_type: String, pub asset_type: String,
pub s3_key: String,
pub mime_type: String, pub mime_type: String,
pub metadata: serde_json::Value, pub metadata: serde_json::Value,
pub created_at: NaiveDateTime, pub created_at: NaiveDateTime,
pub cardinality: Option<i32>,
pub tenant_id: Uuid,
} }
#[derive(Debug, Insertable)] #[derive(Debug, Insertable)]
@@ -162,33 +121,9 @@ pub struct NewDocumentAsset {
pub id: Uuid, pub id: Uuid,
pub document_version_id: Uuid, pub document_version_id: Uuid,
pub asset_type: String, pub asset_type: String,
pub s3_key: String,
pub mime_type: String, pub mime_type: String,
pub metadata: serde_json::Value, pub metadata: serde_json::Value,
pub cardinality: Option<i32>,
pub tenant_id: Uuid,
}
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
#[diesel(table_name = document_asset_objects)]
#[diesel(belongs_to(DocumentAsset, foreign_key = asset_id))]
pub struct DocumentAssetObject {
pub id: Uuid,
pub asset_id: Uuid,
pub ordinal: i32,
pub s3_key: String,
pub metadata: serde_json::Value,
pub tenant_id: Uuid,
}
#[derive(Debug, Insertable)]
#[diesel(table_name = document_asset_objects)]
pub struct NewDocumentAssetObject {
pub id: Uuid,
pub asset_id: Uuid,
pub ordinal: i32,
pub s3_key: String,
pub metadata: serde_json::Value,
pub tenant_id: Uuid,
} }
#[derive(Debug, Clone, Queryable, Identifiable)] #[derive(Debug, Clone, Queryable, Identifiable)]
@@ -203,7 +138,6 @@ 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)]
@@ -214,7 +148,6 @@ 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)]
@@ -224,7 +157,6 @@ 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)]
@@ -233,7 +165,6 @@ 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)]
@@ -247,7 +178,6 @@ 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)]
@@ -256,7 +186,6 @@ 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)]
@@ -267,7 +196,6 @@ 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)]
@@ -276,7 +204,6 @@ 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)]
@@ -290,7 +217,6 @@ 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)]
@@ -300,7 +226,6 @@ 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)]
@@ -315,7 +240,6 @@ 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)]
@@ -326,5 +250,4 @@ 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,
} }
+72 -152
View File
@@ -1,15 +1,11 @@
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::{ use axum_extra::{headers::Cookie, typed_header::TypedHeader};
headers::{authorization::Bearer, Authorization, Cookie},
typed_header::TypedHeader,
};
use chrono::{Duration as ChronoDuration, Utc}; use chrono::{Duration as ChronoDuration, Utc};
use diesel::{pg::PgConnection, prelude::*}; use diesel::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};
@@ -18,11 +14,8 @@ use uuid::Uuid;
use crate::{ use crate::{
auth::{password, AuthenticatedUser}, auth::{password, AuthenticatedUser},
error::{AppError, AppResult}, error::{AppError, AppResult},
models::{NewRefreshToken, RefreshToken, Tenant, User, UserMembership}, models::{NewRefreshToken, RefreshToken, User},
schema::{ schema::{refresh_tokens, users::dsl},
refresh_tokens, tenants::dsl as tenant_dsl, user_memberships::dsl as memberships_dsl,
users::dsl,
},
state::AppState, state::AppState,
}; };
@@ -34,8 +27,6 @@ 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)]
@@ -45,28 +36,11 @@ 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<Response> { ) -> AppResult<(HeaderMap, Json<LoginResponse>)> {
let mut conn = state.db_unscoped()?; let mut conn = state.db()?;
let user: User = dsl::users let user: User = dsl::users
.filter(dsl::username.eq(&payload.username)) .filter(dsl::username.eq(&payload.username))
@@ -79,67 +53,55 @@ pub async fn login(
return Err(AppError::unauthorized()); return Err(AppError::unauthorized());
} }
let memberships: Vec<(UserMembership, Tenant)> = memberships_dsl::user_memberships let access_token = state
.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_tenant_selector_token(user.id) .generate_token(user.id, &user.username, &user.role)
.map_err(AppError::from)?; .map_err(AppError::from)?;
let tenants = memberships let now = Utc::now();
.into_iter() let refresh_value = generate_refresh_token();
.map(|(_, tenant)| TenantSummary { let refresh_hash = hash_refresh_token(&refresh_value);
tenant_id: tenant.id, let refresh_expires_at = now + ChronoDuration::days(state.config.refresh_token_expiry_days);
slug: tenant.slug,
})
.collect();
let response = Json(TenantSelectionResponse { let new_refresh = NewRefreshToken {
selection_token, id: Uuid::new_v4(),
tenants, user_id: user.id,
}) token_hash: refresh_hash,
.into_response(); issued_at: now.naive_utc(),
expires_at: refresh_expires_at.naive_utc(),
};
Ok(response) diesel::insert_into(refresh_tokens::table)
.values(&new_refresh)
.execute(&mut conn)?;
let mut headers = HeaderMap::new();
headers.insert(
SET_COOKIE,
build_refresh_cookie(&state, &refresh_value, refresh_expires_at),
);
Ok((
headers,
Json(LoginResponse {
access_token,
token_type: "Bearer".to_string(),
expires_in: state.config.jwt_expiry_minutes * 60,
}),
))
} }
pub async fn refresh( pub async fn refresh(
State(state): State<AppState>, State(state): State<AppState>,
jar: Option<TypedHeader<Cookie>>, jar: Option<TypedHeader<Cookie>>,
) -> AppResult<Response> { ) -> AppResult<(HeaderMap, Json<LoginResponse>)> {
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_unscoped()?; let mut conn = state.db()?;
let now = Utc::now(); let now = Utc::now();
let now_naive = now.naive_utc(); let now_naive = now.naive_utc();
@@ -166,39 +128,41 @@ pub async fn refresh(
.first(&mut conn) .first(&mut conn)
.map_err(AppError::from)?; .map_err(AppError::from)?;
issue_session(&state, &mut conn, &user, token.tenant_id) let access_token = state
}
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
.verify_tenant_selector_token(bearer.token()) .generate_token(user.id, &user.username, &user.role)
.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)?;
issue_session(&state, &mut conn, &user, payload.tenant_id) let new_refresh_value = generate_refresh_token();
let new_refresh_hash = hash_refresh_token(&new_refresh_value);
let new_refresh_expires = now + ChronoDuration::days(state.config.refresh_token_expiry_days);
let new_refresh = NewRefreshToken {
id: Uuid::new_v4(),
user_id: user.id,
token_hash: new_refresh_hash,
issued_at: now_naive,
expires_at: new_refresh_expires.naive_utc(),
};
diesel::insert_into(refresh_tokens::table)
.values(&new_refresh)
.execute(&mut conn)?;
let mut headers = HeaderMap::new();
headers.insert(
SET_COOKIE,
build_refresh_cookie(&state, &new_refresh_value, new_refresh_expires),
);
Ok((
headers,
Json(LoginResponse {
access_token,
token_type: "Bearer".to_string(),
expires_in: state.config.jwt_expiry_minutes * 60,
}),
))
} }
pub async fn logout( pub async fn logout(
@@ -206,7 +170,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_unscoped()?; let mut conn = state.db()?;
let now = Utc::now().naive_utc(); let now = Utc::now().naive_utc();
let mut rows_affected = 0; let mut rows_affected = 0;
@@ -250,50 +214,6 @@ 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());
+33 -64
View File
@@ -1,6 +1,11 @@
use std::collections::{BTreeMap, HashMap}; use std::collections::{BTreeMap, HashMap};
use axum::{extract::Path, http::StatusCode, Json}; use axum::{
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
Json,
};
use chrono::Utc; use 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};
@@ -8,16 +13,14 @@ 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},
utils::{ state::AppState,
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,
@@ -56,19 +59,15 @@ struct CorrespondentChangeset<'a> {
} }
pub async fn list_correspondents( pub async fn list_correspondents(
TenantScopedConn { State(state): State<AppState>,
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,
@@ -94,15 +93,11 @@ pub async fn list_correspondents(
response.push(build_summary(correspondent, role_counts)); response.push(build_summary(correspondent, role_counts));
} }
response.into_json() Ok(Json(response))
} }
pub async fn create_correspondent( pub async fn create_correspondent(
TenantScopedConn { State(state): State<AppState>,
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();
@@ -116,9 +111,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)
@@ -130,29 +125,19 @@ pub async fn create_correspondent(
Err(err) => return Err(AppError::from(err)), Err(err) => return Err(AppError::from(err)),
} }
let correspondent: Correspondent = correspondents::table let correspondent: Correspondent = correspondents::table.find(new_id).first(&mut conn)?;
.find(new_id) Ok(Json(build_summary(correspondent, BTreeMap::new())))
.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)
.filter(correspondents::tenant_id.eq(tenant_id)) .first(&mut conn)?;
.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 {
@@ -164,7 +149,6 @@ 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() {
@@ -183,8 +167,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, tenant_id, correspondent_id)?; let usage = load_usage_for_correspondent(&mut conn, correspondent_id)?;
return build_summary(existing.clone(), usage).into_json(); return Ok(Json(build_summary(existing.clone(), usage)));
} }
let mut changeset = CorrespondentChangeset::default(); let mut changeset = CorrespondentChangeset::default();
@@ -196,33 +180,24 @@ pub async fn update_correspondent(
} }
let now = Utc::now().naive_utc(); let now = Utc::now().naive_utc();
diesel::update( diesel::update(correspondents::table.find(correspondent_id))
correspondents::table
.find(correspondent_id)
.filter(correspondents::tenant_id.eq(tenant_id)),
)
.set((&changeset, correspondents::updated_at.eq(now))) .set((&changeset, correspondents::updated_at.eq(now)))
.execute(&mut conn)?; .execute(&mut conn)?;
let updated: Correspondent = correspondents::table let updated: Correspondent = correspondents::table
.find(correspondent_id) .find(correspondent_id)
.filter(correspondents::tenant_id.eq(tenant_id)) .first(&mut conn)?;
.first(&mut conn) let usage = load_usage_for_correspondent(&mut conn, correspondent_id)?;
.one()?; Ok(Json(build_summary(updated, usage)))
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>,
TenantScopedConn { ) -> AppResult<impl IntoResponse> {
mut conn, let mut conn = state.db()?;
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)?;
@@ -233,16 +208,12 @@ pub async fn delete_correspondent(
)); ));
} }
let deleted = diesel::delete( let deleted =
correspondents::table diesel::delete(correspondents::table.find(correspondent_id)).execute(&mut conn)?;
.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());
} }
no_content() Ok(StatusCode::NO_CONTENT)
} }
fn build_summary( fn build_summary(
@@ -272,12 +243,10 @@ 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
+27 -74
View File
@@ -10,15 +10,14 @@ 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::TenantScopedConn, auth::AuthenticatedUser,
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, DocumentResponse, to_document_response, to_iso, DocumentResponse,
}; };
use crate::utils::time::to_iso;
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct CreateFolderRequest { pub struct CreateFolderRequest {
@@ -71,17 +70,15 @@ pub struct FolderInfo {
} }
pub async fn ensure_folder_path( pub async fn ensure_folder_path(
TenantScopedConn { State(state): State<AppState>,
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;
@@ -96,14 +93,12 @@ 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()?
}; };
@@ -115,7 +110,6 @@ 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)
@@ -138,22 +132,19 @@ pub async fn ensure_folder_path(
} }
pub async fn create_folder( pub async fn create_folder(
TenantScopedConn { State(state): State<AppState>,
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)
@@ -170,13 +161,10 @@ 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>,
TenantScopedConn { user: AuthenticatedUser,
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 {
@@ -188,10 +176,7 @@ 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 folders::table.find(id).first::<Folder>(&mut conn)?,
.find(id)
.filter(folders::tenant_id.eq(tenant_id))
.first::<Folder>(&mut conn)?,
)), )),
None => None, None => None,
}; };
@@ -199,13 +184,11 @@ 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)?
}; };
@@ -214,7 +197,6 @@ 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 {
@@ -232,7 +214,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, tenant_id, &docs).await?; let primary_versions = load_primary_assets(&state, &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 {
@@ -241,7 +223,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_id, user.user_id,
doc, doc,
tags, tags,
correspondents, correspondents,
@@ -262,23 +244,16 @@ 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 folders::table.find(folder_id).first::<Folder>(conn)?;
.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 folders::table.filter(folders::parent_id.eq(Some(folder_id))),
.filter(folders::parent_id.eq(Some(folder_id)))
.filter(folders::tenant_id.eq(tenant_id)),
)) ))
.get_result(conn)?; .get_result(conn)?;
@@ -291,7 +266,6 @@ 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)?;
@@ -302,12 +276,7 @@ pub async fn delete_folder(
)); ));
} }
diesel::delete( diesel::delete(folders::table.find(folder_id)).execute(conn)?;
folders::table
.filter(folders::id.eq(folder_id))
.filter(folders::tenant_id.eq(tenant_id)),
)
.execute(conn)?;
Ok(()) Ok(())
})?; })?;
@@ -316,19 +285,14 @@ 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>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
Json(payload): Json<UpdateFolderRequest>, Json(payload): Json<UpdateFolderRequest>,
) -> AppResult<StatusCode> { ) -> AppResult<StatusCode> {
let mut conn = state.db()?;
conn.transaction::<(), AppError, _>(|conn| { conn.transaction::<(), AppError, _>(|conn| {
let folder: Folder = folders::table let folder: Folder = folders::table.find(folder_id).first(conn)?;
.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;
@@ -339,12 +303,9 @@ pub async fn update_folder(
} }
if let Some(parent_id) = parent_request { if let Some(parent_id) = parent_request {
let _parent: Folder = folders::table let _parent: Folder = folders::table.find(parent_id).first(conn)?;
.find(parent_id)
.filter(folders::tenant_id.eq(tenant_id))
.first(conn)?;
let descendant_ids = gather_descendant_folder_ids(conn, tenant_id, folder_id)?; let descendant_ids = gather_descendant_folder_ids(conn, folder_id)?;
if descendant_ids.contains(&parent_id) { if descendant_ids.contains(&parent_id) {
return Err(AppError::bad_request( return Err(AppError::bad_request(
"cannot move folder into itself or a descendant", "cannot move folder into itself or a descendant",
@@ -380,7 +341,6 @@ 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 {
@@ -388,7 +348,6 @@ 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()?
}; };
@@ -399,11 +358,7 @@ pub async fn update_folder(
)); ));
} }
diesel::update( diesel::update(folders::table.find(folder_id))
folders::table
.find(folder_id)
.filter(folders::tenant_id.eq(tenant_id)),
)
.set(( .set((
folders::parent_id.eq(next_parent), folders::parent_id.eq(next_parent),
folders::name.eq(&new_name), folders::name.eq(&new_name),
@@ -428,7 +383,6 @@ 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];
@@ -437,7 +391,6 @@ 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());
+2 -4
View File
@@ -50,7 +50,6 @@ 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()
@@ -58,6 +57,7 @@ pub fn create_router(state: AppState) -> Router<()> {
"/", "/",
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,6 +75,7 @@ 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),
@@ -119,14 +120,11 @@ 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));
Router::new() Router::new()
+32 -75
View File
@@ -1,16 +1,19 @@
use crate::utils::json::{classify_nullable, NullableValue}; use crate::utils::json::{classify_nullable, NullableValue};
use axum::{extract::Path, http::StatusCode, Json}; use axum::{
extract::{Path, State},
http::StatusCode,
Json,
};
use diesel::{dsl::count_star, prelude::*}; use 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::utils::db::{no_content, EnsureEntity, IntoJsonResponse}; use crate::state::AppState;
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct CreateTagRequest { pub struct CreateTagRequest {
@@ -33,27 +36,19 @@ pub struct TagCatalogEntry {
pub usage_count: i64, pub usage_count: i64,
} }
pub async fn list_tags( pub async fn list_tags(State(state): State<AppState>) -> AppResult<Json<Vec<TagCatalogEntry>>> {
TenantScopedConn { let mut conn = state.db()?;
mut conn,
tenant_id, let tag_list: Vec<Tag> = tags::table.order(tags::label.asc()).load(&mut conn)?;
..
}: 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: Vec<TagCatalogEntry> = tag_list let response = tag_list
.into_iter() .into_iter()
.map(|tag| TagCatalogEntry { .map(|tag| TagCatalogEntry {
id: tag.id, id: tag.id,
@@ -63,26 +58,22 @@ pub async fn list_tags(
}) })
.collect(); .collect();
response.into_json() Ok(Json(response))
} }
pub async fn create_tag( pub async fn create_tag(
TenantScopedConn { State(state): State<AppState>,
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)
@@ -99,35 +90,22 @@ pub async fn create_tag(
Err(err) => return Err(AppError::from(err)), Err(err) => return Err(AppError::from(err)),
} }
let tag: Tag = tags::table let tag: Tag = tags::table.find(new_tag.id).first(&mut conn)?;
.find(new_tag.id) Ok(Json(TagCatalogEntry {
.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 existing: Tag = tags::table let mut conn = state.db()?;
.find(tag_id) let existing: Tag = tags::table.find(tag_id).first(&mut conn)?;
.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)?;
@@ -138,13 +116,12 @@ 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 TagCatalogEntry { return Ok(Json(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;
@@ -163,7 +140,6 @@ 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() {
@@ -198,7 +174,6 @@ 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 {
@@ -216,45 +191,32 @@ 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( diesel::update(tags::table.find(tag_id))
tags::table
.find(tag_id)
.filter(tags::tenant_id.eq(tenant_id)),
)
.set(&changeset) .set(&changeset)
.execute(&mut conn)?; .execute(&mut conn)?;
let updated: Tag = tags::table let updated: Tag = tags::table.find(tag_id).first(&mut conn)?;
.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)?;
TagCatalogEntry { Ok(Json(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>,
TenantScopedConn { ) -> AppResult<impl axum::response::IntoResponse> {
mut conn, let mut conn = state.db()?;
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)?;
@@ -264,15 +226,10 @@ pub async fn delete_tag(
)); ));
} }
let deleted = diesel::delete( let deleted = diesel::delete(tags::table.find(tag_id)).execute(&mut conn)?;
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());
} }
no_content() Ok(StatusCode::NO_CONTENT)
} }
+103 -184
View File
@@ -20,26 +20,17 @@ 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, tenants::dsl as tenant_dsl, folders::dsl as folders_dsl, users::dsl as users_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 TenantEntry { struct WebDavUser {
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> {
@@ -77,7 +68,7 @@ async fn handle_propfind(
path: &str, path: &str,
headers: HeaderMap, headers: HeaderMap,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
let context = match authenticate(state, &headers)? { let _user = match authenticate(state, &headers)? {
Some(user) => user, Some(user) => user,
None => return Ok(unauthorized_response()), None => return Ok(unauthorized_response()),
}; };
@@ -88,36 +79,18 @@ async fn handle_propfind(
}; };
let segments = parse_segments(path)?; let segments = parse_segments(path)?;
let resolution = match resolve_path(state, &segments)? {
let resources = if segments.is_empty() {
build_account_root_resources(&context.tenants, depth)
} else {
let (requested_slug, remainder) = segments.split_first().unwrap();
let tenant_entry = match context
.tenants
.iter()
.find(|entry| entry.slug.eq_ignore_ascii_case(requested_slug))
{
Some(entry) => TenantEntry {
tenant_id: entry.tenant_id,
slug: entry.slug.clone(),
},
None => return Ok(not_found_response()),
};
let 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()),
}; };
match resolution { let resources = match resolution {
ResolvedPath::TenantRoot { chain } => { ResolvedPath::Root => {
let contents = fetch_folder_contents(state, tenant_entry.tenant_id, None)?; let contents = fetch_folder_contents(state, None)?;
build_resources_for_folder(None, &chain, &contents, depth) build_resources_for_folder(None, &[], &contents, depth)
} }
ResolvedPath::Folder { folder, chain } => { ResolvedPath::Folder { folder, chain } => {
let contents = let contents = fetch_folder_contents(state, Some(folder.id))?;
fetch_folder_contents(state, tenant_entry.tenant_id, Some(folder.id))?;
build_resources_for_folder(Some(&folder), &chain, &contents, depth) build_resources_for_folder(Some(&folder), &chain, &contents, depth)
} }
ResolvedPath::Document { ResolvedPath::Document {
@@ -125,7 +98,6 @@ async fn handle_propfind(
version, version,
chain, chain,
} => build_resources_for_document(&chain, &document, &version), } => build_resources_for_document(&chain, &document, &version),
}
}; };
let body = render_multistatus(&resources) let body = render_multistatus(&resources)
@@ -146,34 +118,13 @@ async fn handle_get_or_head(
headers: HeaderMap, headers: HeaderMap,
method: Method, method: Method,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
let context = match authenticate(state, &headers)? { let _user = 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 (requested_slug, remainder) = match segments.split_first() { let resolution = match resolve_path(state, &segments)? {
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()),
}; };
@@ -268,29 +219,21 @@ 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_for_tenant(tenant_id)?; let mut conn = state.db()?;
let folder = match folder_id { let folder = match folder_id {
Some(id) => Some( Some(id) => Some(folders_dsl::folders.find(id).first::<Folder>(&mut conn)?),
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)?,
@@ -298,7 +241,6 @@ 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 {
@@ -348,9 +290,8 @@ 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 storage = state.storage_for_tenant(document.tenant_id)?; let url = state
.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),
@@ -397,7 +338,7 @@ async fn stream_document(
builder = builder.header("Accept-Ranges", "bytes"); builder = builder.header("Accept-Ranges", "bytes");
if let Some(disposition) = inline_content_disposition(&document.filename) { if let Some(disposition) = content_disposition(&document.filename) {
builder = builder.header(header::CONTENT_DISPOSITION, disposition); builder = builder.header(header::CONTENT_DISPOSITION, disposition);
} }
@@ -419,7 +360,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<WebDavContext>, AppError> { fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavUser>, 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() {
@@ -464,7 +405,7 @@ fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavCo
}; };
tracing::debug!(%username, "attempting webdav login"); tracing::debug!(%username, "attempting webdav login");
let mut conn = state.db_unscoped()?; let mut conn = state.db()?;
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))
@@ -486,27 +427,10 @@ fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavCo
return Ok(None); return Ok(None);
} }
let tenant_rows: Vec<(Uuid, String)> = memberships_dsl::user_memberships tracing::debug!(%username, "webdav login success");
.inner_join(tenant_dsl::tenants) Ok(Some(WebDavUser {
.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,
})) }))
} }
@@ -520,10 +444,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(|| chain.last().cloned().unwrap_or_else(|| "/".to_string())); .unwrap_or_else(|| "/".to_string());
let href = build_href(chain, true); let href = build_href(chain, true);
let last_modified = folder.map(|folder| to_http_date(folder.updated_at)); let last_modified = folder.map(|folder| format_http_date(folder.updated_at));
resources.push(DavResource { resources.push(DavResource {
href, href,
@@ -547,7 +471,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(to_http_date(subfolder.updated_at)), last_modified: Some(format_http_date(subfolder.updated_at)),
}); });
} }
@@ -564,37 +488,6 @@ 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,
@@ -616,7 +509,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(to_http_date(document.updated_at)), last_modified: Some(format_http_date(document.updated_at)),
} }
} }
@@ -697,6 +590,32 @@ 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>,
@@ -716,10 +635,9 @@ struct DavResource {
content_type: Option<String>, content_type: Option<String>,
last_modified: Option<String>, last_modified: Option<String>,
} }
enum ResolvedPath { enum ResolvedPath {
TenantRoot { Root,
chain: Vec<String>,
},
Folder { Folder {
folder: Folder, folder: Folder,
chain: Vec<String>, chain: Vec<String>,
@@ -731,37 +649,37 @@ enum ResolvedPath {
}, },
} }
fn resolve_path( fn resolve_path(state: &AppState, segments: &[String]) -> AppResult<Option<ResolvedPath>> {
state: &AppState,
tenant: &TenantEntry,
segments: &[String],
) -> AppResult<Option<ResolvedPath>> {
let mut conn = state.db_for_tenant(tenant.tenant_id)?;
let mut parent_id: Option<Uuid> = None;
let mut chain: Vec<String> = vec![tenant.slug.clone()];
let mut current_folder: Option<Folder> = None;
if segments.is_empty() { if segments.is_empty() {
return Ok(Some(ResolvedPath::TenantRoot { chain })); return Ok(Some(ResolvedPath::Root));
} }
let mut conn = state.db()?;
let mut parent_id: Option<Uuid> = None;
let mut chain: Vec<String> = Vec::new();
let mut current_folder: Option<Folder> = None;
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;
if let Some(folder) = find_folder_by_name(&mut conn, tenant.tenant_id, parent_id, segment)? match find_folder_by_name(&mut conn, parent_id, segment)? {
{ Some(folder) => {
chain.push(folder.name.clone());
if is_last { if is_last {
chain.push(folder.name.clone());
return Ok(Some(ResolvedPath::Folder { folder, chain })); return Ok(Some(ResolvedPath::Folder { folder, chain }));
} }
parent_id = Some(folder.id); parent_id = Some(folder.id);
chain.push(folder.name.clone());
current_folder = Some(folder); current_folder = Some(folder);
continue; continue;
} }
None => {}
}
if is_last { if is_last {
if let Some((document, version)) = if let Some((document, version)) =
find_document_by_filename(&mut conn, tenant.tenant_id, parent_id, segment)? find_document_by_filename(&mut conn, parent_id, segment)?
{ {
chain.push(document.filename.clone()); chain.push(document.filename.clone());
return Ok(Some(ResolvedPath::Document { return Ok(Some(ResolvedPath::Document {
@@ -773,22 +691,26 @@ fn resolve_path(
} }
if let Ok(uuid) = Uuid::parse_str(segment) { if let Ok(uuid) = Uuid::parse_str(segment) {
if let Some(folder) = find_folder_by_id(&mut conn, tenant.tenant_id, uuid)? { if let Some(folder) = folders_dsl::folders
.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);
} }
chain.push(folder.name.clone()); if !is_last {
if is_last {
return Ok(Some(ResolvedPath::Folder { folder, chain }));
}
parent_id = Some(folder.id); parent_id = Some(folder.id);
chain.push(folder.name.clone());
current_folder = Some(folder); current_folder = Some(folder);
continue; continue;
} else {
chain.push(folder.name.clone());
return Ok(Some(ResolvedPath::Folder { folder, chain }));
}
} }
if let Some((document, version)) = if let Some((document, version)) = find_document_by_id(&mut conn, uuid)? {
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);
} }
@@ -801,6 +723,19 @@ fn resolve_path(
} }
} }
if is_last {
if let Some((document, version)) =
find_document_by_filename(&mut conn, parent_id, segment)?
{
chain.push(document.filename.clone());
return Ok(Some(ResolvedPath::Document {
document,
version,
chain,
}));
}
}
return Ok(None); return Ok(None);
} }
@@ -809,46 +744,32 @@ fn resolve_path(
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 mut query = folders_dsl::folders let result = match parent_id {
.filter(folders_dsl::tenant_id.eq(tenant_id)) Some(parent) => folders_dsl::folders
.into_boxed(); .filter(folders_dsl::parent_id.eq(Some(parent)))
query = match parent_id {
Some(parent) => query.filter(folders_dsl::parent_id.eq(Some(parent))),
None => query.filter(folders_dsl::parent_id.is_null()),
};
Ok(query
.filter(folders_dsl::name.eq(name)) .filter(folders_dsl::name.eq(name))
.first::<Folder>(conn) .first::<Folder>(conn)
.optional()?) .optional()?,
} None => folders_dsl::folders
.filter(folders_dsl::parent_id.is_null())
fn find_folder_by_id( .filter(folders_dsl::name.eq(name))
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) .first::<Folder>(conn)
.optional()?) .optional()?,
};
Ok(result)
} }
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();
@@ -869,12 +790,10 @@ 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()?
+3 -63
View File
@@ -8,18 +8,6 @@ diesel::table! {
metadata -> Jsonb, metadata -> Jsonb,
created_at -> Timestamptz, created_at -> Timestamptz,
updated_at -> Timestamptz, updated_at -> Timestamptz,
tenant_id -> Uuid,
}
}
diesel::table! {
document_asset_objects (id) {
id -> Uuid,
asset_id -> Uuid,
ordinal -> Int4,
s3_key -> Text,
metadata -> Jsonb,
tenant_id -> Uuid,
} }
} }
@@ -28,11 +16,10 @@ diesel::table! {
id -> Uuid, id -> Uuid,
document_version_id -> Uuid, document_version_id -> Uuid,
asset_type -> Text, asset_type -> Text,
s3_key -> Text,
mime_type -> Text, mime_type -> Text,
metadata -> Jsonb, metadata -> Jsonb,
created_at -> Timestamptz, created_at -> Timestamptz,
cardinality -> Nullable<Int4>,
tenant_id -> Uuid,
} }
} }
@@ -44,7 +31,6 @@ diesel::table! {
role -> Varchar, role -> Varchar,
assigned_at -> Timestamptz, assigned_at -> Timestamptz,
assigned_by -> Nullable<Uuid>, assigned_by -> Nullable<Uuid>,
tenant_id -> Uuid,
} }
} }
@@ -54,7 +40,6 @@ 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,
} }
} }
@@ -71,7 +56,6 @@ diesel::table! {
created_at -> Timestamptz, created_at -> Timestamptz,
operations_summary -> Jsonb, operations_summary -> Jsonb,
metadata -> Jsonb, metadata -> Jsonb,
tenant_id -> Uuid,
} }
} }
@@ -93,7 +77,6 @@ diesel::table! {
#[max_length = 255] #[max_length = 255]
title -> Varchar, title -> Varchar,
current_version_id -> Uuid, current_version_id -> Uuid,
tenant_id -> Uuid,
} }
} }
@@ -105,7 +88,6 @@ 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,
} }
} }
@@ -120,7 +102,6 @@ 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,
} }
} }
@@ -134,7 +115,6 @@ 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,
} }
} }
@@ -146,31 +126,6 @@ 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,
} }
} }
@@ -181,38 +136,25 @@ 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 -> 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,
document_asset_objects,
document_assets, document_assets,
document_correspondents, document_correspondents,
document_tags, document_tags,
@@ -222,7 +164,5 @@ diesel::allow_tables_to_appear_in_same_query!(
jobs, jobs,
refresh_tokens, refresh_tokens,
tags, tags,
tenants,
user_memberships,
users, users,
); );
+5 -41
View File
@@ -4,79 +4,43 @@ 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, TenantStorage}, storage::ObjectStorage,
tenants::{apply_tenant_guc, TenantService},
}; };
pub type PgPooledConnection = PooledConnection<ConnectionManager<PgConnection>>; 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>,
storage: Arc<dyn ObjectStorage>, pub 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, config: Arc::new(config),
storage, storage,
jwt, jwt,
tenants,
} }
} }
pub fn db_for_tenant(&self, tenant_id: Uuid) -> AppResult<PgPooledConnection> { pub fn db(&self) -> 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}")))
}
} }
+1 -54
View File
@@ -1,15 +1,11 @@
use std::time::Duration; use std::time::Duration;
use std::sync::Arc; use anyhow::{Context, Result};
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(
@@ -123,52 +119,3 @@ 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
@@ -1,91 +0,0 @@
use axum::{async_trait, extract::FromRequestParts, http::request::Parts};
use diesel::{pg::PgConnection, prelude::*, sql_types::Text};
use uuid::Uuid;
use crate::{
db::PgPool,
error::{AppError, AppResult},
models::Tenant,
schema::tenants::dsl,
state::AppState,
};
pub struct TenantRepository;
impl TenantRepository {
pub fn get_by_id(conn: &mut PgConnection, tenant_id: Uuid) -> AppResult<Tenant> {
dsl::tenants.find(tenant_id).first(conn).map_err(Into::into)
}
pub fn get_by_slug(conn: &mut PgConnection, slug: &str) -> AppResult<Tenant> {
dsl::tenants
.filter(dsl::slug.eq(slug))
.first(conn)
.map_err(Into::into)
}
}
#[derive(Clone)]
pub struct TenantService {
pool: PgPool,
}
impl TenantService {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
pub fn get_by_id(&self, tenant_id: Uuid) -> AppResult<Tenant> {
let tenant = self.load(|conn| TenantRepository::get_by_id(conn, tenant_id))?;
Ok(tenant)
}
pub fn get_by_slug(&self, slug: &str) -> AppResult<Tenant> {
let slug_owned = slug.to_owned();
let tenant = self.load(|conn| TenantRepository::get_by_slug(conn, &slug_owned))?;
Ok(tenant)
}
pub fn tenant_id_for_slug(&self, slug: &str) -> AppResult<Uuid> {
Ok(self.get_by_slug(slug)?.id)
}
fn load<F>(&self, loader: F) -> AppResult<Tenant>
where
F: FnOnce(&mut PgConnection) -> AppResult<Tenant>,
{
let mut conn = self
.pool
.get()
.map_err(|err| AppError::internal(format!("database pool error: {err}")))?;
let tenant = loader(&mut conn)?;
Ok(tenant)
}
}
pub fn apply_tenant_guc(conn: &mut PgConnection, tenant_id: Uuid) -> AppResult<()> {
diesel::sql_query("SELECT set_config('papercrate.tenant_id', $1, true)")
.bind::<Text, _>(tenant_id.to_string())
.execute(conn)
.map(|_| ())
.map_err(AppError::from)
}
pub struct TenantContext {
pub tenant: Tenant,
}
#[async_trait]
impl FromRequestParts<AppState> for TenantContext {
type Rejection = AppError;
async fn from_request_parts(
_parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
let tenant = state
.tenants
.get_by_slug(&state.config.default_tenant_slug)?;
Ok(Self { tenant })
}
}
-14
View File
@@ -1,14 +0,0 @@
use std::sync::Arc;
use anyhow::Result;
use crate::{config::AppConfig, state::AppState, utils::tracing::init_tracing};
/// Initialize tracing, load configuration, and build the shared `AppState`.
/// Optionally override the connection pool size for lightweight components.
pub async fn init_component(name: &str, pool_override: Option<u32>) -> Result<Arc<AppState>> {
init_tracing("info");
let config = AppConfig::load_and_log(name)?;
let state = AppState::initialize(config, pool_override).await?;
Ok(Arc::new(state))
}
-59
View File
@@ -1,59 +0,0 @@
use diesel::{pg::PgConnection, result::Error as DieselError};
use uuid::Uuid;
use crate::{
error::{AppError, AppResult},
state::AppState,
};
pub trait EnsureEntity<T> {
fn one(self) -> AppResult<T>;
fn maybe(self) -> AppResult<Option<T>>;
}
impl<T> EnsureEntity<T> for Result<T, DieselError> {
fn one(self) -> AppResult<T> {
self.map_err(AppError::from)
}
fn maybe(self) -> AppResult<Option<T>> {
match self {
Ok(value) => Ok(Some(value)),
Err(DieselError::NotFound) => Ok(None),
Err(err) => Err(AppError::from(err)),
}
}
}
impl AppState {
pub fn with_tenant_conn<F, T>(&self, tenant_id: Uuid, f: F) -> AppResult<T>
where
F: FnOnce(&mut PgConnection) -> AppResult<T>,
{
let mut conn = self.db_for_tenant(tenant_id)?;
f(&mut conn)
}
}
pub fn validate_bulk_ids(ids: &mut Vec<Uuid>, label: &str) -> AppResult<()> {
if ids.is_empty() {
return Err(AppError::bad_request(format!("{label} must not be empty")));
}
ids.sort_unstable();
ids.dedup();
Ok(())
}
pub trait IntoJsonResponse<T> {
fn into_json(self) -> AppResult<axum::Json<T>>;
}
impl<T> IntoJsonResponse<T> for T {
fn into_json(self) -> AppResult<axum::Json<T>> {
Ok(axum::Json(self))
}
}
pub fn no_content() -> AppResult<axum::http::StatusCode> {
Ok(axum::http::StatusCode::NO_CONTENT)
}
-22
View File
@@ -1,22 +0,0 @@
use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
/// Build an inline `Content-Disposition` header value for a given filename.
pub fn inline_content_disposition(filename: &str) -> Option<String> {
if filename.is_empty() {
return None;
}
let sanitized: String = filename
.chars()
.map(|ch| match ch {
'"' | '\\' => '_',
_ => ch,
})
.collect();
let encoded = utf8_percent_encode(&sanitized, NON_ALPHANUMERIC);
Some(format!(
"inline; filename=\"{}\"; filename*=UTF-8''{}",
sanitized, encoded
))
}
-7
View File
@@ -1,8 +1 @@
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
@@ -1,115 +0,0 @@
//! Document storage path helpers.
//!
//! NOTE: The path layout produced here is part of the durable storage contract.
//! External systems (presigned URLs, lifecycle jobs, migrations) expect the
//! `documents/{document_id}/...` structure to remain stable. Coordinate before
//! changing any of these helpers to avoid breaking compatibility with existing
//! objects.
use uuid::Uuid;
const DOCUMENTS_PREFIX: &str = "documents";
/// Returns the root prefix for all objects belonging to a document.
pub fn document_prefix(document_id: Uuid) -> String {
format!("{DOCUMENTS_PREFIX}/{document_id}")
}
/// Returns the prefix for a specific document version (without the object id).
pub fn document_version_prefix(document_id: Uuid, version_number: i32) -> String {
format!("{}/v{}", document_prefix(document_id), version_number)
}
/// Returns the storage key for a stored document version blob.
pub fn document_version_object_key(
document_id: Uuid,
version_number: i32,
version_id: Uuid,
) -> String {
format!(
"{}/{}",
document_version_prefix(document_id, version_number),
version_id
)
}
fn document_asset_prefix(document_id: Uuid, version_number: i32) -> String {
format!(
"{}/assets",
document_version_prefix(document_id, version_number)
)
}
fn document_asset_type_prefix(document_id: Uuid, version_number: i32, asset_type: &str) -> String {
format!(
"{}/{}",
document_asset_prefix(document_id, version_number),
asset_type
)
}
/// Returns the storage prefix under which the asset objects for a type/id pair live.
pub fn document_asset_object_prefix(
document_id: Uuid,
version_number: i32,
asset_type: &str,
asset_id: Uuid,
) -> String {
format!(
"{}/{}",
document_asset_type_prefix(document_id, version_number, asset_type),
asset_id
)
}
/// Returns the full storage key for a specific asset object (ordinal).
pub fn document_asset_object_key(
document_id: Uuid,
version_number: i32,
asset_type: &str,
asset_id: Uuid,
ordinal: i32,
) -> String {
format!(
"{}/{}",
document_asset_object_prefix(document_id, version_number, asset_type, asset_id),
ordinal
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn generates_expected_paths() {
let document_id = Uuid::nil();
let version_id = Uuid::nil();
let asset_id = Uuid::nil();
assert_eq!(
document_prefix(document_id),
format!("documents/{document_id}")
);
assert_eq!(
document_version_prefix(document_id, 3),
format!("documents/{document_id}/v3")
);
assert_eq!(
document_version_object_key(document_id, 3, version_id),
format!("documents/{document_id}/v3/{version_id}")
);
assert_eq!(
document_asset_object_prefix(document_id, 3, "preview", asset_id),
format!("documents/{document_id}/v3/assets/preview/{asset_id}")
);
assert_eq!(
document_asset_object_key(document_id, 3, "preview", asset_id, 2),
format!("documents/{document_id}/v3/assets/preview/{asset_id}/2")
);
}
}
-13
View File
@@ -1,13 +0,0 @@
use chrono::{DateTime, NaiveDateTime, Utc};
/// Format a timestamp as RFC3339 using UTC.
pub fn to_iso(dt: NaiveDateTime) -> String {
DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc).to_rfc3339()
}
/// Format a timestamp for HTTP headers (RFC 7231 date).
pub fn to_http_date(dt: NaiveDateTime) -> String {
DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc)
.format("%a, %d %b %Y %H:%M:%S GMT")
.to_string()
}
-14
View File
@@ -1,14 +0,0 @@
use tracing_subscriber::EnvFilter;
/// Initialize tracing with an optional default level.
///
/// Falls back to `default_level` when `RUST_LOG` is not provided.
pub fn init_tracing(default_level: &str) {
let filter =
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default_level));
tracing_subscriber::fmt()
.with_env_filter(filter)
.with_target(false)
.compact()
.init();
}
-10
View File
@@ -1,10 +0,0 @@
use crate::error::{AppError, AppResult};
/// Ensure an entity exists, returning a bad request error when it does not.
pub fn ensure_exists(exists: bool, entity: &str) -> AppResult<()> {
if exists {
Ok(())
} else {
Err(AppError::bad_request(format!("{entity} does not exist")))
}
}
+4 -23
View File
@@ -14,7 +14,6 @@ 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};
@@ -41,12 +40,7 @@ impl JobHandler for AnalyzeDocumentJob {
JOB_ANALYZE_DOCUMENT JOB_ANALYZE_DOCUMENT
} }
async fn handle( async fn handle(&self, state: Arc<AppState>, job: crate::models::Job) -> JobExecution {
&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) => {
@@ -57,9 +51,7 @@ impl JobHandler for AnalyzeDocumentJob {
}; };
let state_clone = state.clone(); let state_clone = state.clone();
let tenant_id = job.tenant_id; match task::spawn_blocking(move || analyze_document(state_clone, payload)).await {
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");
@@ -79,14 +71,8 @@ impl JobHandler for AnalyzeDocumentJob {
} }
} }
fn analyze_document( fn analyze_document(state: Arc<AppState>, payload: AnalyzePayload) -> Result<JobExecution, String> {
state: Arc<AppState>, let mut conn = state.db().map_err(|err| format!("{err:?}"))?;
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)
@@ -102,15 +88,12 @@ fn analyze_document(
.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:?}"))?;
@@ -146,7 +129,6 @@ fn analyze_document(
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,
@@ -164,7 +146,6 @@ fn analyze_document(
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,
+18 -52
View File
@@ -12,10 +12,9 @@ use uuid::Uuid;
use crate::{ use crate::{
jobs::JOB_INDEX_DOCUMENT_TEXT, jobs::JOB_INDEX_DOCUMENT_TEXT,
models::{Document, DocumentVersion}, models::{Document, DocumentAsset, DocumentVersion},
schema::{document_asset_objects, document_assets, document_versions, documents}, schema::{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};
@@ -40,12 +39,7 @@ impl JobHandler for IndexDocumentTextJob {
JOB_INDEX_DOCUMENT_TEXT JOB_INDEX_DOCUMENT_TEXT
} }
async fn handle( async fn handle(&self, state: Arc<AppState>, job: crate::models::Job) -> JobExecution {
&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) => {
@@ -58,29 +52,16 @@ 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 => {
return JobExecution::Failed { warn!("quickwit endpoint missing; skipping indexing");
error: "quickwit endpoint missing".into(), return JobExecution::Success;
};
} }
}; };
let tenant = match state.tenants.get_by_id(job.tenant_id) { let quickwit_index = match &state.config.quickwit_index {
Ok(tenant) => tenant, Some(index) => index.clone(),
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 => {
return JobExecution::Failed { warn!("quickwit index missing; skipping indexing");
error: "tenant quickwit index not configured".into(), return JobExecution::Success;
};
} }
}; };
@@ -106,15 +87,15 @@ impl JobHandler for IndexDocumentTextJob {
} }
}; };
if context.text_s3_key.is_none() { if context.text_asset.is_none() {
warn!(job_id = %job.id, "missing OCR text asset; failing indexing job"); warn!(job_id = %job.id, "missing OCR text asset; failing indexing job");
return JobExecution::Failed { return JobExecution::Failed {
error: "missing OCR text asset".into(), error: "missing OCR text asset".into(),
}; };
} }
let s3_key = context.text_s3_key.unwrap(); let asset = context.text_asset.unwrap();
let text = match storage.get_object(&s3_key).await { let text = match state.storage.get_object(&asset.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) => {
@@ -148,7 +129,6 @@ 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()
}); });
@@ -189,15 +169,15 @@ impl JobHandler for IndexDocumentTextJob {
struct IndexContext { struct IndexContext {
document: Document, document: Document,
version: DocumentVersion, version: DocumentVersion,
text_s3_key: Option<String>, text_asset: Option<DocumentAsset>,
} }
fn load_context(state: Arc<AppState>, payload: &IndexPayload) -> Result<IndexContext, String> { fn load_context(state: Arc<AppState>, payload: &IndexPayload) -> Result<IndexContext, String> {
let mut base_conn = state.db_unscoped().map_err(|err| format!("{err:?}"))?; let mut conn = state.db().map_err(|err| format!("{err:?}"))?;
let version: DocumentVersion = document_versions::table let version: DocumentVersion = document_versions::table
.find(payload.document_version_id) .find(payload.document_version_id)
.first(&mut base_conn) .first(&mut 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 {
@@ -206,26 +186,12 @@ 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 base_conn) .first(&mut conn)
.map_err(|err| format!("{err:?}"))?; .map_err(|err| format!("{err:?}"))?;
let tenant_id = document.tenant_id; let text_asset: Option<DocumentAsset> = document_assets::table
drop(base_conn);
let mut conn = state
.db_for_tenant(tenant_id)
.map_err(|err| format!("{err:?}"))?;
let text_s3_key: Option<String> = document_asset_objects::table
.inner_join(
document_assets::table.on(document_asset_objects::asset_id.eq(document_assets::id)),
)
.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::tenant_id.eq(tenant_id))
.select(document_asset_objects::s3_key)
.first(&mut conn) .first(&mut conn)
.optional() .optional()
.map_err(|err| format!("{err:?}"))?; .map_err(|err| format!("{err:?}"))?;
@@ -233,6 +199,6 @@ fn load_context(state: Arc<AppState>, payload: &IndexPayload) -> Result<IndexCon
Ok(IndexContext { Ok(IndexContext {
document, document,
version, version,
text_s3_key, text_asset,
}) })
} }
+8 -21
View File
@@ -8,7 +8,6 @@ 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;
@@ -26,7 +25,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, storage: TenantStorage) -> JobExecution; async fn handle(&self, state: Arc<AppState>, job: Job) -> JobExecution;
} }
pub struct Worker { pub struct Worker {
@@ -72,7 +71,7 @@ impl Worker {
return Ok(false); return Ok(false);
} }
let mut conn = match self.state.db_unscoped() { let mut conn = match self.state.db() {
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");
@@ -85,22 +84,10 @@ 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 execution = match self.state.storage_for_tenant(job.tenant_id) { let result = handler.handle(self.state.clone(), job.clone()).await;
Ok(storage) => { match result {
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_unscoped() { if let Ok(mut conn) = self.state.db() {
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 {
@@ -109,7 +96,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_unscoped() { if let Ok(mut conn) = self.state.db() {
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");
@@ -117,7 +104,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_unscoped() { if let Ok(mut conn) = self.state.db() {
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");
@@ -126,7 +113,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_unscoped() { if let Ok(mut conn) = self.state.db() {
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");
+28 -117
View File
@@ -19,14 +19,9 @@ use uuid::Uuid;
use crate::{ use crate::{
jobs::{enqueue_job, JOB_GENERATE_OCR_TEXT, JOB_INDEX_DOCUMENT_TEXT}, jobs::{enqueue_job, JOB_GENERATE_OCR_TEXT, JOB_INDEX_DOCUMENT_TEXT},
models::{ models::{Document, DocumentAsset, DocumentVersion, NewDocumentAsset},
Document, DocumentAsset, DocumentAssetObject, DocumentVersion, NewDocumentAsset, schema::{document_assets, document_versions, documents},
NewDocumentAssetObject,
},
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};
@@ -56,12 +51,7 @@ impl JobHandler for GenerateOcrTextJob {
JOB_GENERATE_OCR_TEXT JOB_GENERATE_OCR_TEXT
} }
async fn handle( async fn handle(&self, state: Arc<AppState>, job: crate::models::Job) -> JobExecution {
&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) => {
@@ -98,7 +88,7 @@ impl JobHandler for GenerateOcrTextJob {
return JobExecution::Success; return JobExecution::Success;
} }
let bytes = match storage.get_object(&context.version.s3_key).await { let bytes = match state.storage.get_object(&context.version.s3_key).await {
Ok(bytes) => bytes, 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");
@@ -133,24 +123,19 @@ impl JobHandler for GenerateOcrTextJob {
}; };
}; };
if context.existing_asset.is_some() { let asset_id = context
for object in &context.existing_objects { .existing_asset
if let Err(err) = storage.delete_object(&object.s3_key).await { .as_ref()
warn!(job_id = %job.id, error = %err, s3_key = %object.s3_key, "failed to delete existing ocr asset object"); .map(|asset| asset.id)
} .unwrap_or_else(Uuid::new_v4);
}
}
let asset_id = Uuid::new_v4(); let s3_key = format!(
"documents/{}/v{}/assets/{}/{}",
let s3_key = document_asset_object_prefix( context.document.id, context.version.version_number, OCR_TEXT_ASSET_TYPE, asset_id
context.document.id,
context.version.version_number,
OCR_TEXT_ASSET_TYPE,
asset_id,
); );
if let Err(err) = storage if let Err(err) = state
.storage
.put_object( .put_object(
&s3_key, &s3_key,
generation.text.into_bytes(), generation.text.into_bytes(),
@@ -173,9 +158,12 @@ 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) { if let Err(err) = enqueue_index_job(&state, &payload) {
warn!(job_id = %job.id, error = %err, "failed to enqueue index job"); warn!(job_id = %job.id, error = %err, "failed to enqueue index job");
} }
}
JobExecution::Success JobExecution::Success
} }
Ok(Err(err)) => { Ok(Err(err)) => {
@@ -205,7 +193,6 @@ struct OcrContext {
document: Document, document: Document,
version: DocumentVersion, version: DocumentVersion,
existing_asset: Option<DocumentAsset>, existing_asset: Option<DocumentAsset>,
existing_objects: Vec<DocumentAssetObject>,
skip: bool, skip: bool,
} }
@@ -215,11 +202,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 base_conn = state.db_unscoped().map_err(|err| format!("{err:?}"))?; let mut conn = state.db().map_err(|err| format!("{err:?}"))?;
let version: DocumentVersion = document_versions::table let version: DocumentVersion = document_versions::table
.find(payload.document_version_id) .find(payload.document_version_id)
.first(&mut base_conn) .first(&mut 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 {
@@ -228,53 +215,32 @@ 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 base_conn) .first(&mut conn)
.map_err(|err| format!("{err:?}"))?; .map_err(|err| format!("{err:?}"))?;
let tenant_id = document.tenant_id; let existing: Option<DocumentAsset> = document_assets::table
drop(base_conn);
let mut conn = state
.db_for_tenant(tenant_id)
.map_err(|err| format!("{err:?}"))?;
let existing_asset: Option<DocumentAsset> = document_assets::table
.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:?}"))?;
let existing_objects: Vec<DocumentAssetObject> = if let Some(asset) = &existing_asset {
document_asset_objects::table
.filter(document_asset_objects::asset_id.eq(asset.id))
.filter(document_asset_objects::tenant_id.eq(tenant_id))
.order(document_asset_objects::ordinal.asc())
.load(&mut conn)
.map_err(|err| format!("{err:?}"))?
} else {
Vec::new()
};
let is_pdf = document_is_pdf(&document); let is_pdf = document_is_pdf(&document);
if !is_pdf { if !is_pdf {
return Ok(OcrContext { return Ok(OcrContext {
document, document,
version, version,
existing_asset: existing_asset, existing_asset: existing,
existing_objects,
skip: true, skip: true,
}); });
} }
let skip = existing_asset.is_some() && !payload.force; let skip = existing.is_some() && !payload.force;
Ok(OcrContext { Ok(OcrContext {
document, document,
version, version,
existing_asset, existing_asset: existing,
existing_objects,
skip, skip,
}) })
} }
@@ -403,28 +369,18 @@ fn persist_ocr_metadata(
s3_key: &str, s3_key: &str,
source: &'static str, source: &'static str,
) -> Result<(), String> { ) -> Result<(), String> {
let tenant_id = context.document.tenant_id; let mut conn = state.db().map_err(|err| format!("{err:?}"))?;
let mut conn = state
.db_for_tenant(tenant_id)
.map_err(|err| format!("{err:?}"))?;
if let Some(existing_asset) = &context.existing_asset {
diesel::delete(document_assets::table.filter(document_assets::id.eq(existing_asset.id)))
.execute(&mut conn)
.map_err(|err| format!("{err:?}"))?;
}
let new_asset = NewDocumentAsset { let new_asset = NewDocumentAsset {
id: asset_id, id: asset_id,
document_version_id: context.version.id, document_version_id: context.version.id,
asset_type: OCR_TEXT_ASSET_TYPE.to_string(), asset_type: OCR_TEXT_ASSET_TYPE.to_string(),
s3_key: s3_key.to_string(),
mime_type: "text/plain".to_string(), mime_type: "text/plain".to_string(),
metadata: json!({ metadata: json!({
"generated_at": Utc::now().to_rfc3339(), "generated_at": Utc::now().to_rfc3339(),
"source": source, "source": source,
}), }),
cardinality: Some(1),
tenant_id,
}; };
diesel::insert_into(document_assets::table) diesel::insert_into(document_assets::table)
@@ -435,43 +391,9 @@ fn persist_ocr_metadata(
)) ))
.do_update() .do_update()
.set(( .set((
document_assets::s3_key.eq(excluded(document_assets::s3_key)),
document_assets::mime_type.eq(excluded(document_assets::mime_type)), document_assets::mime_type.eq(excluded(document_assets::mime_type)),
document_assets::metadata.eq(excluded(document_assets::metadata)), document_assets::metadata.eq(excluded(document_assets::metadata)),
document_assets::cardinality.eq(excluded(document_assets::cardinality)),
))
.execute(&mut conn)
.map_err(|err| format!("{err:?}"))?;
let existing_object_id: Option<Uuid> = document_asset_objects::table
.filter(document_asset_objects::asset_id.eq(asset_id))
.filter(document_asset_objects::ordinal.eq(1))
.filter(document_asset_objects::tenant_id.eq(tenant_id))
.select(document_asset_objects::id)
.first(&mut conn)
.optional()
.map_err(|err| format!("{err:?}"))?;
let object_id = existing_object_id.unwrap_or_else(Uuid::new_v4);
let new_object = NewDocumentAssetObject {
id: object_id,
asset_id,
ordinal: 1,
s3_key: s3_key.to_string(),
metadata: json!({}),
tenant_id,
};
diesel::insert_into(document_asset_objects::table)
.values(&new_object)
.on_conflict((
document_asset_objects::asset_id,
document_asset_objects::ordinal,
))
.do_update()
.set((
document_asset_objects::s3_key.eq(excluded(document_asset_objects::s3_key)),
document_asset_objects::metadata.eq(excluded(document_asset_objects::metadata)),
)) ))
.execute(&mut conn) .execute(&mut conn)
.map_err(|err| format!("{err:?}"))?; .map_err(|err| format!("{err:?}"))?;
@@ -480,20 +402,9 @@ 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 base_conn = state.db_unscoped().map_err(|err| format!("{err:?}"))?; let mut conn = state.db().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,
+102 -323
View File
@@ -13,14 +13,9 @@ use uuid::Uuid;
use crate::{ use crate::{
jobs::JOB_GENERATE_THUMBNAILS, jobs::JOB_GENERATE_THUMBNAILS,
models::{ models::{Document, DocumentAsset, DocumentVersion, NewDocumentAsset},
Document, DocumentAsset, DocumentAssetObject, DocumentVersion, NewDocumentAsset, schema::{document_assets, document_versions, documents},
NewDocumentAssetObject,
},
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};
@@ -54,12 +49,7 @@ impl JobHandler for GenerateThumbnailsJob {
JOB_GENERATE_THUMBNAILS JOB_GENERATE_THUMBNAILS
} }
async fn handle( async fn handle(&self, state: Arc<AppState>, job: crate::models::Job) -> JobExecution {
&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) => {
@@ -95,7 +85,7 @@ impl JobHandler for GenerateThumbnailsJob {
return JobExecution::Success; return JobExecution::Success;
} }
let bytes = match storage.get_object(&initial.version.s3_key).await { let bytes = match state.storage.get_object(&initial.version.s3_key).await {
Ok(bytes) => bytes, 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");
@@ -152,134 +142,86 @@ impl JobHandler for GenerateThumbnailsJob {
} }
} }
if initial.existing_preview.is_some() { let thumbnail_asset_id = initial
for object in &initial.existing_preview_objects { .existing_thumbnail
if let Err(err) = storage.delete_object(&object.s3_key).await { .as_ref()
warn!( .map(|asset| asset.id)
job_id = %job.id, .unwrap_or_else(Uuid::new_v4);
error = %err, let thumbnail_s3_key = format!(
s3_key = %object.s3_key, "documents/{}/v{}/assets/{}/{}",
"failed to delete existing preview object"
);
}
}
}
if initial.existing_thumbnail.is_some() {
for object in &initial.existing_thumbnail_objects {
if let Err(err) = storage.delete_object(&object.s3_key).await {
warn!(
job_id = %job.id,
error = %err,
s3_key = %object.s3_key,
"failed to delete existing thumbnail object"
);
}
}
}
let preview_asset_id = Uuid::new_v4();
let thumbnail_asset_id = Uuid::new_v4();
let mut preview_objects: Vec<AssetObjectPersistence> =
Vec::with_capacity(generation.preview.objects.len());
for (index, image) in generation.preview.objects.iter().enumerate() {
if index + 1 > i32::MAX as usize {
return JobExecution::Failed {
error: "too many preview objects".to_string(),
};
}
let ordinal = (index + 1) as i32;
let s3_key = document_asset_object_key(
initial.document.id,
initial.version.version_number,
PREVIEW_ASSET_TYPE,
preview_asset_id,
ordinal,
);
if let Err(err) = storage
.put_object(
&s3_key,
image.image_bytes.clone(),
Some("image/png".into()),
None,
)
.await
{
warn!(job_id = %job.id, error = %err, ordinal, "failed to upload preview; retrying");
return JobExecution::Retry {
delay: Duration::from_secs(30),
error: err.to_string(),
};
}
preview_objects.push(AssetObjectPersistence {
ordinal,
s3_key,
width: image.width,
height: image.height,
});
}
let mut thumbnail_objects: Vec<AssetObjectPersistence> =
Vec::with_capacity(generation.thumbnail.objects.len());
for (index, image) in generation.thumbnail.objects.iter().enumerate() {
if index + 1 > i32::MAX as usize {
return JobExecution::Failed {
error: "too many thumbnail objects".to_string(),
};
}
let ordinal = (index + 1) as i32;
let s3_key = document_asset_object_key(
initial.document.id, initial.document.id,
initial.version.version_number, initial.version.version_number,
THUMBNAIL_ASSET_TYPE, THUMBNAIL_ASSET_TYPE,
thumbnail_asset_id, thumbnail_asset_id
ordinal,
); );
if let Err(err) = storage let preview_asset_id = initial
.existing_preview
.as_ref()
.map(|asset| asset.id)
.unwrap_or_else(Uuid::new_v4);
let preview_s3_key = format!(
"documents/{}/v{}/assets/{}/{}",
initial.document.id,
initial.version.version_number,
PREVIEW_ASSET_TYPE,
preview_asset_id
);
if let Err(err) = state
.storage
.put_object( .put_object(
&s3_key, &preview_s3_key,
image.image_bytes.clone(), generation.preview.image_bytes.clone(),
Some("image/png".into()), Some("image/png".into()),
None, None,
) )
.await .await
{ {
warn!(job_id = %job.id, error = %err, ordinal, "failed to upload thumbnail; retrying"); warn!(job_id = %job.id, error = %err, "failed to upload preview; retrying");
return JobExecution::Retry { return JobExecution::Retry {
delay: Duration::from_secs(30), delay: Duration::from_secs(30),
error: err.to_string(), error: err.to_string(),
}; };
} }
thumbnail_objects.push(AssetObjectPersistence { if let Err(err) = state
ordinal, .storage
s3_key, .put_object(
width: image.width, &thumbnail_s3_key,
height: image.height, generation.thumbnail.image_bytes.clone(),
}); Some("image/png".into()),
None,
)
.await
{
warn!(job_id = %job.id, error = %err, "failed to upload thumbnail; retrying");
return JobExecution::Retry {
delay: Duration::from_secs(30),
error: err.to_string(),
};
} }
let asset_persistences = vec![ let state_clone = state.clone();
match task::spawn_blocking(move || {
persist_assets_metadata(
state_clone,
&initial,
&[
AssetPersistence { AssetPersistence {
asset_type: PREVIEW_ASSET_TYPE, asset_type: PREVIEW_ASSET_TYPE,
asset_id: preview_asset_id, asset_id: preview_asset_id,
objects: preview_objects, s3_key: &preview_s3_key,
generated: &generation.preview,
}, },
AssetPersistence { AssetPersistence {
asset_type: THUMBNAIL_ASSET_TYPE, asset_type: THUMBNAIL_ASSET_TYPE,
asset_id: thumbnail_asset_id, asset_id: thumbnail_asset_id,
objects: thumbnail_objects, s3_key: &thumbnail_s3_key,
generated: &generation.thumbnail,
}, },
]; ],
)
let state_clone = state.clone();
match task::spawn_blocking(move || {
persist_assets_metadata(state_clone, &initial, &asset_persistences)
}) })
.await .await
{ {
@@ -308,9 +250,7 @@ struct ThumbnailContext {
document: Document, document: Document,
version: DocumentVersion, version: DocumentVersion,
existing_thumbnail: Option<DocumentAsset>, existing_thumbnail: Option<DocumentAsset>,
existing_thumbnail_objects: Vec<DocumentAssetObject>,
existing_preview: Option<DocumentAsset>, existing_preview: Option<DocumentAsset>,
existing_preview_objects: Vec<DocumentAssetObject>,
skip: bool, skip: bool,
} }
@@ -320,34 +260,24 @@ struct GeneratedImage {
height: Option<i32>, height: Option<i32>,
} }
struct GeneratedAsset {
objects: Vec<GeneratedImage>,
}
struct GeneratedAssets { struct GeneratedAssets {
thumbnail: GeneratedAsset, thumbnail: GeneratedImage,
preview: GeneratedAsset, preview: GeneratedImage,
page_count: Option<u32>, page_count: Option<u32>,
} }
struct AssetObjectPersistence { struct AssetPersistence<'a> {
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,
objects: Vec<AssetObjectPersistence>, s3_key: &'a str,
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_unscoped().map_err(|err| format!("{err:?}"))?; let mut conn = state.db().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)
@@ -363,42 +293,21 @@ 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:?}"))?;
let mut existing_thumbnail = None; let mut existing_thumbnail = None;
let mut existing_thumbnail_objects: Vec<DocumentAssetObject> = Vec::new();
let mut existing_preview = None; let mut existing_preview = None;
let mut existing_preview_objects: Vec<DocumentAssetObject> = Vec::new();
for asset in existing_assets { for asset in existing_assets {
match asset.asset_type.as_str() { match asset.asset_type.as_str() {
THUMBNAIL_ASSET_TYPE => { THUMBNAIL_ASSET_TYPE => existing_thumbnail = Some(asset),
existing_thumbnail_objects = document_asset_objects::table PREVIEW_ASSET_TYPE => existing_preview = Some(asset),
.filter(document_asset_objects::asset_id.eq(asset.id))
.filter(document_asset_objects::tenant_id.eq(tenant_id))
.order(document_asset_objects::ordinal.asc())
.load(&mut conn)
.map_err(|err| format!("{err:?}"))?;
existing_thumbnail = Some(asset);
}
PREVIEW_ASSET_TYPE => {
existing_preview_objects = document_asset_objects::table
.filter(document_asset_objects::asset_id.eq(asset.id))
.filter(document_asset_objects::tenant_id.eq(tenant_id))
.order(document_asset_objects::ordinal.asc())
.load(&mut conn)
.map_err(|err| format!("{err:?}"))?;
existing_preview = Some(asset);
}
_ => {} _ => {}
} }
} }
@@ -408,33 +317,13 @@ 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 expected_cardinality = expected_asset_cardinality(&document, &version); let skip = existing_thumbnail.is_some() && existing_preview.is_some() && !payload.force;
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,
version, version,
existing_thumbnail, existing_thumbnail,
existing_thumbnail_objects,
existing_preview, existing_preview,
existing_preview_objects,
skip, skip,
}) })
} }
@@ -443,7 +332,18 @@ fn generate_preview_and_thumbnail(
document: &Document, document: &Document,
bytes: &[u8], bytes: &[u8],
) -> Result<GeneratedAssets, String> { ) -> Result<GeneratedAssets, String> {
let is_pdf = document_is_pdf(document); let is_pdf = document
.content_type
.as_deref()
.map(|mime| mime == "application/pdf")
.unwrap_or_else(|| {
document
.original_name
.rsplit('.')
.next()
.map(|ext| ext.eq_ignore_ascii_case("pdf"))
.unwrap_or(false)
});
if is_pdf { if is_pdf {
let pdf_assets = generate_pdf_assets(bytes)?; let pdf_assets = generate_pdf_assets(bytes)?;
@@ -462,7 +362,7 @@ fn generate_preview_and_thumbnail(
} }
} }
fn generate_image_assets(bytes: &[u8]) -> Result<(GeneratedAsset, GeneratedAsset), String> { fn generate_image_assets(bytes: &[u8]) -> Result<(GeneratedImage, GeneratedImage), String> {
let reader = ImageReader::new(Cursor::new(bytes)) 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())?;
@@ -484,19 +384,12 @@ fn generate_image_assets(bytes: &[u8]) -> Result<(GeneratedAsset, GeneratedAsset
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(( Ok((preview, thumbnail))
GeneratedAsset {
objects: vec![preview],
},
GeneratedAsset {
objects: vec![thumbnail],
},
))
} }
struct PdfGeneratedAssets { struct PdfGeneratedAssets {
preview: GeneratedAsset, preview: GeneratedImage,
thumbnail: GeneratedAsset, thumbnail: GeneratedImage,
page_count: u32, page_count: u32,
} }
@@ -509,7 +402,11 @@ 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() as usize; let total_pages = pages.len();
let page = pages
.get(0)
.map_err(|err| format!("load first page: {err}"))?;
let render_config = PdfRenderConfig::new() let render_config = PdfRenderConfig::new()
.set_target_width(PREVIEW_WIDTH as i32) .set_target_width(PREVIEW_WIDTH as i32)
@@ -517,44 +414,30 @@ 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 mut preview_objects: Vec<GeneratedImage> = Vec::with_capacity(total_pages);
let mut thumbnail_objects: Vec<GeneratedImage> = Vec::with_capacity(total_pages);
for page_index in 0..total_pages {
let page = pages
.get(u16::try_from(page_index).map_err(|_| "page index overflow".to_string())?)
.map_err(|err| format!("load page {page_index}: {err}"))?;
let bitmap = page let bitmap = page
.render_with_config(&render_config) .render_with_config(&render_config)
.map_err(|err| format!("render pdf page {page_index}: {err}"))?; .map_err(|err| format!("render pdf page: {err}"))?;
let preview_buffer = bitmap.as_image().to_rgb8(); let preview_buffer = bitmap.as_image().to_rgb8();
let preview_image = image::DynamicImage::ImageRgb8(preview_buffer); let preview_image = image::DynamicImage::ImageRgb8(preview_buffer);
let thumbnail_image = if preview_image.width() > THUMBNAIL_WIDTH let thumbnail_image =
|| preview_image.height() > THUMBNAIL_HEIGHT 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()
}; };
preview_objects.push(encode_dynamic_image(preview_image)?); let preview = encode_dynamic_image(preview_image)?;
thumbnail_objects.push(encode_dynamic_image(thumbnail_image)?); let thumbnail = 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: GeneratedAsset { preview,
objects: preview_objects, thumbnail,
},
thumbnail: GeneratedAsset {
objects: thumbnail_objects,
},
page_count, page_count,
}) })
} }
@@ -575,51 +458,22 @@ 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 tenant_id = context.document.tenant_id; let mut conn = state.db().map_err(|err| format!("{err:?}"))?;
let mut conn = state
.db_for_tenant(tenant_id)
.map_err(|err| format!("{err:?}"))?;
if let Some(existing_preview) = &context.existing_preview {
diesel::delete(document_assets::table.filter(document_assets::id.eq(existing_preview.id)))
.execute(&mut conn)
.map_err(|err| format!("{err:?}"))?;
}
if let Some(existing_thumbnail) = &context.existing_thumbnail {
diesel::delete(
document_assets::table.filter(document_assets::id.eq(existing_thumbnail.id)),
)
.execute(&mut conn)
.map_err(|err| format!("{err:?}"))?;
}
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,
asset_type: asset.asset_type.to_string(), asset_type: asset.asset_type.to_string(),
s3_key: asset.s3_key.to_string(),
mime_type: "image/png".to_string(), mime_type: "image/png".to_string(),
metadata: json!({ metadata: json!({
"generated_at": Utc::now().to_rfc3339(), "generated_at": Utc::now().to_rfc3339(),
"width": asset.generated.width,
"height": asset.generated.height,
}), }),
cardinality: Some(object_count),
tenant_id,
}; };
diesel::insert_into(document_assets::table) diesel::insert_into(document_assets::table)
@@ -630,46 +484,12 @@ fn persist_assets_metadata(
)) ))
.do_update() .do_update()
.set(( .set((
document_assets::s3_key.eq(excluded(document_assets::s3_key)),
document_assets::mime_type.eq(excluded(document_assets::mime_type)), document_assets::mime_type.eq(excluded(document_assets::mime_type)),
document_assets::metadata.eq(excluded(document_assets::metadata)), document_assets::metadata.eq(excluded(document_assets::metadata)),
document_assets::cardinality.eq(excluded(document_assets::cardinality)),
)) ))
.execute(&mut conn) .execute(&mut conn)
.map_err(|err| format!("{err:?}"))?; .map_err(|err| format!("{err:?}"))?;
diesel::delete(
document_asset_objects::table
.filter(document_asset_objects::asset_id.eq(asset.asset_id))
.filter(document_asset_objects::tenant_id.eq(tenant_id)),
)
.execute(&mut conn)
.map_err(|err| format!("{err:?}"))?;
for object in &asset.objects {
let mut metadata_map = Map::new();
if let Some(width) = object.width {
metadata_map.insert("width".to_string(), Value::from(width));
}
if let Some(height) = object.height {
metadata_map.insert("height".to_string(), Value::from(height));
}
let object_metadata = Value::Object(metadata_map);
let new_object = NewDocumentAssetObject {
id: Uuid::new_v4(),
asset_id: asset.asset_id,
ordinal: object.ordinal,
s3_key: object.s3_key.clone(),
metadata: object_metadata,
tenant_id,
};
diesel::insert_into(document_asset_objects::table)
.values(&new_object)
.execute(&mut conn)
.map_err(|err| format!("{err:?}"))?;
}
} }
Ok(()) Ok(())
@@ -681,17 +501,11 @@ 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_unscoped().map_err(|err| format!("{err:?}"))?; let mut conn = state.db().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:?}"))?;
@@ -711,8 +525,7 @@ 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)
@@ -720,37 +533,3 @@ 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,6 +8,7 @@ use serde::Deserialize;
#[derive(Deserialize)] #[derive(Deserialize)]
struct AuthenticatedUser { struct AuthenticatedUser {
username: String, username: String,
role: String,
} }
#[tokio::test] #[tokio::test]
@@ -26,6 +27,7 @@ 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(())
+5 -69
View File
@@ -11,7 +11,7 @@ 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, NewUserMembership}; use backend::models::{Job, NewUser};
use backend::routes; use backend::routes;
use backend::state::AppState; use backend::state::AppState;
use backend::storage::ObjectStorage; use backend::storage::ObjectStorage;
@@ -22,8 +22,7 @@ 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::{Deserialize, Serialize}; use serde::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;
@@ -139,7 +138,6 @@ 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)?;
@@ -180,34 +178,18 @@ impl TestApp {
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)
.context("default tenant not found")?;
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
@@ -235,57 +217,11 @@ impl TestApp {
); );
let body = body_to_vec(response.into_body()).await?; let body = body_to_vec(response.into_body()).await?;
#[derive(Deserialize)] #[derive(serde::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)
} }
+93 -2
View File
@@ -56,12 +56,12 @@ struct DocumentDownload {
filename: String, filename: String,
} }
#[derive(Deserialize)]
#[derive(Deserialize)] #[derive(Deserialize)]
struct BulkReanalyze { struct BulkReanalyze {
queued: usize, queued: usize,
} }
#[derive(Deserialize)]
struct BulkMoveResult { struct BulkMoveResult {
updated: usize, updated: usize,
} }
@@ -318,6 +318,97 @@ 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<()> {
+14 -33
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 -d papercrate_test"] test: ["CMD-SHELL", "pg_isready -U papercrate"]
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 5 retries: 5
quickwit-test: quickwit:
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:
- "7281:7280" - "7280:7280"
volumes: volumes:
- quickwit_test_data:/quickwit/data - quickwit_test_data:/quickwit/data
healthcheck: healthcheck:
@@ -31,40 +31,21 @@ services:
timeout: 5s timeout: 5s
retries: 5 retries: 5
admin-bootstrap: quickwit-create-index:
build: image: quickwit/quickwit:0.8.2
context: ./backend
depends_on: depends_on:
postgres-test: quickwit:
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:
DATABASE_URL: postgres://papercrate:papercrate_test@postgres-test:5432/papercrate_test QW_DATA_DIR: /quickwit/data
DATABASE_MAX_POOL_SIZE: 1 QW_NODE_URI: http://quickwit:7280
S3_BUCKET: documents entrypoint: ["/bin/sh", "-c"]
JWT_SECRET: change-me-super-secret
QUICKWIT_ENDPOINT: http://quickwit-test:7280
DEFAULT_TENANT_SLUG: admin
entrypoint: []
command: > command: >
/bin/sh -c " quickwit index list --node-uri http://quickwit:7280 2>/dev/null | grep -q '"documents"' \
echo 'Running database migrations' && || quickwit index create --index-config /tmp/documents-index.yaml --node-uri http://quickwit:7280
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:
+6 -35
View File
@@ -62,44 +62,15 @@ services:
timeout: 5s timeout: 5s
retries: 5 retries: 5
admin-bootstrap: quickwit-create-index:
build: image: quickwit/quickwit:0.8.2
context: ./backend
depends_on: depends_on:
postgres:
condition: service_healthy
quickwit: quickwit:
condition: service_healthy condition: service_healthy
environment: volumes:
DATABASE_URL: postgres://papercrate:papercrate_dev@postgres:5432/papercrate - quickwit_data:/quickwit/data
DATABASE_MAX_POOL_SIZE: 2 - ./quickwit/documents-index.yaml:/tmp/documents-index.yaml:ro
AWS_ENDPOINT_URL: http://minio:9000 command: index create --endpoint=http://quickwit:7280 --index-config /tmp/documents-index.yaml
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:
+2 -1
View File
@@ -18,6 +18,7 @@ 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). - POST /api/documents - Upload a document via multipart form-data (`file`, optional metadata/folder fields).
- POST /api/documents/reanalyze - Queue re-analysis for every non-deleted 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.
@@ -36,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/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). - GET /api/documents/:id/assets/:asset_id - Fetch metadata and a pre-signed URL for a specific asset.
Downloads Downloads
--------- ---------
+1 -1
View File
@@ -44,4 +44,4 @@ npm run build
## Assets ## Assets
- The folder icon (`src/assets/folder.svg`) is derived from the Adwaita icon theme by the [GNOME Project](http://www.gnome.org/). - The folder icon (`public/folder.svg`) is derived from the Adwaita icon theme by the [GNOME Project](http://www.gnome.org/).
-737
View File
@@ -8,7 +8,6 @@
"name": "papercrate-frontend", "name": "papercrate-frontend",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@fontsource/inter": "^5.2.8",
"@tabler/icons-react": "3.11.0", "@tabler/icons-react": "3.11.0",
"axios": "1.7.7", "axios": "1.7.7",
"react": "18.3.1", "react": "18.3.1",
@@ -19,7 +18,6 @@
"@babel/core": "7.26.0", "@babel/core": "7.26.0",
"@babel/preset-env": "7.26.0", "@babel/preset-env": "7.26.0",
"@babel/preset-react": "7.26.3", "@babel/preset-react": "7.26.3",
"@svgr/webpack": "8.1.0",
"babel-loader": "9.2.1", "babel-loader": "9.2.1",
"css-loader": "7.1.2", "css-loader": "7.1.2",
"dotenv": "16.4.5", "dotenv": "16.4.5",
@@ -643,22 +641,6 @@
"@babel/core": "^7.0.0-0" "@babel/core": "^7.0.0-0"
} }
}, },
"node_modules/@babel/plugin-syntax-typescript": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz",
"integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/plugin-syntax-unicode-sets-regex": { "node_modules/@babel/plugin-syntax-unicode-sets-regex": {
"version": "7.18.6", "version": "7.18.6",
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz",
@@ -1318,22 +1300,6 @@
"@babel/core": "^7.0.0-0" "@babel/core": "^7.0.0-0"
} }
}, },
"node_modules/@babel/plugin-transform-react-constant-elements": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-constant-elements/-/plugin-transform-react-constant-elements-7.27.1.tgz",
"integrity": "sha512-edoidOjl/ZxvYo4lSBOQGDSyToYVkTAwyVoa2tkuYTSmjrB1+uAedoL5iROVLXkxH+vRgA7uP4tMg2pUJpZ3Ug==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/plugin-transform-react-display-name": { "node_modules/@babel/plugin-transform-react-display-name": {
"version": "7.28.0", "version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.28.0.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.28.0.tgz",
@@ -1533,26 +1499,6 @@
"@babel/core": "^7.0.0-0" "@babel/core": "^7.0.0-0"
} }
}, },
"node_modules/@babel/plugin-transform-typescript": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.0.tgz",
"integrity": "sha512-4AEiDEBPIZvLQaWlc9liCavE0xRM0dNca41WtBeM3jgFptfUOSG9z0uteLhq6+3rq+WB6jIvUwKDTpXEHPJ2Vg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-annotate-as-pure": "^7.27.3",
"@babel/helper-create-class-features-plugin": "^7.27.1",
"@babel/helper-plugin-utils": "^7.27.1",
"@babel/helper-skip-transparent-expression-wrappers": "^7.27.1",
"@babel/plugin-syntax-typescript": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/plugin-transform-unicode-escapes": { "node_modules/@babel/plugin-transform-unicode-escapes": {
"version": "7.27.1", "version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz",
@@ -1750,26 +1696,6 @@
"@babel/core": "^7.0.0-0" "@babel/core": "^7.0.0-0"
} }
}, },
"node_modules/@babel/preset-typescript": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.27.1.tgz",
"integrity": "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1",
"@babel/helper-validator-option": "^7.27.1",
"@babel/plugin-syntax-jsx": "^7.27.1",
"@babel/plugin-transform-modules-commonjs": "^7.27.1",
"@babel/plugin-transform-typescript": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/template": { "node_modules/@babel/template": {
"version": "7.27.2", "version": "7.27.2",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
@@ -1853,15 +1779,6 @@
"node": ">=10.0.0" "node": ">=10.0.0"
} }
}, },
"node_modules/@fontsource/inter": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.8.tgz",
"integrity": "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@jridgewell/gen-mapping": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13", "version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -2047,290 +1964,6 @@
"node": ">=14.0.0" "node": ">=14.0.0"
} }
}, },
"node_modules/@svgr/babel-plugin-add-jsx-attribute": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz",
"integrity": "sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/gregberge"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@svgr/babel-plugin-remove-jsx-attribute": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-8.0.0.tgz",
"integrity": "sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/gregberge"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@svgr/babel-plugin-remove-jsx-empty-expression": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-empty-expression/-/babel-plugin-remove-jsx-empty-expression-8.0.0.tgz",
"integrity": "sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/gregberge"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@svgr/babel-plugin-replace-jsx-attribute-value": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-8.0.0.tgz",
"integrity": "sha512-KVQ+PtIjb1BuYT3ht8M5KbzWBhdAjjUPdlMtpuw/VjT8coTrItWX6Qafl9+ji831JaJcu6PJNKCV0bp01lBNzQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/gregberge"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@svgr/babel-plugin-svg-dynamic-title": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-dynamic-title/-/babel-plugin-svg-dynamic-title-8.0.0.tgz",
"integrity": "sha512-omNiKqwjNmOQJ2v6ge4SErBbkooV2aAWwaPFs2vUY7p7GhVkzRkJ00kILXQvRhA6miHnNpXv7MRnnSjdRjK8og==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/gregberge"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@svgr/babel-plugin-svg-em-dimensions": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-em-dimensions/-/babel-plugin-svg-em-dimensions-8.0.0.tgz",
"integrity": "sha512-mURHYnu6Iw3UBTbhGwE/vsngtCIbHE43xCRK7kCw4t01xyGqb2Pd+WXekRRoFOBIY29ZoOhUCTEweDMdrjfi9g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/gregberge"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@svgr/babel-plugin-transform-react-native-svg": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-8.1.0.tgz",
"integrity": "sha512-Tx8T58CHo+7nwJ+EhUwx3LfdNSG9R2OKfaIXXs5soiy5HtgoAEkDay9LIimLOcG8dJQH1wPZp/cnAv6S9CrR1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/gregberge"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@svgr/babel-plugin-transform-svg-component": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-8.0.0.tgz",
"integrity": "sha512-DFx8xa3cZXTdb/k3kfPeaixecQLgKh5NVBMwD0AQxOzcZawK4oo1Jh9LbrcACUivsCA7TLG8eeWgrDXjTMhRmw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/gregberge"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@svgr/babel-preset": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/@svgr/babel-preset/-/babel-preset-8.1.0.tgz",
"integrity": "sha512-7EYDbHE7MxHpv4sxvnVPngw5fuR6pw79SkcrILHJ/iMpuKySNCl5W1qcwPEpU+LgyRXOaAFgH0KhwD18wwg6ug==",
"dev": true,
"license": "MIT",
"dependencies": {
"@svgr/babel-plugin-add-jsx-attribute": "8.0.0",
"@svgr/babel-plugin-remove-jsx-attribute": "8.0.0",
"@svgr/babel-plugin-remove-jsx-empty-expression": "8.0.0",
"@svgr/babel-plugin-replace-jsx-attribute-value": "8.0.0",
"@svgr/babel-plugin-svg-dynamic-title": "8.0.0",
"@svgr/babel-plugin-svg-em-dimensions": "8.0.0",
"@svgr/babel-plugin-transform-react-native-svg": "8.1.0",
"@svgr/babel-plugin-transform-svg-component": "8.0.0"
},
"engines": {
"node": ">=14"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/gregberge"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@svgr/core": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz",
"integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/core": "^7.21.3",
"@svgr/babel-preset": "8.1.0",
"camelcase": "^6.2.0",
"cosmiconfig": "^8.1.3",
"snake-case": "^3.0.4"
},
"engines": {
"node": ">=14"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/gregberge"
}
},
"node_modules/@svgr/hast-util-to-babel-ast": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-8.0.0.tgz",
"integrity": "sha512-EbDKwO9GpfWP4jN9sGdYwPBU0kdomaPIL2Eu4YwmgP+sJeXT+L7bMwJUBnhzfH8Q2qMBqZ4fJwpCyYsAN3mt2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.21.3",
"entities": "^4.4.0"
},
"engines": {
"node": ">=14"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/gregberge"
}
},
"node_modules/@svgr/hast-util-to-babel-ast/node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/@svgr/plugin-jsx": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/@svgr/plugin-jsx/-/plugin-jsx-8.1.0.tgz",
"integrity": "sha512-0xiIyBsLlr8quN+WyuxooNW9RJ0Dpr8uOnH/xrCVO8GLUcwHISwj1AG0k+LFzteTkAA0GbX0kj9q6Dk70PTiPA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/core": "^7.21.3",
"@svgr/babel-preset": "8.1.0",
"@svgr/hast-util-to-babel-ast": "8.0.0",
"svg-parser": "^2.0.4"
},
"engines": {
"node": ">=14"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/gregberge"
},
"peerDependencies": {
"@svgr/core": "*"
}
},
"node_modules/@svgr/plugin-svgo": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/@svgr/plugin-svgo/-/plugin-svgo-8.1.0.tgz",
"integrity": "sha512-Ywtl837OGO9pTLIN/onoWLmDQ4zFUycI1g76vuKGEz6evR/ZTJlJuz3G/fIkb6OVBJ2g0o6CGJzaEjfmEo3AHA==",
"dev": true,
"license": "MIT",
"dependencies": {
"cosmiconfig": "^8.1.3",
"deepmerge": "^4.3.1",
"svgo": "^3.0.2"
},
"engines": {
"node": ">=14"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/gregberge"
},
"peerDependencies": {
"@svgr/core": "*"
}
},
"node_modules/@svgr/webpack": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/@svgr/webpack/-/webpack-8.1.0.tgz",
"integrity": "sha512-LnhVjMWyMQV9ZmeEy26maJk+8HTIbd59cH4F2MJ439k9DqejRisfFNGAPvRYlKETuh9LrImlS8aKsBgKjMA8WA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/core": "^7.21.3",
"@babel/plugin-transform-react-constant-elements": "^7.21.3",
"@babel/preset-env": "^7.20.2",
"@babel/preset-react": "^7.18.6",
"@babel/preset-typescript": "^7.21.0",
"@svgr/core": "8.1.0",
"@svgr/plugin-jsx": "8.1.0",
"@svgr/plugin-svgo": "8.1.0"
},
"engines": {
"node": ">=14"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/gregberge"
}
},
"node_modules/@tabler/icons": { "node_modules/@tabler/icons": {
"version": "3.11.0", "version": "3.11.0",
"resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.11.0.tgz", "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.11.0.tgz",
@@ -2357,16 +1990,6 @@
"react": ">= 16" "react": ">= 16"
} }
}, },
"node_modules/@trysound/sax": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz",
"integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/@types/body-parser": { "node_modules/@types/body-parser": {
"version": "1.19.6", "version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
@@ -2972,13 +2595,6 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0"
},
"node_modules/array-flatten": { "node_modules/array-flatten": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@@ -3313,16 +2929,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/callsites": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
"integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/camel-case": { "node_modules/camel-case": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz", "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz",
@@ -3334,19 +2940,6 @@
"tslib": "^2.0.3" "tslib": "^2.0.3"
} }
}, },
"node_modules/camelcase": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz",
"integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001749", "version": "1.0.30001749",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001749.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001749.tgz",
@@ -3577,33 +3170,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/cosmiconfig": {
"version": "8.3.6",
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz",
"integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==",
"dev": true,
"license": "MIT",
"dependencies": {
"import-fresh": "^3.3.0",
"js-yaml": "^4.1.0",
"parse-json": "^5.2.0",
"path-type": "^4.0.0"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/d-fischer"
},
"peerDependencies": {
"typescript": ">=4.9.5"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -3672,20 +3238,6 @@
"url": "https://github.com/sponsors/fb55" "url": "https://github.com/sponsors/fb55"
} }
}, },
"node_modules/css-tree": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz",
"integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==",
"dev": true,
"license": "MIT",
"dependencies": {
"mdn-data": "2.0.30",
"source-map-js": "^1.0.1"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
"node_modules/css-what": { "node_modules/css-what": {
"version": "6.2.2", "version": "6.2.2",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
@@ -3712,42 +3264,6 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/csso": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz",
"integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"css-tree": "~2.2.0"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0",
"npm": ">=7.0.0"
}
},
"node_modules/csso/node_modules/css-tree": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz",
"integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==",
"dev": true,
"license": "MIT",
"dependencies": {
"mdn-data": "2.0.28",
"source-map-js": "^1.0.1"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0",
"npm": ">=7.0.0"
}
},
"node_modules/csso/node_modules/mdn-data": {
"version": "2.0.28",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz",
"integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==",
"dev": true,
"license": "CC0-1.0"
},
"node_modules/debug": { "node_modules/debug": {
"version": "2.6.9", "version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -3758,16 +3274,6 @@
"ms": "2.0.0" "ms": "2.0.0"
} }
}, },
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/default-browser": { "node_modules/default-browser": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.2.1.tgz", "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.2.1.tgz",
@@ -4029,16 +3535,6 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/error-ex": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
"integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-arrayish": "^0.2.1"
}
},
"node_modules/es-define-property": { "node_modules/es-define-property": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -4951,33 +4447,6 @@
"postcss": "^8.1.0" "postcss": "^8.1.0"
} }
}, },
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
"integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"parent-module": "^1.0.0",
"resolve-from": "^4.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/import-fresh/node_modules/resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
"integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/import-local": { "node_modules/import-local": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz",
@@ -5025,13 +4494,6 @@
"node": ">= 10" "node": ">= 10"
} }
}, },
"node_modules/is-arrayish": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
"integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
"dev": true,
"license": "MIT"
},
"node_modules/is-binary-path": { "node_modules/is-binary-path": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@@ -5229,19 +4691,6 @@
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/js-yaml": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/jsesc": { "node_modules/jsesc": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@@ -5303,13 +4752,6 @@
"shell-quote": "^1.8.3" "shell-quote": "^1.8.3"
} }
}, },
"node_modules/lines-and-columns": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"dev": true,
"license": "MIT"
},
"node_modules/loader-runner": { "node_modules/loader-runner": {
"version": "4.3.1", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz",
@@ -5392,13 +4834,6 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/mdn-data": {
"version": "2.0.30",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz",
"integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==",
"dev": true,
"license": "CC0-1.0"
},
"node_modules/media-typer": { "node_modules/media-typer": {
"version": "0.3.0", "version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
@@ -5748,38 +5183,6 @@
"tslib": "^2.0.3" "tslib": "^2.0.3"
} }
}, },
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
"integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
"dev": true,
"license": "MIT",
"dependencies": {
"callsites": "^3.0.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/parse-json": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
"integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.0.0",
"error-ex": "^1.3.1",
"json-parse-even-better-errors": "^2.3.0",
"lines-and-columns": "^1.1.6"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/parseurl": { "node_modules/parseurl": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -5835,16 +5238,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/path-type": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
"integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -6724,17 +6117,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/snake-case": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz",
"integrity": "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==",
"dev": true,
"license": "MIT",
"dependencies": {
"dot-case": "^3.0.4",
"tslib": "^2.0.3"
}
},
"node_modules/sockjs": { "node_modules/sockjs": {
"version": "0.3.24", "version": "0.3.24",
"resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz",
@@ -6939,125 +6321,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/svg-parser": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.4.tgz",
"integrity": "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==",
"dev": true,
"license": "MIT"
},
"node_modules/svgo": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.2.tgz",
"integrity": "sha512-OoohrmuUlBs8B8o6MB2Aevn+pRIH9zDALSR+6hhqVfa6fRwG/Qw9VUMSMW9VNg2CFc/MTIfabtdOVl9ODIJjpw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@trysound/sax": "0.2.0",
"commander": "^7.2.0",
"css-select": "^5.1.0",
"css-tree": "^2.3.1",
"css-what": "^6.1.0",
"csso": "^5.0.5",
"picocolors": "^1.0.0"
},
"bin": {
"svgo": "bin/svgo"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/svgo"
}
},
"node_modules/svgo/node_modules/commander": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 10"
}
},
"node_modules/svgo/node_modules/css-select": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0",
"css-what": "^6.1.0",
"domhandler": "^5.0.2",
"domutils": "^3.0.1",
"nth-check": "^2.0.1"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/svgo/node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"dev": true,
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/svgo/node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/svgo/node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/svgo/node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/tapable": { "node_modules/tapable": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
-2
View File
@@ -9,7 +9,6 @@
"lint": "echo \"No linting configured\"" "lint": "echo \"No linting configured\""
}, },
"dependencies": { "dependencies": {
"@fontsource/inter": "^5.2.8",
"@tabler/icons-react": "3.11.0", "@tabler/icons-react": "3.11.0",
"axios": "1.7.7", "axios": "1.7.7",
"react": "18.3.1", "react": "18.3.1",
@@ -20,7 +19,6 @@
"@babel/core": "7.26.0", "@babel/core": "7.26.0",
"@babel/preset-env": "7.26.0", "@babel/preset-env": "7.26.0",
"@babel/preset-react": "7.26.3", "@babel/preset-react": "7.26.3",
"@svgr/webpack": "8.1.0",
"babel-loader": "9.2.1", "babel-loader": "9.2.1",
"css-loader": "7.1.2", "css-loader": "7.1.2",
"dotenv": "16.4.5", "dotenv": "16.4.5",
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<svg viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> <svg height="128px" viewBox="0 0 128 128" width="128px" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<linearGradient id="a" gradientTransform="matrix(0.45451 0 0 0.455522 -1210.292114 616.172607)" gradientUnits="userSpaceOnUse" x1="2689.251953" x2="2918.069824" y1="-1106.802979" y2="-1106.802979"> <linearGradient id="a" gradientTransform="matrix(0.45451 0 0 0.455522 -1210.292114 616.172607)" gradientUnits="userSpaceOnUse" x1="2689.251953" x2="2918.069824" y1="-1106.802979" y2="-1106.802979">
<stop offset="0" stop-color="#62a0ea"/> <stop offset="0" stop-color="#62a0ea"/>
<stop offset="0.0576991" stop-color="#afd4ff"/> <stop offset="0.0576991" stop-color="#afd4ff"/>

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

-12
View File
@@ -1,12 +0,0 @@
import React from 'react';
export const AppShellContext = React.createContext(null);
export const useAppShell = () => {
const context = React.useContext(AppShellContext);
if (!context) {
throw new Error('AppShellContext not found. Ensure routes are nested under AppLayout.');
}
return context;
};
+21 -214
View File
@@ -17,116 +17,7 @@ export const getAssetFromVersion = (currentVersion, assetType) => {
return getAssetFromGroup(currentVersion.assets, assetType); return getAssetFromGroup(currentVersion.assets, assetType);
}; };
const normalizeAssetObjects = (objects) => { export const resolveDocumentAssetUrl = (doc, type, { ensureAssetUrl, getAsset, ensureOptions } = {}) => {
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;
} }
@@ -134,32 +25,15 @@ export const resolveDocumentAssetUrl = (
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();
if (url && (!expiresAt || expiresAt > now)) { const expiresAt = typeof asset.expiresAt === 'number' ? asset.expiresAt : null;
return url; const hasFreshUrl = asset.url && (!expiresAt || expiresAt > now);
if (hasFreshUrl) {
return asset.url;
} }
if (doc.id && asset.id && typeof ensureAssetUrl === 'function') { if (doc.id && asset.id && typeof ensureAssetUrl === 'function') {
const force = Boolean(url && expiresAt && expiresAt <= now); const force = Boolean(asset.url && expiresAt && expiresAt <= now);
const options = { const options = ensureOptions ? { ...ensureOptions, force } : { force };
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;
@@ -189,10 +63,6 @@ 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 };
@@ -206,10 +76,6 @@ 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;
} }
@@ -306,53 +172,24 @@ class AssetManager {
return next; return next;
} }
ensureAsset(documentId, asset, { force = false, start = null, limit = null } = {}) { ensureAsset(documentId, asset, { force = false } = {}) {
if (!documentId || !asset?.id) { if (!documentId || !asset?.id) {
return Promise.resolve(asset || null); return Promise.resolve(asset || null);
} }
const requestedStart = Number.isInteger(start) && start > 0 ? start : 1; const assetExpiresAt = typeof asset.expiresAt === 'number' ? asset.expiresAt : null;
const requestedLimit = Number.isInteger(limit) && limit > 0 ? limit : 1; if (!force && asset?.url && (!assetExpiresAt || assetExpiresAt > Date.now())) {
const requestedEnd = requestedStart + requestedLimit - 1; this.rememberAsset(asset);
return Promise.resolve(asset);
}
const baseAsset = this.assetCache.get(asset.id) || asset; const cached = this.assetCache.get(asset.id);
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) {
const isOrdinalSatisfied = (ordinal) => { return Promise.resolve({ ...asset, ...cached });
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;
}
}
} }
if (!needsFetch) { const inflightKey = `${documentId}:${asset.id}`;
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);
} }
@@ -361,44 +198,14 @@ 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(`/assets/${asset.id}`, requestConfig) .get(`/documents/${documentId}/assets/${asset.id}`)
.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 = {
...combined, ...asset,
cardinality, ...data,
url: view.getPrimaryUrl(), expiresAt: Date.now() + this.assetPresignTtlMs,
expiresAt,
}; };
this.rememberAsset(entry); this.rememberAsset(entry);
return entry; return entry;
}) })
-19
View File
@@ -1,19 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 128 128" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<defs>
<filter id="cornerShadow" x="-20%" y="-20%" width="200%" height="200%">
<feDropShadow dx="-4" dy="4" stdDeviation="8" flood-opacity="0.24"/>
</filter>
<linearGradient id="cornerGradient" gradientUnits="userSpaceOnUse" x1="32" y1="0" x2="0" y2="64">
<stop offset="0%" stop-color="#ffffff"/>
<stop offset="100%" stop-color="#f3f3f3"/>
</linearGradient>
</defs>
<g filter="url(#cornerShadow)">
<g transform="matrix(-1.81626,-1.81626,0.460023,-0.460023,180.663,203.387)">
<path d="M70.488,10.11L89.954,86.966L51.022,86.966L70.488,10.11Z" fill="url(#cornerGradient)"/>
</g>
<g transform="matrix(1.81626,1.81626,-0.460023,0.460023,4.6259,-132.676)">
<path d="M70.488,10.11L89.954,86.966L51.022,86.966L70.488,10.11Z" fill="#fdfdfd"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

@@ -118,7 +118,7 @@ function CorrespondentsPanel({
}, []); }, []);
return ( return (
<section className="correspondents-panel"> <section className="correspondents-panel column">
<div className="column-header"> <div className="column-header">
<div className="column-header__titles"> <div className="column-header__titles">
<h2>Correspondents</h2> <h2>Correspondents</h2>
+81 -310
View File
@@ -1,34 +1,15 @@
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 { import { DownloadIcon, EditIcon } from '../ui/icons';
DownloadIcon,
EditIcon,
ArrowLeftIcon,
ArrowRightIcon,
ChevronsRightIcon,
AnalyzeIcon,
WindowMaximizeIcon,
TextScanIcon,
} 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, createAssetView } from '../asset_manager'; import { resolveDocumentAssetUrl } 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';
@@ -306,20 +287,10 @@ const DetailPanel = ({
onCorrespondentAdd, onCorrespondentAdd,
onCorrespondentRemove, onCorrespondentRemove,
resolveApiPath, resolveApiPath,
onClose = () => {},
}) => { }) => {
const selectedCount = selectedDocuments.length; const selectedCount = selectedDocuments.length;
const singleDoc = selectedCount === 1 ? selectedDocuments[0] : null; const singleDoc = selectedCount === 1 ? selectedDocuments[0] : null;
const singleDownloadHref = useMemo(() => {
if (!singleDoc) return null;
const downloadPath = singleDoc.current_version?.download_path;
if (!downloadPath || !resolveApiPath) {
return null;
}
return resolveApiPath(downloadPath);
}, [singleDoc, resolveApiPath]);
const [titleEditDocId, setTitleEditDocId] = useState(null); const [titleEditDocId, setTitleEditDocId] = useState(null);
const [titleDraft, setTitleDraft] = useState(''); const [titleDraft, setTitleDraft] = useState('');
const [titleSaving, setTitleSaving] = useState(false); const [titleSaving, setTitleSaving] = useState(false);
@@ -464,34 +435,20 @@ 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, ordinal = 1) => { (doc) => {
if (!doc) return null; if (!doc) return null;
const asset = getDocumentAsset(doc, 'preview'); const url = resolveDocumentAssetUrl(doc, 'preview', {
const assetView = createAssetView(asset);
const object = assetView.getObject(ordinal);
let url = object?.url || null;
if (!url) {
url = resolveDocumentAssetUrl(doc, 'preview', {
ensureAssetUrl, ensureAssetUrl,
getAsset: getDocumentAsset, getAsset: getDocumentAsset,
ensureOptions: { start: ordinal, limit: 1 },
objectOrdinal: ordinal,
}); });
}
if (!url) { if (!url) {
return null; return null;
} }
const metadata = object?.metadata || assetView.getPrimaryMetadata() || {}; const asset = getDocumentAsset(doc, 'preview');
const orientation = derivePreviewOrientation(metadata); const width = Number(asset?.metadata?.width) || 0;
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,
@@ -518,74 +475,32 @@ 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 url = singlePreviewNavigator.currentUrl; const item = makePreviewItem(singleDoc);
if (!url) { return item ? [item] : [];
return []; }, [singleDoc, makePreviewItem]);
}
const orientation = derivePreviewOrientation(singlePreviewNavigator.currentMetadata);
return [
{
id: singleDoc.id,
url,
orientation,
alt: singleDoc.title || singleDoc.original_name || 'Document preview',
},
];
}, [singleDoc, singlePreviewNavigator.currentUrl, singlePreviewNavigator.currentMetadata]);
const stackPreviews = useMemo(() => { const stackPreviews = useMemo(
if (!stackDocuments.length) { () =>
return []; stackDocuments
} .map((doc) => makePreviewItem(doc))
return stackDocuments .filter(Boolean),
.map((doc) => { [stackDocuments, makePreviewItem],
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; useEffect(() => {
const singleEffectiveCardinality = singleCardinality || (singlePreviewNavigator.currentUrl ? 1 : 0); if (!ensureAssetUrl) {
const singleHasPreview = Boolean(singlePreviewNavigator.currentUrl); return;
}
const topDocId = stackTopDocument?.id || null; stackDocuments.forEach((doc) => {
const topCardinality = stackPreviewNavigator.cardinality; resolveDocumentAssetUrl(doc, 'preview', {
const topEffectiveCardinality = topCardinality || (stackPreviewNavigator.currentUrl ? 1 : 0); ensureAssetUrl,
const topHasPreview = Boolean(stackPreviewNavigator.currentUrl); getAsset: getDocumentAsset,
const topOrdinal = stackPreviewNavigator.ordinal; });
const topCanGoPrev = stackPreviewNavigator.canGoPrev; });
const topCanGoNext = stackPreviewNavigator.canGoNext; }, [stackDocuments, ensureAssetUrl, getDocumentAsset]);
const bulkTagUnion = useMemo(() => { const bulkTagUnion = useMemo(() => {
if (!selectedDocuments.length) return []; if (!selectedDocuments.length) return [];
@@ -724,6 +639,9 @@ const DetailPanel = ({
} }
const displayName = singleDoc.title || singleDoc.original_name; const displayName = singleDoc.title || singleDoc.original_name;
const downloadHref = singleDoc.current_version?.download_path
? resolveApiPath?.(singleDoc.current_version.download_path)
: null;
const isEditingTitle = titleEditDocId === singleDoc.id; const isEditingTitle = titleEditDocId === singleDoc.id;
const sizeBytes = Number(singleDoc.current_version?.size_bytes) || 0; const sizeBytes = Number(singleDoc.current_version?.size_bytes) || 0;
const sizeLabel = sizeBytes > 0 ? formatFileSize(sizeBytes) : '—'; const sizeLabel = sizeBytes > 0 ? formatFileSize(sizeBytes) : '—';
@@ -731,27 +649,11 @@ const DetailPanel = ({
? new Date(singleDoc.issued_at).toLocaleString() ? new Date(singleDoc.issued_at).toLocaleString()
: '—'; : '—';
const tagsForDoc = Array.isArray(singleDoc.tags) ? singleDoc.tags : []; const tagsForDoc = Array.isArray(singleDoc.tags) ? singleDoc.tags : [];
const pageCountRaw = singleDoc.current_version?.metadata?.page_count;
const pageCountValue =
typeof pageCountRaw === 'number'
? pageCountRaw
: pageCountRaw != null && pageCountRaw !== ''
? Number.parseInt(pageCountRaw, 10)
: null;
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}
@@ -761,44 +663,7 @@ const DetailPanel = ({
onOpenPreview={onOpenPreview} onOpenPreview={onOpenPreview}
activeItemId={activePreviewId} activeItemId={activePreviewId}
/> />
{hasPreviewImage && (effectiveCardinality > 1 || canGoPrev || canGoNext) ? (
<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> </div>
) : null}
</div> </div>
<div className="doc-title-row"> <div className="doc-title-row">
{isEditingTitle ? ( {isEditingTitle ? (
@@ -863,16 +728,49 @@ const DetailPanel = ({
<div> <div>
<strong>Issued:</strong> {issuedAt} <strong>Issued:</strong> {issuedAt}
</div> </div>
{hasPageCount ? (
<div>
<strong>Pages:</strong> {pageCountValue}
</div>
) : null}
<div> <div>
<strong>Original filename:</strong>{' '} <strong>Original filename:</strong>{' '}
{singleDoc.original_name} {singleDoc.original_name}
</div> </div>
</div> </div>
<div className="detail-actions">
<a
className="button-link with-icon"
href={downloadHref || '#'}
target="_blank"
rel="noopener noreferrer"
aria-disabled={!downloadHref}
onClick={(event) => {
if (!downloadHref) {
event.preventDefault();
}
}}
>
<DownloadIcon className="icon-inline" />
<span>Download</span>
</a>
<button
type="button"
className="secondary"
onClick={() => onOpenPreview(singleDoc.id)}
>
Open preview
</button>
<button
type="button"
className="secondary"
onClick={() => onRegenerateThumbnails(singleDoc.id)}
>
Re-run analysis
</button>
</div>
{hasOcrAsset ? (
<div className="detail-ocr-trigger">
<button type="button" className="secondary" onClick={openOcrModal}>
View OCR text
</button>
</div>
) : null}
<TagSection <TagSection
title="Tags" title="Tags"
tags={tagsForDoc.map((tag) => ({ tags={tagsForDoc.map((tag) => ({
@@ -960,20 +858,10 @@ 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}
@@ -982,44 +870,7 @@ const DetailPanel = ({
onOpenPreview={onOpenPreview} onOpenPreview={onOpenPreview}
activeItemId={activePreviewId} activeItemId={activePreviewId}
/> />
{topDocIdLocal && topHasPreview && (topCardinalityLocal > 1 || topCanGoPrev || topCanGoNext) ? (
<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> </div>
) : null}
</div> </div>
<h3 style={{ margin: 0 }}>{countLabel}</h3> <h3 style={{ margin: 0 }}>{countLabel}</h3>
<div className="meta"> <div className="meta">
@@ -1052,100 +903,20 @@ const DetailPanel = ({
showCount showCount
className="bulk-correspondents" className="bulk-correspondents"
/> />
<button
type="button"
className="secondary"
onClick={() => onBulkReanalyze?.()}
>
Re-analyze selection
</button>
</> </>
); );
}; };
const isBulkSelection = selectedCount > 1;
const showOcrAction = Boolean(singleDoc && hasOcrAsset);
return ( return (
<aside className="detail-panel panel"> <aside className="detail-panel column">
<div className="panel-header"> <div className="column-body scrollable">
<div className="panel-actions">
<button
type="button"
className="icon-button ghost"
onClick={onClose}
aria-label="Close detail panel"
title="Close detail panel"
>
<ChevronsRightIcon />
</button>
<div className="spacer" />
{isBulkSelection && onBulkReanalyze ? (
<button
type="button"
className="icon-button ghost"
onClick={(event) => {
event.stopPropagation();
onBulkReanalyze();
}}
aria-label="Re-run analysis for selection"
title="Re-run analysis for selection"
>
<AnalyzeIcon />
</button>
) : null}
{singleDoc && singleDownloadHref ? (
<a
className="icon-button"
href={singleDownloadHref}
target="_blank"
rel="noopener noreferrer"
aria-label="Download document"
title="Download document"
onClick={(event) => event.stopPropagation()}
>
<DownloadIcon />
</a>
) : null}
{singleDoc ? (
<button
type="button"
className="icon-button ghost"
onClick={(event) => {
event.stopPropagation();
onOpenPreview(singleDoc.id);
}}
aria-label="Open preview"
title="Open preview"
disabled={!singleHasPreview}
>
<WindowMaximizeIcon />
</button>
) : null}
{showOcrAction ? (
<button
type="button"
className="icon-button ghost"
onClick={(event) => {
event.stopPropagation();
openOcrModal();
}}
aria-label="View OCR text"
title="View OCR text"
>
<TextScanIcon />
</button>
) : null}
{singleDoc ? (
<button
type="button"
className="icon-button ghost"
onClick={(event) => {
event.stopPropagation();
onRegenerateThumbnails(singleDoc.id);
}}
aria-label="Re-run analysis"
title="Re-run analysis"
>
<AnalyzeIcon />
</button>
) : null}
</div>
</div>
<div className="panel-body">
{selectedCount <= 1 ? renderSingle() : renderBulk()} {selectedCount <= 1 ? renderSingle() : renderBulk()}
</div> </div>
</aside> </aside>
+99 -137
View File
@@ -1,50 +1,13 @@
import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { getAssetFromVersion, resolveDocumentAssetUrl, createAssetView } from '../asset_manager'; import { resolveDocumentAssetUrl } from '../asset_manager';
import { getTagColorStyle } from '../utils/colors'; import { getTagColorStyle } from '../utils/colors';
import { DownloadIcon, EditIcon, ViewListIcon, ViewGridIcon, FolderIcon, TrashIcon } from '../ui/icons'; import { DownloadIcon, EditIcon, ViewListIcon, ViewGridIcon } from '../ui/icons';
const FOLDER_ICON_SRC = '/folder.svg';
const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag']; const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag'];
const DEFAULT_GRID_ICON_SIZE = 96;
const DEFAULT_GRID_TITLE_SIZE = '11px';
const LIST_ICON_SIZE = 48;
const getPageCount = (doc) => const DocumentThumbnailImage = ({ document, ensureAssetUrl, getDocumentAsset, alt }) => {
Number.isFinite(doc?.current_version?.metadata?.page_count)
? doc.current_version.metadata.page_count
: null;
const DocumentThumbnailImage = ({
document,
ensureAssetUrl,
getDocumentAsset,
alt,
maxSize = LIST_ICON_SIZE,
}) => {
const resolvedMaxSize = Math.max(1, Math.round(maxSize || 1));
const thumbnailAsset = useMemo(
() => getAssetFromVersion(document?.current_version, 'thumbnail'),
[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(() => {
if (!Number.isFinite(assetWidth) || assetWidth <= 0 || !Number.isFinite(assetHeight) || assetHeight <= 0) {
return { width: resolvedMaxSize, height: resolvedMaxSize };
}
const scale = Math.min(1, resolvedMaxSize / assetWidth, resolvedMaxSize / assetHeight);
return {
width: Math.max(1, Math.round(assetWidth * scale)),
height: Math.max(1, Math.round(assetHeight * scale)),
};
}, [assetWidth, assetHeight, resolvedMaxSize]);
const innerStyle = useMemo(
() => ({ width: `${dimensions.width}px`, height: `${dimensions.height}px` }),
[dimensions.height, dimensions.width],
);
const url = useMemo( const url = useMemo(
() => () =>
resolveDocumentAssetUrl(document, 'thumbnail', { resolveDocumentAssetUrl(document, 'thumbnail', {
@@ -54,16 +17,8 @@ const DocumentThumbnailImage = ({
[document, ensureAssetUrl, getDocumentAsset], [document, ensureAssetUrl, getDocumentAsset],
); );
const pageCount = getPageCount(document);
const showMultiPageBadge = Number.isFinite(pageCount) && pageCount > 1;
const innerClasses = ['document-thumbnail-inner'];
if (showMultiPageBadge) {
innerClasses.push('document-thumbnail-inner--multipage');
}
return ( return (
<div className="document-thumbnail-wrapper"> <div className="document-thumbnail-wrapper">
<div className={innerClasses.join(' ')} style={innerStyle}>
{url ? ( {url ? (
<img <img
src={url} src={url}
@@ -76,7 +31,6 @@ const DocumentThumbnailImage = ({
<div className="thumb-placeholder">DOC</div> <div className="thumb-placeholder">DOC</div>
)} )}
</div> </div>
</div>
); );
}; };
@@ -143,7 +97,6 @@ const DocumentsTable = ({
); );
const scrollRef = useRef(null); const scrollRef = useRef(null);
const isGridView = viewMode === 'grid'; const isGridView = viewMode === 'grid';
const gridIconSize = DEFAULT_GRID_ICON_SIZE;
const handleSetViewMode = useCallback( const handleSetViewMode = useCallback(
(nextMode) => { (nextMode) => {
if (!onViewModeChange) { if (!onViewModeChange) {
@@ -263,12 +216,15 @@ const DocumentsTable = ({
[isTagDragEvent, onDocumentTagDrop], [isTagDragEvent, onDocumentTagDrop],
); );
const showDefaultEmptyState = !showingSearchResults && !subfolders.length && rows.length === 0; const handleGridBackgroundClick = useCallback(
const showListSearchEmptyState = (event) => {
showingSearchResults && rows.length === 0 && !isGridView && !isSearchLoading; if (event.target !== event.currentTarget) {
const showGridSearchEmptyState = return;
isGridView && showingSearchResults && rows.length === 0 && !isSearchLoading; }
const showSearchHint = showingSearchResults && rows.length > 0; onClearSelection?.();
},
[onClearSelection],
);
return ( return (
<section <section
@@ -276,7 +232,29 @@ const DocumentsTable = ({
> >
<div className="column-header"> <div className="column-header">
<div className="column-header__titles"> <div className="column-header__titles">
<h2>{currentFolderName}</h2> <nav className="breadcrumb" aria-label="Folder breadcrumbs">
{breadcrumbs.map((crumb, index) => {
const isLast = index === breadcrumbs.length - 1;
return (
<span key={crumb.id} className="breadcrumb-item">
{isLast ? (
<span className="breadcrumb-current">{crumb.name}</span>
) : (
<a
href="#"
onClick={(event) => {
event.preventDefault();
onFolderSelect(crumb.id);
}}
>
{crumb.name}
</a>
)}
{!isLast && <span className="breadcrumb-separator"></span>}
</span>
);
})}
</nav>
{showingSearchResults && ( {showingSearchResults && (
<div className="column-subtitle">Search results</div> <div className="column-subtitle">Search results</div>
)} )}
@@ -317,20 +295,11 @@ const DocumentsTable = ({
</button> </button>
</div> </div>
</div> </div>
{showDefaultEmptyState && (
<div className="empty-state">
Drop files anywhere or onto a folder to upload documents.
</div>
)}
{showGridSearchEmptyState && (
<div className="empty-state empty-state--global">
No documents match the current filters.
</div>
)}
<div className="column-body"> <div className="column-body">
<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?.();
@@ -344,23 +313,17 @@ const DocumentsTable = ({
onDocumentListKeyDown(event); onDocumentListKeyDown(event);
} }
}} }}
onClick={(event) => {
if (event.target === event.currentTarget) {
onClearSelection?.();
}
}}
aria-activedescendant={isGridView ? undefined : activeDescendantId} aria-activedescendant={isGridView ? undefined : activeDescendantId}
> >
{showDefaultEmptyState ? null : isGridView ? ( {!showingSearchResults && !subfolders.length && rows.length === 0 ? (
<div className="empty-state">
Drop files anywhere or onto a folder to upload documents.
</div>
) : isGridView ? (
<div <div
className="documents-grid" className="documents-grid"
role="list" role="list"
onClick={(event) => { onClick={handleGridBackgroundClick}
if (event.target === event.currentTarget) {
onClearSelection?.();
}
}}
style={{ '--documents-grid-icon-size': `${gridIconSize}px` }}
> >
{!showingSearchResults && {!showingSearchResults &&
subfolders.map((folder) => { subfolders.map((folder) => {
@@ -407,9 +370,10 @@ const DocumentsTable = ({
}} }}
> >
<div className="folder-card__icon"> <div className="folder-card__icon">
<FolderIcon <img
src={FOLDER_ICON_SRC}
alt="Folder"
className="folder-card__icon-svg" className="folder-card__icon-svg"
size={gridIconSize}
/> />
</div> </div>
<div className="folder-card__meta"> <div className="folder-card__meta">
@@ -435,7 +399,6 @@ const DocumentsTable = ({
className={cardClasses.join(' ')} className={cardClasses.join(' ')}
role="listitem" role="listitem"
id={`document-card-${doc.id}`} id={`document-card-${doc.id}`}
data-doc-id={doc.id}
onClick={(event) => onDocumentRowClick(doc.id, event)} onClick={(event) => onDocumentRowClick(doc.id, event)}
onDoubleClick={() => onDocumentOpen(doc.id)} onDoubleClick={() => onDocumentOpen(doc.id)}
draggable draggable
@@ -453,7 +416,6 @@ const DocumentsTable = ({
ensureAssetUrl={ensureAssetUrl} ensureAssetUrl={ensureAssetUrl}
getDocumentAsset={getDocumentAsset} getDocumentAsset={getDocumentAsset}
alt={`Thumbnail for ${doc.title || doc.original_name}`} alt={`Thumbnail for ${doc.title || doc.original_name}`}
maxSize={gridIconSize}
/> />
<div className="document-card__meta"> <div className="document-card__meta">
<div <div
@@ -480,6 +442,7 @@ const DocumentsTable = ({
} }
}} }}
role="button" role="button"
tabIndex={0}
draggable draggable
onDragStart={(event) => { onDragStart={(event) => {
event.stopPropagation(); event.stopPropagation();
@@ -587,29 +550,23 @@ const DocumentsTable = ({
> >
<td className="thumb-cell"> <td className="thumb-cell">
<div className="thumb-icon"> <div className="thumb-icon">
<FolderIcon <img
src={FOLDER_ICON_SRC}
alt="Folder"
className="thumb-icon__image" className="thumb-icon__image"
size={32}
/> />
</div> </div>
</td> </td>
<td className="doc-list__name"> <td className="doc-list__name">
<div className="doc-list__name-content"> <div className="doc-list__name-content">
<span>{folder.name}</span> <span>{folder.name}</span>
</div> {folder.id !== 'root' && (
</td>
<td>Folder</td>
<td></td>
<td className="actions">
<div className="action-buttons">
{folder.id !== 'root' && onFolderRename && (
<button <button
type="button" type="button"
className="icon-button ghost" className="icon-button ghost doc-list__icon-button"
title="Rename"
aria-label={`Rename folder ${folder.name}`}
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
if (!onFolderRename) return;
const nextName = window.prompt('Rename folder', folder.name || ''); const nextName = window.prompt('Rename folder', folder.name || '');
if (!nextName) { if (!nextName) {
return; return;
@@ -620,23 +577,27 @@ const DocumentsTable = ({
} }
onFolderRename(folder.id, trimmed); onFolderRename(folder.id, trimmed);
}} }}
title="Rename folder"
aria-label={`Rename folder ${folder.name}`}
> >
<EditIcon className="icon-inline" /> <EditIcon className="doc-list__icon" size={16} />
</button> </button>
)} )}
</div>
</td>
<td>Folder</td>
<td></td>
<td className="actions">
<button <button
type="button" type="button"
className="icon-button danger" className="icon-button danger"
title="Delete"
aria-label={`Delete folder ${folder.name}`}
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
onFolderDelete(folder.id); onFolderDelete(folder.id);
}} }}
> >
<TrashIcon className="icon-inline" /> Delete
</button> </button>
</div>
</td> </td>
</tr> </tr>
); );
@@ -654,7 +615,6 @@ const DocumentsTable = ({
key={doc.id} key={doc.id}
className={rowClasses.join(' ')} className={rowClasses.join(' ')}
id={`document-row-${doc.id}`} id={`document-row-${doc.id}`}
data-doc-id={doc.id}
onClick={(event) => onDocumentRowClick(doc.id, event)} onClick={(event) => onDocumentRowClick(doc.id, event)}
onDoubleClick={() => onDocumentOpen(doc.id)} onDoubleClick={() => onDocumentOpen(doc.id)}
draggable draggable
@@ -676,6 +636,30 @@ const DocumentsTable = ({
<div className="doc-name"> <div className="doc-name">
<div className="doc-list__name-content"> <div className="doc-list__name-content">
<span className="doc-name__title">{doc.title || doc.original_name}</span> <span className="doc-name__title">{doc.title || doc.original_name}</span>
<button
type="button"
className="icon-button ghost doc-list__icon-button"
onClick={(event) => {
event.stopPropagation();
if (!onDocumentRename) return;
const nextName = window.prompt(
'Rename document',
doc.title || doc.original_name || '',
);
if (!nextName) {
return;
}
const trimmed = nextName.trim();
if (!trimmed || trimmed === (doc.title || doc.original_name)) {
return;
}
onDocumentRename(doc.id, trimmed);
}}
title="Rename document"
aria-label={`Rename document ${doc.title || doc.original_name}`}
>
<EditIcon className="doc-list__icon" size={16} />
</button>
</div> </div>
{(doc.tags || []).length > 0 && ( {(doc.tags || []).length > 0 && (
<div className="doc-name__tags"> <div className="doc-name__tags">
@@ -695,6 +679,7 @@ const DocumentsTable = ({
} }
}} }}
role="button" role="button"
tabIndex={0}
draggable draggable
onDragStart={(event) => { onDragStart={(event) => {
event.stopPropagation(); event.stopPropagation();
@@ -744,59 +729,31 @@ const DocumentsTable = ({
</td> </td>
<td className="actions"> <td className="actions">
<div className="action-buttons"> <div className="action-buttons">
{onDocumentRename && (
<button
type="button"
className="icon-button ghost"
title="Rename"
aria-label={`Rename document ${doc.title || doc.original_name}`}
onClick={(event) => {
event.stopPropagation();
const nextName = window.prompt(
'Rename document',
doc.title || doc.original_name || '',
);
if (!nextName) {
return;
}
const trimmed = nextName.trim();
if (!trimmed || trimmed === (doc.title || doc.original_name)) {
return;
}
onDocumentRename(doc.id, trimmed);
}}
>
<EditIcon className="icon-inline" />
</button>
)}
{downloadHref ? ( {downloadHref ? (
<a <a
className="icon-button" className="button-link with-icon"
href={downloadHref} href={downloadHref}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
title="Download"
aria-label="Download document"
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
onAuxClick={(event) => event.stopPropagation()} onAuxClick={(event) => event.stopPropagation()}
onContextMenu={(event) => event.stopPropagation()} onContextMenu={(event) => event.stopPropagation()}
> >
<DownloadIcon className="icon-inline" /> <DownloadIcon className="icon-inline" />
<span>Download</span>
</a> </a>
) : ( ) : (
<span className="meta">No download</span> <span className="meta">No download</span>
)} )}
<button <button
type="button" type="button"
className="icon-button danger" className="danger"
title="Delete"
aria-label="Delete document"
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
onDocumentDelete?.(doc.id); onDocumentDelete?.(doc.id);
}} }}
> >
<TrashIcon className="icon-inline" /> Delete
</button> </button>
</div> </div>
</td> </td>
@@ -807,15 +764,20 @@ const DocumentsTable = ({
</table> </table>
)} )}
</div> </div>
{showListSearchEmptyState && ( {rows.length === 0 && showingSearchResults && !isGridView && !isSearchLoading && (
<div className="empty-state">No documents match the current filters.</div> <div className="empty-state">No documents match the current filters.</div>
)} )}
{showSearchHint && ( {showingSearchResults && rows.length > 0 && (
<div className="search-hint"> <div className="search-hint">
Showing {rows.length} document{rows.length === 1 ? '' : 's'} in {currentFolderName} and subfolders. Showing {rows.length} document{rows.length === 1 ? '' : 's'} in {currentFolderName} and subfolders.
</div> </div>
)} )}
</div> </div>
{isGridView && showingSearchResults && rows.length === 0 && !isSearchLoading && (
<div className="empty-state empty-state--global">
No documents match the current filters.
</div>
)}
</section> </section>
); );
}; };
-133
View File
@@ -1,133 +0,0 @@
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;
+461 -476
View File
File diff suppressed because it is too large Load Diff
-92
View File
@@ -1,92 +0,0 @@
import React from 'react';
import { DownloadIcon } from '../ui/icons';
import { formatFileSize } from '../utils/format';
const PreviewWorkspace = ({
document,
previewEntry,
resolveApiPath,
onClose,
onRegenerateThumbnails,
}) => {
if (!document) {
return null;
}
const title = document.title || document.original_name || 'Document';
const mime = previewEntry?.contentType || document.content_type || 'application/pdf';
const downloadHref = document.current_version?.download_path
? resolveApiPath(document.current_version.download_path)
: null;
const sizeBytes = Number(document.current_version?.size_bytes) || 0;
const sizeLabel = sizeBytes > 0 ? formatFileSize(sizeBytes) : null;
const metadata =
document.metadata && Object.keys(document.metadata).length > 0 ? document.metadata : null;
return (
<section className="preview-workspace">
<header className="preview-workspace__header">
<div className="preview-workspace__meta">
<button
type="button"
className="secondary"
onClick={() => onClose(document.folder_id ?? 'root')}
>
Back
</button>
<div>
<h2>{title}</h2>
<span className="meta">
{document.content_type || mime}
{sizeLabel ? ` · ${sizeLabel}` : ''}
</span>
</div>
</div>
<div className="preview-workspace__actions">
<a
className="button-link with-icon"
href={downloadHref || '#'}
target="_blank"
rel="noopener noreferrer"
aria-disabled={!downloadHref}
onClick={(event) => {
if (!downloadHref) {
event.preventDefault();
}
}}
>
<DownloadIcon className="icon-inline" />
<span>Download</span>
</a>
<button
type="button"
className="secondary"
onClick={() => onRegenerateThumbnails(document.id)}
>
Re-run analysis
</button>
</div>
</header>
<div className="preview-workspace__body">
{!previewEntry?.url ? (
<div className="preview-workspace__message">Loading preview</div>
) : (
<iframe
src={previewEntry.url}
title={`Preview of ${title}`}
className="preview-workspace__object"
/>
)}
</div>
{metadata && (
<section className="preview-workspace__metadata">
<h3>Metadata</h3>
<pre>{JSON.stringify(metadata, null, 2)}</pre>
</section>
)}
</section>
);
};
export default PreviewWorkspace;
@@ -1,72 +0,0 @@
import React, { useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useAppShell } from '../appShellContext';
import PreviewWorkspace from '../preview/PreviewWorkspace';
const DocumentViewerRoute = () => {
const {
previewWorkspaceDocument,
previewWorkspaceEntry,
closeDocumentPreview,
handleThumbnailRegeneration,
ensurePreviewData,
notifyApiError,
resolveApiPath,
} = useAppShell();
const { documentId } = useParams();
const navigate = useNavigate();
useEffect(() => {
if (!documentId) {
navigate('/documents', { replace: true });
return;
}
let cancelled = false;
const hydrate = async () => {
try {
await ensurePreviewData(documentId);
} catch (error) {
if (cancelled) {
return;
}
notifyApiError(error, 'Failed to open document preview.');
navigate('/documents', { replace: true });
}
};
hydrate();
return () => {
cancelled = true;
};
}, [documentId, ensurePreviewData, notifyApiError, navigate]);
const isReady =
documentId && previewWorkspaceDocument && previewWorkspaceDocument.id === documentId;
if (!isReady) {
return (
<main className="preview-main">
<div className="preview-workspace__message">Loading preview</div>
</main>
);
}
return (
<main className="preview-main">
<PreviewWorkspace
document={previewWorkspaceDocument}
previewEntry={previewWorkspaceEntry}
resolveApiPath={resolveApiPath}
onClose={closeDocumentPreview}
onRegenerateThumbnails={handleThumbnailRegeneration}
/>
</main>
);
};
export default DocumentViewerRoute;
+17 -87
View File
@@ -1,6 +1,7 @@
import React, { useCallback, useMemo } from 'react'; import React, { useCallback, useMemo } from 'react';
import { ChevronIcon, TrashIcon, EditIcon, FolderIcon } from '../ui/icons'; import { ChevronIcon, TrashIcon, EditIcon } from '../ui/icons';
const FOLDER_ICON_SRC = '/folder.svg';
import { getTagColorStyle } from '../utils/colors'; import { getTagColorStyle } from '../utils/colors';
const FolderNode = ({ const FolderNode = ({
@@ -67,7 +68,7 @@ const FolderNode = ({
</span> </span>
)} )}
<span className="name"> <span className="name">
<FolderIcon className="folder-icon-image" size={16} /> <img src={FOLDER_ICON_SRC} alt="Folder" className="folder-icon-image" />
{node.name} {node.name}
</span> </span>
{node.id !== 'root' && ( {node.id !== 'root' && (
@@ -136,18 +137,6 @@ const Sidebar = ({
correspondents = [], correspondents = [],
activeCorrespondentIds = [], activeCorrespondentIds = [],
onToggleCorrespondentFilter, onToggleCorrespondentFilter,
onManageTags,
onManageCorrespondents,
searchQuery = '',
onSearchChange,
onSearchSubmit,
onSearchClear,
isFilterActive,
appStatus,
loading,
previewActive,
onLogout,
status,
}) => { }) => {
const sortedCorrespondents = useMemo( const sortedCorrespondents = useMemo(
() => () =>
@@ -162,27 +151,6 @@ const Sidebar = ({
); );
const handleToggleTag = onToggleTagFilter || (() => {}); const handleToggleTag = onToggleTagFilter || (() => {});
const activeTagSet = new Set(activeTagIds); const activeTagSet = new Set(activeTagIds);
const handleManageTags = onManageTags || (() => {});
const handleManageCorrespondents = onManageCorrespondents || (() => {});
const handleSearchInputChange = useCallback(
(event) => {
onSearchChange?.(event.target.value);
},
[onSearchChange],
);
const handleSearchFormSubmit = useCallback(
(event) => {
event.preventDefault();
onSearchSubmit?.();
},
[onSearchSubmit],
);
const handleSearchClear = useCallback(() => {
onSearchClear?.();
}, [onSearchClear]);
const handleLogoutClick = useCallback(() => {
onLogout?.();
}, [onLogout]);
const renderNodes = useCallback( const renderNodes = useCallback(
(ids, depth) => (ids, depth) =>
@@ -226,39 +194,9 @@ const Sidebar = ({
); );
const rootNode = folderNodes.get('root'); const rootNode = folderNodes.get('root');
const hintText = appStatus === 'bootstrapping' && loading
? 'Loading your library…'
: previewActive
? 'Viewing document preview. Press ← Back to return to the library.'
: 'Drag files here to upload.';
return ( return (
<aside className="sidebar"> <aside className="sidebar column">
<div className="sidebar__meta">
<h1 className="sidebar__title">Papercrate</h1>
<span className="sidebar__hint">{hintText}</span>
{onSearchChange && (
<form className="sidebar__search" onSubmit={handleSearchFormSubmit}>
<input
type="search"
value={searchQuery}
onChange={handleSearchInputChange}
placeholder="Search documents"
aria-label="Search documents"
/>
{isFilterActive && (
<button type="button" onClick={handleSearchClear}>
Clear
</button>
)}
</form>
)}
{status && (
<div className="sidebar__status">
<div className={`status-banner ${status.variant}`}>{status.message}</div>
</div>
)}
</div>
<div className="sidebar-section sidebar-section--folders"> <div className="sidebar-section sidebar-section--folders">
<div className="sidebar-section__header"> <div className="sidebar-section__header">
<h3>Folders</h3> <h3>Folders</h3>
@@ -269,14 +207,8 @@ const Sidebar = ({
</div> </div>
<div className="sidebar-section"> <div className="sidebar-section">
<div className="sidebar-section__header"> <div className="sidebar-section__header">
<button
type="button"
className="sidebar-section__title"
onClick={handleManageTags}
>
<h3>Tags</h3> <h3>Tags</h3>
<span className="meta">{tags.length}</span> <span className="meta">{tags.length}</span>
</button>
</div> </div>
<div <div
className={`sidebar-tag-cloud${ className={`sidebar-tag-cloud${
@@ -284,7 +216,8 @@ const Sidebar = ({
}`} }`}
role="list" role="list"
> >
{tags.map((tag) => { {tags.length ? (
tags.map((tag) => {
const isActive = activeTagSet.has(tag.id); const isActive = activeTagSet.has(tag.id);
const style = getTagColorStyle(tag.color); const style = getTagColorStyle(tag.color);
const className = `sidebar-tag-pill${isActive ? ' active' : ''}`; const className = `sidebar-tag-pill${isActive ? ' active' : ''}`;
@@ -317,22 +250,20 @@ const Sidebar = ({
{tag.label} {tag.label}
</button> </button>
); );
})} })
) : (
<span className="meta">No tags yet</span>
)}
</div> </div>
</div> </div>
<div className="sidebar-section"> <div className="sidebar-section">
<div className="sidebar-section__header"> <div className="sidebar-section__header">
<button
type="button"
className="sidebar-section__title"
onClick={handleManageCorrespondents}
>
<h3>Correspondents</h3> <h3>Correspondents</h3>
<span className="meta">{correspondents.length}</span> <span className="meta">{correspondents.length}</span>
</button>
</div> </div>
<ul className="sidebar-correspondent-list"> <ul className="sidebar-correspondent-list">
{sortedCorrespondents.map((correspondent) => { {sortedCorrespondents.length ? (
sortedCorrespondents.map((correspondent) => {
const isActive = activeCorrespondentSet.has(correspondent.id); const isActive = activeCorrespondentSet.has(correspondent.id);
const className = `sidebar-correspondent-item${isActive ? ' active' : ''}`; const className = `sidebar-correspondent-item${isActive ? ' active' : ''}`;
const label = correspondent.name || 'Unnamed'; const label = correspondent.name || 'Unnamed';
@@ -345,6 +276,7 @@ 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 === ' ') {
@@ -357,14 +289,12 @@ const Sidebar = ({
</span> </span>
</li> </li>
); );
})} })
) : (
<li className="meta">No correspondents yet</li>
)}
</ul> </ul>
</div> </div>
<div className="sidebar__footer">
<button className="secondary" type="button" onClick={handleLogoutClick}>
Log out
</button>
</div>
</aside> </aside>
); );
}; };
+65 -56
View File
@@ -10,9 +10,9 @@
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
grid-column: 2 / -1;
min-height: 0; min-height: 0;
position: relative; position: relative;
background-color: var(--surface-subtle);
} }
.skeuo-header { .skeuo-header {
@@ -21,6 +21,7 @@
justify-content: space-between; justify-content: space-between;
gap: 1rem; gap: 1rem;
padding: 0.75rem 1rem 0.5rem; padding: 0.75rem 1rem 0.5rem;
background-color: var(--surface-subtle);
} }
.skeuo-header__meta { .skeuo-header__meta {
@@ -157,7 +158,7 @@
align-items: center; align-items: center;
justify-content: flex-start; justify-content: flex-start;
padding: 0.28rem 0.7rem; padding: 0.28rem 0.7rem;
border-radius: 1rem; border-radius: 1px;
background: linear-gradient(180deg, rgba(0, 0, 0, 0.04), rgba(0, 0, 0, 0) 70%), var(--surface); background: linear-gradient(180deg, rgba(0, 0, 0, 0.04), rgba(0, 0, 0, 0) 70%), var(--surface);
color: var(--fg); color: var(--fg);
font-size: 1rem; font-size: 1rem;
@@ -187,63 +188,72 @@
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;
} }
.skeuo-tag-shelf {
position: absolute;
top: 2.25rem;
right: 1rem;
display: flex;
flex-direction: column;
gap: 0.6rem;
align-items: flex-end;
max-height: calc(100% - 4rem);
overflow: auto;
padding: 0;
z-index: 100000;
}
.skeuo-tag-shelf .skeuo-tag {
cursor: grab;
}
.skeuo-tag-shelf .skeuo-tag:active {
cursor: grabbing;
}
.skeuo-tag-shelf .skeuo-tag.is-inactive {
opacity: 0.25;
}
.skeuo-tag-shelf .skeuo-tag.is-active {
opacity: 1;
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.08);
}
.skeuo-tag-add {
width: 2.4rem;
height: 2.4rem;
border-radius: 50%;
border: 1px solid rgba(0, 0, 0, 0.15);
background: rgba(255, 255, 255, 0.8);
color: var(--fg);
font-size: 1.4rem;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
margin-top: 0.4rem;
transition: background 0.2s ease, transform 0.2s ease;
}
.skeuo-tag-add:hover {
background: rgba(255, 255, 255, 1);
transform: translateY(-1px);
}
.skeuo-tag-add:active {
transform: translateY(0);
}
.skeuo-tag-add:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
body.skeuo-cursor-remove, body.skeuo-cursor-remove,
body.skeuo-cursor-remove * { body.skeuo-cursor-remove * {
cursor: not-allowed !important; cursor: not-allowed !important;
@@ -261,7 +271,6 @@ 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 {
+233 -151
View File
@@ -6,10 +6,8 @@ import React, {
useRef, useRef,
useState, useState,
} from 'react'; } from 'react';
import { resolveDocumentAssetUrl, createAssetView } from './asset_manager'; import { resolveDocumentAssetUrl } from './asset_manager';
import { useAssetNavigator } from './hooks/useAssetNavigator'; import { generateRandomTagColor, getReadableTextColor } from './utils/colors';
import { ArrowLeftIcon, ArrowRightIcon } from './ui/icons';
import { getReadableTextColor } from './utils/colors';
import './skeuomorphic_ws.css'; import './skeuomorphic_ws.css';
const ITEM_WIDTH = 220; const ITEM_WIDTH = 220;
@@ -33,6 +31,7 @@ const ZOOM_MIN_SCALE = 1.05;
const ZOOM_MAX_SCALE = 5; const ZOOM_MAX_SCALE = 5;
const TAG_REMOVE_DISTANCE = 160; const TAG_REMOVE_DISTANCE = 160;
const DRAG_PREVIEW_KEY = Symbol('dragPreview');
const DEBUG_DRAG = false; const DEBUG_DRAG = false;
const DEBUG_FOCUS = true; const DEBUG_FOCUS = true;
const DEBUG_DROP = true; const DEBUG_DROP = true;
@@ -45,107 +44,6 @@ 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,
{ {
@@ -313,15 +211,6 @@ const useDocumentDrag = ({
if (!state || state.pointerId !== pointerId) { if (!state || state.pointerId !== pointerId) {
return; return;
} }
const capturedTarget = state.capturedTarget;
if (capturedTarget && typeof capturedTarget.releasePointerCapture === 'function') {
try {
capturedTarget.releasePointerCapture(pointerId);
} catch (
// eslint-disable-next-line no-empty
error
) {}
}
dragStateRef.current = null; dragStateRef.current = null;
setDraggingId((current) => (current === state.docId ? null : current)); setDraggingId((current) => (current === state.docId ? null : current));
syncLayoutSnapshot(); syncLayoutSnapshot();
@@ -358,15 +247,6 @@ const useDocumentDrag = ({
} }
bringToFront(docId); bringToFront(docId);
const capturedTarget = event.currentTarget instanceof HTMLElement ? event.currentTarget : null;
if (capturedTarget && typeof capturedTarget.setPointerCapture === 'function') {
try {
capturedTarget.setPointerCapture(event.pointerId);
} catch (
// eslint-disable-next-line no-empty
error
) {}
}
dragStateRef.current = { dragStateRef.current = {
docId, docId,
pointerId: event.pointerId, pointerId: event.pointerId,
@@ -380,7 +260,6 @@ const useDocumentDrag = ({
width: docWidth, width: docWidth,
height: docHeight, height: docHeight,
scale: baseScale, scale: baseScale,
capturedTarget,
}; };
setDraggingId(docId); setDraggingId(docId);
}, },
@@ -553,17 +432,20 @@ const SkeuomorphicWorkspace = ({
onRefresh, onRefresh,
onDocumentOpen, onDocumentOpen,
resolveThumbnailUrl, resolveThumbnailUrl,
availableTags = [],
onCreateTag = null,
onAssignTagToDocument = null, onAssignTagToDocument = null,
onRemoveTagFromDocument = null, onRemoveTagFromDocument = null,
ensureAssetUrl = null, ensureAssetUrl = null,
getDocumentAsset = () => null, getDocumentAsset = () => null,
activeTagIds = [], prepareTagPayload = null,
}) => { }) => {
const items = useMemo(() => (searchResults ? searchResults : documents), [documents, searchResults]); const items = useMemo(() => (searchResults ? searchResults : documents), [documents, searchResults]);
const showingSearchResults = searchResults !== null; const showingSearchResults = searchResults !== null;
const containerRef = useRef(null); const containerRef = useRef(null);
const tagShelfRef = useRef(null);
const layoutRef = useRef(new Map()); const layoutRef = useRef(new Map());
const itemRefs = useRef(new Map()); const itemRefs = useRef(new Map());
const zCounterRef = useRef(10); const zCounterRef = useRef(10);
@@ -571,27 +453,17 @@ const SkeuomorphicWorkspace = ({
const [layoutSnapshot, setLayoutSnapshot] = useState(() => new Map()); const [layoutSnapshot, setLayoutSnapshot] = useState(() => new Map());
const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 }); const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 });
const [tagShelfWidth, setTagShelfWidth] = useState(0);
const [draggingId, setDraggingId] = useState(null); const [draggingId, setDraggingId] = useState(null);
const [zoomedId, setZoomedId] = useState(null); const [zoomedId, setZoomedId] = useState(null);
const [tagDropTargetId, setTagDropTargetId] = useState(null); const [tagDropTargetId, setTagDropTargetId] = useState(null);
const [pendingTagDocId, setPendingTagDocId] = useState(null); const [pendingTagDocId, setPendingTagDocId] = useState(null);
const [pendingRemovalTag, setPendingRemovalTag] = useState(null); const [pendingRemovalTag, setPendingRemovalTag] = useState(null);
const [activeShelfTagId, setActiveShelfTagId] = useState(null);
const draggingTagRef = useRef(null); const draggingTagRef = useRef(null);
const pendingDocTagDragRef = useRef(null); const pendingDocTagDragRef = useRef(null);
const docSizeMapRef = useRef(new Map()); const docSizeMapRef = useRef(new Map());
const removalCursorActiveRef = useRef(false); const removalCursorActiveRef = useRef(false);
const activeTagSet = useMemo(() => {
if (!Array.isArray(activeTagIds) || activeTagIds.length === 0) {
return new Set();
}
const set = new Set();
activeTagIds.forEach((id) => {
if (id != null) {
set.add(String(id));
}
});
return set;
}, [activeTagIds]);
const resolvePreviewAsset = useCallback( const resolvePreviewAsset = useCallback(
(doc) => { (doc) => {
@@ -605,10 +477,8 @@ const SkeuomorphicWorkspace = ({
(doc) => { (doc) => {
if (!doc) return null; if (!doc) return null;
const asset = resolvePreviewAsset(doc); const asset = resolvePreviewAsset(doc);
const view = createAssetView(asset); const width = asset?.metadata?.width;
const metadata = view.getPrimaryMetadata() || {}; const height = asset?.metadata?.height;
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 };
} }
@@ -617,6 +487,17 @@ 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;
@@ -710,11 +591,72 @@ const SkeuomorphicWorkspace = ({
); );
}, []); }, []);
const handleShelfTagDragStart = useCallback((event, tag) => {
if (!tag) return;
try {
event.dataTransfer.effectAllowed = 'copy';
const payload = JSON.stringify({ id: tag.id, label: tag.label });
event.dataTransfer.setData('application/x-papercrate-tag', payload);
event.dataTransfer.setData('text/papercrate-tag', payload);
event.dataTransfer.setData('text/plain', tag.label || 'Tag');
} catch (error) {
console.warn('Failed to initiate tag drag', error);
}
const node = event.currentTarget;
const hideNode = () => {
if (node instanceof HTMLElement) {
node.classList.add('is-drag-hidden');
}
};
if (node instanceof HTMLElement) {
if (node[DRAG_PREVIEW_KEY]) {
cleanupPreview(node[DRAG_PREVIEW_KEY]);
delete node[DRAG_PREVIEW_KEY];
}
const preview = createDragPreview(node, event.clientX, event.clientY);
if (preview && event.dataTransfer) {
try {
event.dataTransfer.setDragImage(preview.clone, preview.offsetX, preview.offsetY);
node[DRAG_PREVIEW_KEY] = preview.clone;
} catch (
// eslint-disable-next-line no-empty
error
) {}
}
}
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
window.requestAnimationFrame(hideNode);
} else {
setTimeout(hideNode, 0);
}
}, []);
const handleTagDragEnd = useCallback(() => { const handleTagDragEnd = useCallback(() => {
updateRemovalCursor(false); updateRemovalCursor(false);
setTagDropTargetId(null); setTagDropTargetId(null);
}, [updateRemovalCursor]); }, [updateRemovalCursor]);
const handleShelfTagDragEndWithReset = useCallback((event) => {
if (event?.currentTarget instanceof HTMLElement) {
const target = event.currentTarget;
const showNode = () => {
if (target instanceof HTMLElement) {
target.classList.remove('is-drag-hidden');
const stored = target[DRAG_PREVIEW_KEY];
cleanupPreview(stored);
delete target[DRAG_PREVIEW_KEY];
}
};
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
window.requestAnimationFrame(showNode);
} else {
setTimeout(showNode, 0);
}
}
handleTagDragEnd();
queueFocusCanvas();
}, [handleTagDragEnd, queueFocusCanvas]);
const ensureDocumentSize = useCallback((doc) => { const ensureDocumentSize = useCallback((doc) => {
const key = resolveSizeKey(doc); const key = resolveSizeKey(doc);
const cache = docSizeMapRef.current.get(key); const cache = docSizeMapRef.current.get(key);
@@ -779,6 +721,17 @@ const SkeuomorphicWorkspace = ({
docSizeMapRef.current = new Map(); docSizeMapRef.current = new Map();
}, [items]); }, [items]);
useEffect(() => {
if (!activeShelfTagId) {
return;
}
const stillExists = availableTags.some((tag) => resolveTagKey(tag) === activeShelfTagId);
if (!stillExists) {
setActiveShelfTagId(null);
}
}, [activeShelfTagId, availableTags]);
const resolveZoomMetrics = useCallback( const resolveZoomMetrics = useCallback(
(doc, cardWidth, cardHeight) => { (doc, cardWidth, cardHeight) => {
const canvasWidth = canvasSize.width || DEFAULT_CANVAS_WIDTH; const canvasWidth = canvasSize.width || DEFAULT_CANVAS_WIDTH;
@@ -868,6 +821,30 @@ const SkeuomorphicWorkspace = ({
[resolvePreviewDimensions], [resolvePreviewDimensions],
); );
useLayoutEffect(() => {
const shelfNode = tagShelfRef.current;
if (!shelfNode || !availableTags.length) {
setTagShelfWidth(0);
return () => {};
}
const measure = () => {
const rect = shelfNode.getBoundingClientRect();
setTagShelfWidth(Math.ceil(rect.width));
};
measure();
if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', measure);
return () => window.removeEventListener('resize', measure);
}
const observer = new ResizeObserver(() => measure());
observer.observe(shelfNode);
return () => observer.disconnect();
}, [availableTags.length]);
const syncLayoutSnapshot = useCallback(() => { const syncLayoutSnapshot = useCallback(() => {
setLayoutSnapshot(new Map(layoutRef.current)); setLayoutSnapshot(new Map(layoutRef.current));
}, []); }, []);
@@ -973,7 +950,7 @@ const SkeuomorphicWorkspace = ({
startZ: maxZ, startZ: maxZ,
rotationRange: ROTATION_RANGE, rotationRange: ROTATION_RANGE,
minSpacing: 48, minSpacing: 48,
shelfWidth: 0, shelfWidth: tagShelfWidth > 0 ? tagShelfWidth + CANVAS_PADDING : 0,
}, },
); );
generatedLayout.forEach((entry, docId) => { generatedLayout.forEach((entry, docId) => {
@@ -994,6 +971,7 @@ const SkeuomorphicWorkspace = ({
canvasSize.height, canvasSize.height,
ensureDocumentSize, ensureDocumentSize,
syncLayoutSnapshot, syncLayoutSnapshot,
tagShelfWidth,
]); ]);
useEffect(() => { useEffect(() => {
@@ -1172,7 +1150,7 @@ const SkeuomorphicWorkspace = ({
setPendingTagDocId(doc.id); setPendingTagDocId(doc.id);
try { try {
await onAssignTagToDocument({ documentId: doc.id, tagId, tag: payload }); await onAssignTagToDocument({ documentId: doc.id, tagId });
markActiveTagDropHandled(tagId, sourceDocId); markActiveTagDropHandled(tagId, sourceDocId);
if (DEBUG_DROP) { if (DEBUG_DROP) {
console.log('[skeuo] handleTagDropOnDoc: assigned tag', tagId, 'to doc', doc.id); console.log('[skeuo] handleTagDropOnDoc: assigned tag', tagId, 'to doc', doc.id);
@@ -1489,11 +1467,29 @@ const SkeuomorphicWorkspace = ({
[queueFocusCanvas, handleTagDragEnd, onRemoveTagFromDocument, updateRemovalCursor], [queueFocusCanvas, handleTagDragEnd, onRemoveTagFromDocument, updateRemovalCursor],
); );
const renderBreadcrumbs = () => {
if (!breadcrumbs.length) return null;
return (
<nav className="skeuo-breadcrumbs" aria-label="Folder breadcrumbs">
{breadcrumbs.map((crumb, index) => {
const isLast = index === breadcrumbs.length - 1;
return (
<span key={crumb.id} className={`skeuo-crumb${isLast ? ' is-current' : ''}`}>
{crumb.name}
{!isLast && <span className="skeuo-crumb-separator"></span>}
</span>
);
})}
</nav>
);
};
return ( return (
<div className="skeuo-shell"> <div className="skeuo-shell">
<header className="skeuo-header"> <header className="skeuo-header">
<div className="skeuo-header__meta"> <div className="skeuo-header__meta">
<h2>{currentFolderName}</h2> <h2>{currentFolderName}</h2>
{renderBreadcrumbs()}
{showingSearchResults && <span className="meta">Showing search results</span>} {showingSearchResults && <span className="meta">Showing search results</span>}
</div> </div>
<div className="skeuo-header__actions"> <div className="skeuo-header__actions">
@@ -1508,6 +1504,7 @@ const SkeuomorphicWorkspace = ({
<div <div
className="skeuo-canvas" className="skeuo-canvas"
ref={containerRef} ref={containerRef}
data-active-tag={activeShelfTagId || undefined}
onDragOver={handleCanvasDragOver} onDragOver={handleCanvasDragOver}
onDragLeave={handleCanvasDragLeave} onDragLeave={handleCanvasDragLeave}
onDrop={handleCanvasDrop} onDrop={handleCanvasDrop}
@@ -1565,6 +1562,9 @@ 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 : [];
@@ -1572,7 +1572,7 @@ const SkeuomorphicWorkspace = ({
.map((tag) => resolveTagKey(tag)) .map((tag) => resolveTagKey(tag))
.filter(Boolean); .filter(Boolean);
const matchesFilter = const matchesFilter =
activeTagSet.size === 0 || docTagKeys.some((key) => activeTagSet.has(key)); !activeShelfTagId || docTagKeys.includes(activeShelfTagId);
const dropActive = tagDropTargetId === doc.id; const dropActive = tagDropTargetId === doc.id;
const dropPending = pendingTagDocId === doc.id; const dropPending = pendingTagDocId === doc.id;
const itemClasses = ['skeuo-item']; const itemClasses = ['skeuo-item'];
@@ -1581,6 +1581,8 @@ 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
@@ -1616,13 +1618,18 @@ const SkeuomorphicWorkspace = ({
}} }}
> >
<div className="skeuo-item__body" style={bodyStyle}> <div className="skeuo-item__body" style={bodyStyle}>
<SkeuoPreviewCard <div className={cardClasses.join(' ')}>
doc={doc} {hasPreview ? (
title={title} <img src={imageUrl} alt={title} />
ensureAssetUrl={ensureAssetUrl} ) : (
getDocumentAsset={getDocumentAsset} <div className="skeuo-item__empty">
navScale={inverseTagScale} <div className="skeuo-item__placeholder">DOC</div>
/> <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) => {
@@ -1667,6 +1674,81 @@ const SkeuomorphicWorkspace = ({
); );
}) })
)} )}
{(availableTags.length > 0 || onCreateTag) && (
<div
className="skeuo-tag-shelf"
aria-label="Available tags"
ref={tagShelfRef}
>
{availableTags.map((tag) => {
const colorValue = normalizeColor(tag.color);
const foreground = getContrastingTextColor(colorValue || '#1b1f24');
const tagStyle = colorValue
? { backgroundColor: colorValue, color: foreground }
: undefined;
const tagKey = resolveTagKey(tag);
const isSelected = activeShelfTagId === tagKey;
const shelfTagClasses = ['skeuo-tag'];
if (isSelected) {
shelfTagClasses.push('is-active');
} else if (activeShelfTagId) {
shelfTagClasses.push('is-inactive');
}
return (
<div
key={tag.id || tag.label}
className={shelfTagClasses.join(' ')}
style={tagStyle}
title={tag.label}
draggable
data-tag-id={tagKey || undefined}
onDragStart={(event) => handleShelfTagDragStart(event, tag)}
onDragEnd={handleShelfTagDragEndWithReset}
role="button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setActiveShelfTagId((current) => (current === tagKey ? null : tagKey));
}}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
setActiveShelfTagId((current) => (current === tagKey ? null : tagKey));
}
}}
>
<span>{tag.label}</span>
</div>
);
})}
{typeof onCreateTag === 'function' && (
<button
type="button"
className="skeuo-tag-add"
aria-label="Add tag"
onClick={async () => {
const labelInput = window.prompt('New tag name?');
const label = labelInput ? labelInput.trim() : '';
if (!label) {
return;
}
try {
const payload =
typeof prepareTagPayload === 'function'
? prepareTagPayload({ label })
: { label, color: generateRandomTagColor() };
await onCreateTag(payload);
setActiveShelfTagId(null);
} catch (error) {
console.error('Failed to create tag', error);
}
}}
>
+
</button>
)}
</div>
)}
</div> </div>
</div> </div>
); );
+339 -543
View File
File diff suppressed because it is too large Load Diff
+4 -82
View File
@@ -1,22 +1,12 @@
import React, { useCallback, useMemo, useState } from 'react'; import React, { useCallback, useMemo, useState } from 'react';
import { getTagColorStyle, HEX_COLOR_PATTERN } from '../utils/colors'; import { getTagColorStyle, HEX_COLOR_PATTERN } from '../utils/colors';
function TagsPanel({ function TagsPanel({ tags, onRefresh, onUpdateTag, onDeleteTag, onNotify }) {
tags,
onRefresh,
onCreateTag,
onUpdateTag,
onDeleteTag,
onNotify,
}) {
const [editingId, setEditingId] = useState(null); const [editingId, setEditingId] = useState(null);
const [draftLabel, setDraftLabel] = useState(''); const [draftLabel, setDraftLabel] = useState('');
const [draftColor, setDraftColor] = useState(''); const [draftColor, setDraftColor] = useState('');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [deletingId, setDeletingId] = useState(null); const [deletingId, setDeletingId] = useState(null);
const [createLabel, setCreateLabel] = useState('');
const [createColor, setCreateColor] = useState('');
const [creating, setCreating] = useState(false);
const startEdit = useCallback((tag) => { const startEdit = useCallback((tag) => {
setEditingId(tag.id); setEditingId(tag.id);
@@ -108,87 +98,19 @@ function TagsPanel({
[onDeleteTag, editingId, cancelEdit, onNotify], [onDeleteTag, editingId, cancelEdit, onNotify],
); );
const handleCreate = useCallback(
async (event) => {
event.preventDefault();
if (typeof onCreateTag !== 'function') {
return;
}
const trimmedLabel = createLabel.trim();
if (!trimmedLabel) {
onNotify?.('Tag label cannot be empty.', 'error');
return;
}
const trimmedColor = createColor.trim();
const colorPattern = /^#([0-9a-fA-F]{6})$/;
if (trimmedColor && !colorPattern.test(trimmedColor)) {
onNotify?.('Colors must use the #RRGGBB format.', 'error');
return;
}
setCreating(true);
try {
await onCreateTag({
label: trimmedLabel,
color: trimmedColor ? trimmedColor : null,
});
setCreateLabel('');
setCreateColor('');
} catch (createError) {
const message = createError?.message || 'Failed to create tag.';
onNotify?.(message, 'error');
} finally {
setCreating(false);
}
},
[createLabel, createColor, onCreateTag, onNotify],
);
return ( return (
<section className="tags-panel"> <section className="tags-panel column">
<div className="column-header"> <div className="column-header">
<div className="column-header__titles"> <div className="column-header__titles">
<h2>Tags</h2> <h2>Tags</h2>
<div className="column-subtitle">{tags.length} total</div> <div className="column-subtitle">{tags.length} total</div>
</div> </div>
<div className="header-actions tags-actions"> <div className="header-actions">
<form className="tags-actions__form" onSubmit={handleCreate}>
<input
type="text"
placeholder="New tag label"
value={createLabel}
onChange={(event) => setCreateLabel(event.target.value)}
disabled={creating}
/>
<input
type="color"
className="tags-table__color-picker"
value={createColor || '#3366ff'}
onChange={(event) => setCreateColor(event.target.value)}
disabled={creating}
aria-label="Tag color (optional)"
/>
{createColor && (
<button
type="button"
className="secondary"
onClick={() => setCreateColor('')}
disabled={creating}
>
Clear
</button>
)}
<button type="submit" disabled={creating || !createLabel.trim()}>
{creating ? 'Creating…' : 'Create'}
</button>
</form>
<button <button
className="secondary" className="secondary"
type="button" type="button"
onClick={onRefresh} onClick={onRefresh}
disabled={saving || creating || Boolean(deletingId)} disabled={saving || Boolean(deletingId)}
> >
Refresh Refresh
</button> </button>
+6 -76
View File
@@ -1,20 +1,14 @@
import { import {
IconChevronRight as TablerChevronRight, IconChevronRight as TablerChevronRight,
IconDownload as TablerDownload, IconDownload as TablerDownload,
IconFolderFilled,
IconPencil, IconPencil,
IconTagFilled, IconTagFilled,
IconUserFilled, IconUserFilled,
IconTrash, IconTrash,
IconLayoutList, IconLayoutList,
IconLayoutGrid, IconLayoutGrid,
IconArrowLeft,
IconArrowRight,
IconChevronsRight,
IconAnalyze,
IconWindowMaximize,
IconTextScan2,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import FolderSvg from '../assets/folder.svg';
const composeClassName = (base, extra) => (extra ? `${base} ${extra}` : base); const composeClassName = (base, extra) => (extra ? `${base} ${extra}` : base);
@@ -45,21 +39,14 @@ export const EditIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) =>
/> />
); );
export const FolderIcon = ({ className, size = 16, title, ...rest }) => { export const FolderIcon = ({ className, size = '1em', stroke = 0, ...rest }) => (
const dimensionProps = typeof size === 'number' ? { width: size, height: size } : {}; <IconFolderFilled
className={composeClassName('icon icon--fill', className)}
return ( size={size}
<FolderSvg stroke={stroke}
className={composeClassName('folder-icon', className)}
{...dimensionProps}
role={title ? 'img' : 'presentation'}
aria-hidden={title ? undefined : true}
focusable="false"
title={title}
{...rest} {...rest}
/> />
); );
};
export const TagIcon = ({ className, size = '1em', stroke = 0, ...rest }) => ( export const TagIcon = ({ className, size = '1em', stroke = 0, ...rest }) => (
<IconTagFilled <IconTagFilled
@@ -106,51 +93,6 @@ 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 const ChevronsRightIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<IconChevronsRight
className={composeClassName('icon', className)}
size={size}
stroke={stroke}
{...rest}
/>
);
export const AnalyzeIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<IconAnalyze
className={composeClassName('icon', className)}
size={size}
stroke={stroke}
{...rest}
/>
);
export const WindowMaximizeIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<IconWindowMaximize
className={composeClassName('icon', className)}
size={size}
stroke={stroke}
{...rest}
/>
);
export default { export default {
ChevronIcon, ChevronIcon,
TrashIcon, TrashIcon,
@@ -161,16 +103,4 @@ export default {
DownloadIcon, DownloadIcon,
ViewListIcon, ViewListIcon,
ViewGridIcon, ViewGridIcon,
ChevronsRightIcon,
AnalyzeIcon,
WindowMaximizeIcon,
}; };
export const TextScanIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<IconTextScan2
className={composeClassName('icon', className)}
size={size}
stroke={stroke}
{...rest}
/>
);
-10
View File
@@ -29,16 +29,6 @@ module.exports = {
test: /\.css$/i, test: /\.css$/i,
use: ['style-loader', 'css-loader'], use: ['style-loader', 'css-loader'],
}, },
{
test: /\.svg$/i,
issuer: /\.[jt]sx?$/,
use: ['@svgr/webpack'],
},
{
test: /\.svg$/i,
type: 'asset/resource',
issuer: { not: [/\.[jt]sx?$/] },
},
], ],
}, },
plugins: [ plugins: [
-11
View File
@@ -1,11 +0,0 @@
# tmux session for Papercrate dev stack
new-session -d -s papercrate -n docker -c . 'docker compose up'
new-window -t papercrate:1 -n frontend -c ./frontend 'npm run dev'
new-window -t papercrate:2 -n backend -c ./backend 'cargo run --bin backend'
new-window -t papercrate:3 -n worker -c ./backend 'cargo run --bin worker'
select-window -t papercrate:0
attach-session -t papercrate
+26
View File
@@ -0,0 +1,26 @@
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]