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
236 changed files with 13949 additions and 43129 deletions
+3 -45
View File
@@ -5,7 +5,6 @@ on:
branches: branches:
- main - main
- staging - staging
- dev
tags: tags:
- '*' - '*'
@@ -28,52 +27,13 @@ jobs:
with: with:
submodules: recursive submodules: recursive
- name: Derive repository metadata - name: Login to Docker Registry
id: repo_meta
run: |
repo="${GITHUB_REPOSITORY:-$GITEA_REPOSITORY}"
owner="${repo%%/*}"
name="${repo##*/}"
echo "repo_owner=$owner" >> "$GITHUB_OUTPUT"
echo "repo_name=$name" >> "$GITHUB_OUTPUT"
- name: Compute base tag
id: compute_tag
run: |
sha="${GITHUB_SHA:-$GITEA_SHA}"
ref_type="${GITHUB_REF_TYPE:-$GITEA_REF_TYPE}"
ref_name="${GITHUB_REF_NAME:-$GITEA_REF_NAME}"
if [ -z "$sha" ]; then
echo "base_tag=$ref_name" >> "$GITHUB_OUTPUT"
exit 0
fi
short="${sha:0:7}"
tag="$short"
if [ "$ref_type" = "tag" ]; then
tag="$ref_name"
elif [ "$ref_name" = "dev" ]; then
tag="${tag}-dev"
fi
echo "base_tag=$tag" >> "$GITHUB_OUTPUT"
- name: Login to local registry
uses: docker/login-action@v2 uses: docker/login-action@v2
with: with:
registry: ${{ vars.REGISTRY_URL }} registry: ${{ vars.REGISTRY_URL }}
username: ${{ vars.REGISTRY_USER }} username: ${{ vars.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }} password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Login to GHCR
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ vars.GHCR_USERNAME }}
password: ${{ secrets.GHCR_PASSWORD }}
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
with: with:
@@ -89,7 +49,5 @@ jobs:
push: true push: true
provenance: false provenance: false
tags: | tags: |
${{ vars.REGISTRY_URL }}/${{ steps.repo_meta.outputs.repo_name }}-${{ matrix.service }}:${{ steps.compute_tag.outputs.base_tag }} ${{ vars.REGISTRY_URL }}/${{ gitea.repository }}-${{ matrix.service }}:${{ gitea.ref_type == 'tag' && gitea.ref_name || (gitea.ref_name == 'main' && 'latest' || gitea.ref_name) }}
${{ vars.REGISTRY_URL }}/${{ steps.repo_meta.outputs.repo_name }}-${{ matrix.service }}:${{ gitea.sha }} ${{ vars.REGISTRY_URL }}/${{ gitea.repository }}-${{ matrix.service }}:${{ gitea.sha }}
ghcr.io/paperless-dms/${{ steps.repo_meta.outputs.repo_name }}-${{ matrix.service }}:${{ steps.compute_tag.outputs.base_tag }}
ghcr.io/paperless-dms/${{ steps.repo_meta.outputs.repo_name }}-${{ matrix.service }}:${{ gitea.sha }}
-104
View File
@@ -1,104 +0,0 @@
# Development
This document collects runtime assumptions and workflows for local development,
integration testing, and infrastructure automation.
## 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.
The development Postgres container now seeds two database roles:
- `papercrate_app_login` (password `papercrate_app`) is used by the backend and
is subject to row-level security policies.
- `papercrate` remains the owner role for running Diesel migrations or other
maintenance tasks.
When connecting manually to inspect RLS behaviour, switch to the application
role with `SET ROLE papercrate_app_login;` before querying tenant tables.
## 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:
```bash
docker compose -f docker-compose.test.yml up -d
export TEST_DATABASE_URL=postgres://papercrate:papercrate_test@localhost:5433/papercrate_test
# optional, enables Quickwit indexing jobs
export QUICKWIT_ENDPOINT=http://localhost:7280
export QUICKWIT_INDEX=documents
cargo test
```
Stop the database when you are done:
```bash
docker compose -f docker-compose.test.yml down
```
The compose service uses tmpfs storage, giving each test run a clean database.
## Runtime Dependencies
- `ocrmypdf` (optional but recommended): Used by the OCR worker to extract text
from PDFs when no embedded text layer is available. Ensure it is installed and
available on the worker hosts if OCR is desired.
- Quickwit (optional): The Quickwit indexer is used to ingest extracted text for
search. Set `QUICKWIT_ENDPOINT` and `QUICKWIT_INDEX` in the environment when
running workers if you want indexing jobs to run. The local compose file starts
a Quickwit instance on `http://localhost:7280` and seeds the `documents` index
automatically.
## Configuration
The backend reads its settings from environment variables. In particular:
- `DATABASE_URL` connection string for the primary Postgres database (required).
- `DATABASE_MAX_POOL_SIZE` optional override for the r2d2 connection pool size.
Defaults to `2`; increase it in staging/production to match expected concurrency.
On startup each binary logs the effective configuration with secrets redacted
(for example, the database password is masked). This makes it easier to confirm
runtime settings in staging without exposing credentials.
## Running Migrations in Kubernetes
The backend container image ships the `diesel` CLI, so schema migrations can be
executed as a short-lived Job (or Helm hook) before rolling out new pods. Example
manifest:
```yaml
apiVersion: batch/v1
kind: Job
metadata:
name: papercrate-migrate
spec:
template:
spec:
restartPolicy: OnFailure
containers:
- name: migrate
image: ghcr.io/example/papercrate-backend:<TAG>
command: ["/usr/local/bin/diesel", "migration", "run"]
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: papercrate-db
key: DATABASE_URL
```
Run the Job manually (`kubectl apply -f migrate-job.yaml`) or configure it as a
Helm pre-install/pre-upgrade hook so migrations run automatically on each
deployment. Once the Job succeeds, deploy/update the backend `Deployment` as
usual.
+51 -36
View File
@@ -1,48 +1,63 @@
# Papercrate # Papercrate
![Papercrate document workspace](docs/screenshot.png) ## Backend Integration Tests
## Single-Host Deployment (Docker Compose) Integration tests require a running Postgres instance (and, optionally, Quickwit for OCR indexing). The repository includes a lightweight compose file for local runs:
For self-hosting (for example on a Raspberry Pi), use the production-oriented
`docker-compose.yml`. Define the required secrets in a `.env` file alongside the
compose file before starting the stack:
```bash ```bash
cat <<'EOF' > .env docker compose -f docker-compose.test.yml up -d
POSTGRES_PASSWORD=change-me export TEST_DATABASE_URL=postgres://papercrate:papercrate_test@localhost:5433/papercrate_test
MINIO_ROOT_PASSWORD=change-me-too # optional, enables Quickwit indexing jobs
JWT_SECRET=generate-a-long-random-string export QUICKWIT_ENDPOINT=http://localhost:7280
WEBAUTHN_RP_ID=papercrate.local export QUICKWIT_INDEX=documents
WEBAUTHN_ORIGIN=http://papercrate.local:8080 cargo test
# Optional overrides
# CORS_ALLOWED_ORIGIN=http://papercrate.local:8080
# REFRESH_COOKIE_SECURE=true
EOF
docker compose build
docker compose up -d
``` ```
**Important:** the WebAuthn settings must match the public URL clients will use. Stop the database when you are done:
The relying party (RP) identifier is the bare host name, while the origin must
include scheme and port. Adjust the values above if you serve Papercrate from a
different host, domain, or HTTPS endpoint—otherwise passkey registration and
login will fail.
The compose file builds the backend and frontend images locally, then launches ```bash
Postgres, MinIO, Quickwit, the API, background worker, WebDAV endpoint, and the docker compose -f docker-compose.test.yml down
SPA frontend. Once the containers report healthy, visit `http://<host>:8080` ```
and use the passkey signup flow to provision the first tenant/user. Upgrades are
as simple as `git pull` followed by `docker compose up -d`.
## Screenshots The compose service uses tmpfs storage, giving each test run a clean database.
![Document detail view](docs/detailview.png) ## Runtime Dependencies
![Desk overview](docs/deskview.png)
![Document view](docs/documentview.png)
--- - `ocrmypdf` (optional but recommended): Used by the OCR worker to extract text from PDFs when no embedded text layer is available. Ensure it is installed and available on the worker hosts if OCR is desired.
- Quickwit (optional): The Quickwit indexer is used to ingest extracted text for search. Set `QUICKWIT_ENDPOINT` and `QUICKWIT_INDEX` in the environment when running workers if you want indexing jobs to run. The local compose file starts a Quickwit instance on `http://localhost:7280` and seeds the `documents` index automatically.
For development workflows (local stack, integration tests, migrations, and ## Configuration
configuration details) see [DEVELOPMENT.md](./DEVELOPMENT.md).
The backend reads its settings from environment variables (see `backend/.env` for local defaults). In particular:
- `DATABASE_URL` connection string for the primary Postgres database (required).
- `DATABASE_MAX_POOL_SIZE` optional override for the r2d2 connection pool size. Defaults to `2`; increase it in staging/production to match expected concurrency.
On startup each binary logs the effective configuration with secrets redacted (for example, the database password is masked). This makes it easier to confirm the runtime settings in staging without exposing credentials.
## Running Migrations in Kubernetes
The backend container image ships the `diesel` CLI, so schema migrations can be executed as a short-lived Job (or Helm hook) before rolling out new pods. Example manifest:
```yaml
apiVersion: batch/v1
kind: Job
metadata:
name: papercrate-migrate
spec:
template:
spec:
restartPolicy: OnFailure
containers:
- name: migrate
image: ghcr.io/example/papercrate-backend:<TAG>
command: ["/usr/local/bin/diesel", "migration", "run"]
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: papercrate-db
key: DATABASE_URL
```
Run the Job manually (`kubectl apply -f migrate-job.yaml`) or configure it as a Helm pre-install/pre-upgrade hook so migrations run automatically on each deployment. Once the Job succeeds, deploy/update the backend `Deployment` as usual.
+70 -588
View File
@@ -41,56 +41,6 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "anstream"
version = "0.6.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
[[package]]
name = "anstyle-parse"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2"
dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.60.2",
]
[[package]] [[package]]
name = "anyhow" name = "anyhow"
version = "1.0.100" version = "1.0.100"
@@ -109,45 +59,6 @@ dependencies = [
"password-hash", "password-hash",
] ]
[[package]]
name = "asn1-rs"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048"
dependencies = [
"asn1-rs-derive",
"asn1-rs-impl",
"displaydoc",
"nom",
"num-traits",
"rusticata-macros",
"thiserror 1.0.69",
"time",
]
[[package]]
name = "asn1-rs-derive"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
"synstructure",
]
[[package]]
name = "asn1-rs-impl"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]] [[package]]
name = "async-trait" name = "async-trait"
version = "0.1.89" version = "0.1.89"
@@ -156,7 +67,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.106", "syn",
] ]
[[package]] [[package]]
@@ -682,6 +593,51 @@ dependencies = [
"tower-service", "tower-service",
] ]
[[package]]
name = "backend"
version = "0.1.0"
dependencies = [
"anyhow",
"argon2",
"async-trait",
"aws-config",
"aws-credential-types",
"aws-sdk-s3",
"axum",
"axum-extra",
"base64 0.21.7",
"bytes",
"chrono",
"diesel",
"diesel_migrations",
"dotenv",
"futures-util",
"hex",
"http-body-util",
"hyper 1.7.0",
"image",
"jsonwebtoken",
"mime_guess",
"once_cell",
"pdfium-render",
"percent-encoding",
"quick-xml",
"rand 0.8.5",
"reqwest",
"serde",
"serde_json",
"sha2",
"tempfile",
"thiserror 1.0.69",
"tokio",
"tower 0.4.13",
"tower-http",
"tracing",
"tracing-subscriber",
"url",
"uuid",
]
[[package]] [[package]]
name = "backtrace" name = "backtrace"
version = "0.3.76" version = "0.3.76"
@@ -731,17 +687,6 @@ version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba"
[[package]]
name = "base64urlsafedata"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "215ee31f8a88f588c349ce2d20108b2ed96089b96b9c2b03775dc35dd72938e8"
dependencies = [
"base64 0.21.7",
"pastey",
"serde",
]
[[package]] [[package]]
name = "bindgen" name = "bindgen"
version = "0.72.1" version = "0.72.1"
@@ -759,7 +704,7 @@ dependencies = [
"regex", "regex",
"rustc-hash", "rustc-hash",
"shlex", "shlex",
"syn 2.0.106", "syn",
] ]
[[package]] [[package]]
@@ -884,46 +829,6 @@ dependencies = [
"libloading", "libloading",
] ]
[[package]]
name = "clap"
version = "4.5.51"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c26d721170e0295f191a69bd9a1f93efcdb0aff38684b61ab5750468972e5f5"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.5.51"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75835f0c7bf681bfd05abe44e965760fea999a5286c6eb2d59883634fd02011a"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.5.49"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "clap_lex"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
[[package]] [[package]]
name = "cmake" name = "cmake"
version = "0.1.54" version = "0.1.54"
@@ -933,12 +838,6 @@ dependencies = [
"cc", "cc",
] ]
[[package]]
name = "colorchoice"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
[[package]] [[package]]
name = "console_error_panic_hook" name = "console_error_panic_hook"
version = "0.1.7" version = "0.1.7"
@@ -1037,12 +936,6 @@ dependencies = [
"cfg-if", "cfg-if",
] ]
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]] [[package]]
name = "crypto-bigint" name = "crypto-bigint"
version = "0.4.9" version = "0.4.9"
@@ -1096,7 +989,7 @@ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"strsim", "strsim",
"syn 2.0.106", "syn",
] ]
[[package]] [[package]]
@@ -1107,15 +1000,9 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81"
dependencies = [ dependencies = [
"darling_core", "darling_core",
"quote", "quote",
"syn 2.0.106", "syn",
] ]
[[package]]
name = "data-encoding"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476"
[[package]] [[package]]
name = "der" name = "der"
version = "0.6.1" version = "0.6.1"
@@ -1126,20 +1013,6 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "der-parser"
version = "9.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553"
dependencies = [
"asn1-rs",
"displaydoc",
"nom",
"num-bigint",
"num-traits",
"rusticata-macros",
]
[[package]] [[package]]
name = "deranged" name = "deranged"
version = "0.5.4" version = "0.5.4"
@@ -1177,7 +1050,7 @@ dependencies = [
"dsl_auto_type", "dsl_auto_type",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.106", "syn",
] ]
[[package]] [[package]]
@@ -1197,7 +1070,7 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe2444076b48641147115697648dc743c2c00b61adade0f01ce67133c7babe8c" checksum = "fe2444076b48641147115697648dc743c2c00b61adade0f01ce67133c7babe8c"
dependencies = [ dependencies = [
"syn 2.0.106", "syn",
] ]
[[package]] [[package]]
@@ -1219,7 +1092,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.106", "syn",
] ]
[[package]] [[package]]
@@ -1245,7 +1118,7 @@ dependencies = [
"heck", "heck",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.106", "syn",
] ]
[[package]] [[package]]
@@ -1301,15 +1174,6 @@ dependencies = [
"cfg-if", "cfg-if",
] ]
[[package]]
name = "envy"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f47e0157f2cb54f5ae1bd371b30a2ae4311e1c028f575cd4e81de7353215965"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.2"
@@ -1379,21 +1243,6 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]] [[package]]
name = "form_urlencoded" name = "form_urlencoded"
version = "1.2.2" version = "1.2.2"
@@ -1438,7 +1287,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.106", "syn",
] ]
[[package]] [[package]]
@@ -1568,17 +1417,6 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "half"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
dependencies = [
"cfg-if",
"crunchy",
"zerocopy",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.15.5" version = "0.15.5"
@@ -1959,7 +1797,6 @@ checksum = "529feb3e6769d234375c4cf1ee2ce713682b8e76538cb13f9fc23e1400a591e7"
dependencies = [ dependencies = [
"bytemuck", "bytemuck",
"byteorder-lite", "byteorder-lite",
"image-webp",
"moxcms", "moxcms",
"num-traits", "num-traits",
"png", "png",
@@ -1967,16 +1804,6 @@ dependencies = [
"zune-jpeg", "zune-jpeg",
] ]
[[package]]
name = "image-webp"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
dependencies = [
"byteorder-lite",
"quick-error",
]
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "2.11.4" version = "2.11.4"
@@ -1985,8 +1812,6 @@ checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5"
dependencies = [ dependencies = [
"equivalent", "equivalent",
"hashbrown 0.16.0", "hashbrown 0.16.0",
"serde",
"serde_core",
] ]
[[package]] [[package]]
@@ -2016,12 +1841,6 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]] [[package]]
name = "itertools" name = "itertools"
version = "0.13.0" version = "0.13.0"
@@ -2326,80 +2145,18 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "oid-registry"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9"
dependencies = [
"asn1-rs",
]
[[package]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.21.3" version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "openssl"
version = "0.10.74"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24ad14dd45412269e1a30f52ad8f0664f0f4f4a89ee8fe28c3b3527021ebb654"
dependencies = [
"bitflags",
"cfg-if",
"foreign-types",
"libc",
"once_cell",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]] [[package]]
name = "openssl-probe" name = "openssl-probe"
version = "0.1.6" version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
[[package]]
name = "openssl-sys"
version = "0.9.110"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a9f0075ba3c21b09f8e8b2026584b1d18d49388648f2fbbf3c97ea8deced8e2"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "ordered-float"
version = "2.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c"
dependencies = [
"num-traits",
]
[[package]] [[package]]
name = "outref" name = "outref"
version = "0.5.2" version = "0.5.2"
@@ -2417,59 +2174,6 @@ dependencies = [
"sha2", "sha2",
] ]
[[package]]
name = "papercrate"
version = "0.1.0"
dependencies = [
"anyhow",
"argon2",
"async-trait",
"aws-config",
"aws-credential-types",
"aws-sdk-s3",
"axum",
"axum-extra",
"base64 0.21.7",
"bytes",
"chrono",
"clap",
"diesel",
"diesel_migrations",
"dotenv",
"envy",
"futures-util",
"hex",
"http-body-util",
"hyper 1.7.0",
"image",
"jsonwebtoken",
"mime_guess",
"once_cell",
"pdfium-render",
"percent-encoding",
"quick-xml",
"rand 0.8.5",
"reqwest",
"serde",
"serde-aux",
"serde_bytes",
"serde_cbor_2",
"serde_json",
"sha2",
"tempfile",
"thiserror 1.0.69",
"tokio",
"tower 0.4.13",
"tower-http",
"tracing",
"tracing-subscriber",
"url",
"utoipa",
"uuid",
"webauthn-rs",
"webauthn-rs-core",
]
[[package]] [[package]]
name = "parking_lot" name = "parking_lot"
version = "0.12.5" version = "0.12.5"
@@ -2504,12 +2208,6 @@ dependencies = [
"subtle", "subtle",
] ]
[[package]]
name = "pastey"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
[[package]] [[package]]
name = "pdfium-render" name = "pdfium-render"
version = "0.8.35" version = "0.8.35"
@@ -2569,7 +2267,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.106", "syn",
] ]
[[package]] [[package]]
@@ -2661,31 +2359,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"syn 2.0.106", "syn",
]
[[package]]
name = "proc-macro-error"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
dependencies = [
"proc-macro-error-attr",
"proc-macro2",
"quote",
"syn 1.0.109",
"version_check",
]
[[package]]
name = "proc-macro-error-attr"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
dependencies = [
"proc-macro2",
"quote",
"version_check",
] ]
[[package]] [[package]]
@@ -2706,12 +2380,6 @@ dependencies = [
"num-traits", "num-traits",
] ]
[[package]]
name = "quick-error"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.32.0" version = "0.32.0"
@@ -2992,15 +2660,6 @@ dependencies = [
"semver", "semver",
] ]
[[package]]
name = "rusticata-macros"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
dependencies = [
"nom",
]
[[package]] [[package]]
name = "rustix" name = "rustix"
version = "1.1.2" version = "1.1.2"
@@ -3218,48 +2877,6 @@ dependencies = [
"serde_derive", "serde_derive",
] ]
[[package]]
name = "serde-aux"
version = "4.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "207f67b28fe90fb596503a9bf0bf1ea5e831e21307658e177c5dfcdfc3ab8a0a"
dependencies = [
"chrono",
"serde",
"serde-value",
"serde_json",
]
[[package]]
name = "serde-value"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c"
dependencies = [
"ordered-float",
"serde",
]
[[package]]
name = "serde_bytes"
version = "0.11.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8"
dependencies = [
"serde",
"serde_core",
]
[[package]]
name = "serde_cbor_2"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34aec2709de9078e077090abd848e967abab63c9fb3fdb5d4799ad359d8d482c"
dependencies = [
"half",
"serde",
]
[[package]] [[package]]
name = "serde_core" name = "serde_core"
version = "1.0.228" version = "1.0.228"
@@ -3277,7 +2894,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.106", "syn",
] ]
[[package]] [[package]]
@@ -3465,16 +3082,6 @@ version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "1.0.109"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
dependencies = [
"proc-macro2",
"unicode-ident",
]
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.106" version = "2.0.106"
@@ -3503,7 +3110,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.106", "syn",
] ]
[[package]] [[package]]
@@ -3545,7 +3152,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.106", "syn",
] ]
[[package]] [[package]]
@@ -3556,7 +3163,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.106", "syn",
] ]
[[package]] [[package]]
@@ -3652,7 +3259,7 @@ checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.106", "syn",
] ]
[[package]] [[package]]
@@ -3802,7 +3409,7 @@ checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.106", "syn",
] ]
[[package]] [[package]]
@@ -3907,37 +3514,6 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "utoipa"
version = "4.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5afb1a60e207dca502682537fefcfd9921e71d0b83e9576060f09abc6efab23"
dependencies = [
"indexmap",
"serde",
"serde_json",
"utoipa-gen",
]
[[package]]
name = "utoipa-gen"
version = "4.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20c24e8ab68ff9ee746aad22d39b5535601e6416d1b0feeabf78be986a5c4392"
dependencies = [
"proc-macro-error",
"proc-macro2",
"quote",
"syn 2.0.106",
"uuid",
]
[[package]] [[package]]
name = "uuid" name = "uuid"
version = "1.18.1" version = "1.18.1"
@@ -4039,7 +3615,7 @@ dependencies = [
"log", "log",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.106", "syn",
"wasm-bindgen-shared", "wasm-bindgen-shared",
] ]
@@ -4074,7 +3650,7 @@ checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.106", "syn",
"wasm-bindgen-backend", "wasm-bindgen-backend",
"wasm-bindgen-shared", "wasm-bindgen-shared",
] ]
@@ -4121,74 +3697,6 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "webauthn-attestation-ca"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f77a2892ec44032e6c48dad9aad1b05fada09c346ada11d8d32db119b4b4f205"
dependencies = [
"base64urlsafedata",
"openssl",
"openssl-sys",
"serde",
"tracing",
"uuid",
]
[[package]]
name = "webauthn-rs"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb7c3a2f9c8bddd524e47bbd427bcf3a28aa074de55d74470b42a91a41937b8e"
dependencies = [
"base64urlsafedata",
"serde",
"tracing",
"url",
"uuid",
"webauthn-rs-core",
]
[[package]]
name = "webauthn-rs-core"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f1d80f3146382529fe70a3ab5d0feb2413a015204ed7843f9377cd39357fc4"
dependencies = [
"base64 0.21.7",
"base64urlsafedata",
"der-parser",
"hex",
"nom",
"openssl",
"openssl-sys",
"rand 0.8.5",
"rand_chacha 0.3.1",
"serde",
"serde_cbor_2",
"serde_json",
"thiserror 1.0.69",
"tracing",
"url",
"uuid",
"webauthn-attestation-ca",
"webauthn-rs-proto",
"x509-parser",
]
[[package]]
name = "webauthn-rs-proto"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e786894f89facb9aaf1c5f6559670236723c98382e045521c76f3d5ca5047bd"
dependencies = [
"base64 0.21.7",
"base64urlsafedata",
"serde",
"serde_json",
"url",
]
[[package]] [[package]]
name = "webpki-roots" name = "webpki-roots"
version = "1.0.3" version = "1.0.3"
@@ -4219,7 +3727,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.106", "syn",
] ]
[[package]] [[package]]
@@ -4230,7 +3738,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.106", "syn",
] ]
[[package]] [[package]]
@@ -4275,15 +3783,6 @@ dependencies = [
"windows-targets 0.52.6", "windows-targets 0.52.6",
] ]
[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets 0.53.5",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.61.2" version = "0.61.2"
@@ -4440,23 +3939,6 @@ version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb"
[[package]]
name = "x509-parser"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69"
dependencies = [
"asn1-rs",
"data-encoding",
"der-parser",
"lazy_static",
"nom",
"oid-registry",
"rusticata-macros",
"thiserror 1.0.69",
"time",
]
[[package]] [[package]]
name = "xmlparser" name = "xmlparser"
version = "0.13.6" version = "0.13.6"
@@ -4483,7 +3965,7 @@ checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.106", "syn",
"synstructure", "synstructure",
] ]
@@ -4504,7 +3986,7 @@ checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.106", "syn",
] ]
[[package]] [[package]]
@@ -4524,7 +4006,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.106", "syn",
"synstructure", "synstructure",
] ]
@@ -4564,7 +4046,7 @@ checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.106", "syn",
] ]
[[package]] [[package]]
+2 -30
View File
@@ -1,5 +1,5 @@
[package] [package]
name = "papercrate" name = "backend"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
@@ -25,8 +25,6 @@ aws-credential-types = "1.2"
# Serialization # Serialization
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
envy = "0.4"
serde-aux = "4.4"
# Utilities # Utilities
tracing = "0.1" tracing = "0.1"
@@ -36,7 +34,7 @@ sha2 = "0.10"
hex = "0.4" hex = "0.4"
bytes = "1.5" bytes = "1.5"
async-trait = "0.1" async-trait = "0.1"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] } image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
pdfium-render = "0.8" pdfium-render = "0.8"
mime_guess = "2.0" mime_guess = "2.0"
tempfile = "3.10" tempfile = "3.10"
@@ -46,9 +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"
utoipa = { version = "4.2", default-features = false, features = ["chrono", "uuid", "preserve_order"] }
clap = { version = "4.5", features = ["derive"] }
# Error handling # Error handling
thiserror = "1.0" thiserror = "1.0"
@@ -57,9 +52,6 @@ anyhow = "1.0"
# Authentication & security # Authentication & security
argon2 = "0.5" argon2 = "0.5"
jsonwebtoken = "9" jsonwebtoken = "9"
webauthn-rs = { version = "0.5", features = ["danger-allow-state-serialisation", "danger-credential-internals"] }
serde_bytes = "0.11"
serde_cbor_2 = "0.13"
# Misc # Misc
rand = "0.8" rand = "0.8"
@@ -68,23 +60,3 @@ rand = "0.8"
once_cell = "1.19" once_cell = "1.19"
hyper = "1.2" hyper = "1.2"
http-body-util = "0.1" http-body-util = "0.1"
webauthn-rs-core = "0.5"
[[bin]]
name = "backend"
path = "src/main.rs"
[[bin]]
name = "worker"
path = "src/bin/worker.rs"
[[bin]]
name = "webdav"
path = "src/bin/webdav.rs"
[[bin]]
name = "admin"
path = "src/bin/admin.rs"
[[bin]]
name = "openapi-dump"
path = "src/bin/openapi_dump.rs"
+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,25 +0,0 @@
DROP TRIGGER IF EXISTS trg_jobs_updated_at ON jobs;
DROP FUNCTION IF EXISTS touch_jobs_updated_at();
DROP TABLE IF EXISTS webauthn_challenges;
DROP TABLE IF EXISTS user_passkeys;
DROP TABLE IF EXISTS webdav_tokens;
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 TYPE IF EXISTS tenant_status;
DROP EXTENSION IF EXISTS "pgcrypto";
@@ -1,295 +0,0 @@
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE TYPE tenant_status AS ENUM ('creating', 'active', 'suspended', 'deleting', 'error');
CREATE TABLE tenants (
id UUID PRIMARY KEY,
name TEXT NOT NULL,
storage_root TEXT,
quickwit_index TEXT,
config JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
status tenant_status NOT NULL,
created_by UUID
);
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,
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,
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_tenant_parent_name_unique_idx
ON folders (
tenant_id,
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_tenant_folder_filename_unique
ON documents (
tenant_id,
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(),
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,
color VARCHAR(7),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
tenant_id UUID NOT NULL REFERENCES tenants(id)
);
CREATE UNIQUE INDEX tags_tenant_label_unique ON tags(tenant_id, label);
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)
);
CREATE UNIQUE INDEX correspondents_tenant_name_unique
ON correspondents (tenant_id, 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,
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)
);
CREATE INDEX idx_document_correspondents_document ON document_correspondents(document_id);
CREATE INDEX idx_document_correspondents_correspondent ON document_correspondents(correspondent_id);
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);
CREATE TABLE webdav_tokens (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
token_prefix TEXT NOT NULL,
token_hash TEXT NOT NULL,
label TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_used_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX webdav_tokens_token_prefix_key ON webdav_tokens(token_prefix);
CREATE INDEX webdav_tokens_user_tenant_idx ON webdav_tokens(user_id, tenant_id);
CREATE TABLE user_passkeys (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
credential_id BYTEA NOT NULL UNIQUE,
public_key BYTEA NOT NULL,
credential JSONB NOT NULL,
sign_count BIGINT NOT NULL,
transports TEXT[] NOT NULL DEFAULT '{}'::text[],
aaguid UUID,
nickname TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_used_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
revoked_by UUID,
revoked_reason TEXT
);
CREATE INDEX user_passkeys_user_id_idx ON user_passkeys(user_id);
CREATE TABLE webauthn_challenges (
id UUID PRIMARY KEY,
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
purpose TEXT NOT NULL,
challenge BYTEA NOT NULL,
state BYTEA NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
CONSTRAINT webauthn_challenges_purpose_check CHECK (purpose IN ('registration', 'authentication'))
);
CREATE INDEX webauthn_challenges_user_id_idx ON webauthn_challenges(user_id);
CREATE INDEX webauthn_challenges_expires_at_idx ON webauthn_challenges(expires_at);
@@ -1,2 +0,0 @@
ALTER TABLE documents
RENAME COLUMN created_at TO uploaded_at;
@@ -1,2 +0,0 @@
ALTER TABLE documents
RENAME COLUMN uploaded_at TO created_at;
@@ -1,2 +0,0 @@
DROP TABLE magic_tokens;
DROP TYPE magic_token_kind;
@@ -1,18 +0,0 @@
CREATE TYPE magic_token_kind AS ENUM ('email_login', 'demo_login');
CREATE TABLE magic_tokens (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
kind magic_token_kind NOT NULL,
token_hash VARCHAR NOT NULL UNIQUE,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
expires_at TIMESTAMPTZ NOT NULL,
max_uses INTEGER,
used_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
last_used_at TIMESTAMPTZ
);
CREATE INDEX magic_tokens_token_hash_idx ON magic_tokens (token_hash);
CREATE INDEX magic_tokens_expires_at_idx ON magic_tokens (expires_at);
@@ -1,28 +0,0 @@
-- Move tables and types back to the public schema
ALTER TABLE tenant.webdav_tokens SET SCHEMA public;
ALTER TABLE tenant.user_memberships SET SCHEMA public;
ALTER TABLE tenant.refresh_tokens SET SCHEMA public;
ALTER TABLE tenant.tags SET SCHEMA public;
ALTER TABLE tenant.document_correspondents SET SCHEMA public;
ALTER TABLE tenant.document_tags SET SCHEMA public;
ALTER TABLE tenant.document_asset_objects SET SCHEMA public;
ALTER TABLE tenant.document_assets SET SCHEMA public;
ALTER TABLE tenant.document_versions SET SCHEMA public;
ALTER TABLE tenant.documents SET SCHEMA public;
ALTER TABLE tenant.folders SET SCHEMA public;
ALTER TABLE tenant.correspondents SET SCHEMA public;
ALTER FUNCTION shared.touch_jobs_updated_at() SET SCHEMA public;
ALTER TABLE shared.magic_tokens SET SCHEMA public;
ALTER TABLE shared.jobs SET SCHEMA public;
ALTER TABLE shared.webauthn_challenges SET SCHEMA public;
ALTER TABLE shared.user_passkeys SET SCHEMA public;
ALTER TABLE shared.users SET SCHEMA public;
ALTER TABLE shared.tenants SET SCHEMA public;
ALTER TYPE shared.magic_token_kind SET SCHEMA public;
ALTER TYPE shared.tenant_status SET SCHEMA public;
DROP SCHEMA IF EXISTS tenant CASCADE;
DROP SCHEMA IF EXISTS shared CASCADE;
@@ -1,45 +0,0 @@
CREATE SCHEMA IF NOT EXISTS shared;
CREATE SCHEMA IF NOT EXISTS tenant;
-- Move global types and tables into the shared schema
ALTER TYPE tenant_status SET SCHEMA shared;
ALTER TYPE magic_token_kind SET SCHEMA shared;
ALTER TABLE tenants SET SCHEMA shared;
ALTER TABLE users SET SCHEMA shared;
ALTER TABLE user_passkeys SET SCHEMA shared;
ALTER TABLE webauthn_challenges SET SCHEMA shared;
ALTER TABLE jobs SET SCHEMA shared;
ALTER TABLE magic_tokens SET SCHEMA shared;
ALTER FUNCTION touch_jobs_updated_at() SET SCHEMA shared;
-- Move tenant-scoped tables into the tenant schema
ALTER TABLE correspondents SET SCHEMA tenant;
ALTER TABLE folders SET SCHEMA tenant;
ALTER TABLE documents SET SCHEMA tenant;
ALTER TABLE document_versions SET SCHEMA tenant;
ALTER TABLE document_assets SET SCHEMA tenant;
ALTER TABLE document_asset_objects SET SCHEMA tenant;
ALTER TABLE document_tags SET SCHEMA tenant;
ALTER TABLE document_correspondents SET SCHEMA tenant;
ALTER TABLE tags SET SCHEMA tenant;
ALTER TABLE refresh_tokens SET SCHEMA tenant;
ALTER TABLE user_memberships SET SCHEMA tenant;
ALTER TABLE webdav_tokens SET SCHEMA tenant;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'papercrate_app') THEN
RETURN;
END IF;
GRANT USAGE ON SCHEMA shared TO papercrate_app;
GRANT USAGE ON SCHEMA tenant TO papercrate_app;
GRANT SELECT ON ALL TABLES IN SCHEMA shared TO papercrate_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA tenant TO papercrate_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA shared GRANT SELECT ON TABLES TO papercrate_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA tenant GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO papercrate_app;
END
$$;
@@ -1,51 +0,0 @@
DROP POLICY IF EXISTS tenant_membership_select_policy ON tenant.user_memberships;
ALTER TABLE tenant.user_memberships NO FORCE ROW LEVEL SECURITY;
ALTER TABLE tenant.user_memberships DISABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.webdav_tokens;
ALTER TABLE tenant.webdav_tokens NO FORCE ROW LEVEL SECURITY;
ALTER TABLE tenant.webdav_tokens DISABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.tags;
ALTER TABLE tenant.tags NO FORCE ROW LEVEL SECURITY;
ALTER TABLE tenant.tags DISABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_refresh_token_policy ON tenant.refresh_tokens;
ALTER TABLE tenant.refresh_tokens NO FORCE ROW LEVEL SECURITY;
ALTER TABLE tenant.refresh_tokens DISABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.folders;
ALTER TABLE tenant.folders NO FORCE ROW LEVEL SECURITY;
ALTER TABLE tenant.folders DISABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.documents;
ALTER TABLE tenant.documents NO FORCE ROW LEVEL SECURITY;
ALTER TABLE tenant.documents DISABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.document_versions;
ALTER TABLE tenant.document_versions NO FORCE ROW LEVEL SECURITY;
ALTER TABLE tenant.document_versions DISABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.document_tags;
ALTER TABLE tenant.document_tags NO FORCE ROW LEVEL SECURITY;
ALTER TABLE tenant.document_tags DISABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.document_correspondents;
ALTER TABLE tenant.document_correspondents NO FORCE ROW LEVEL SECURITY;
ALTER TABLE tenant.document_correspondents DISABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.document_assets;
ALTER TABLE tenant.document_assets NO FORCE ROW LEVEL SECURITY;
ALTER TABLE tenant.document_assets DISABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.document_asset_objects;
ALTER TABLE tenant.document_asset_objects NO FORCE ROW LEVEL SECURITY;
ALTER TABLE tenant.document_asset_objects DISABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.correspondents;
ALTER TABLE tenant.correspondents NO FORCE ROW LEVEL SECURITY;
ALTER TABLE tenant.correspondents DISABLE ROW LEVEL SECURITY;
DROP FUNCTION IF EXISTS shared.current_refresh_token_hash();
DROP FUNCTION IF EXISTS shared.current_user_id();
DROP FUNCTION IF EXISTS shared.current_tenant_id();
@@ -1,69 +0,0 @@
CREATE OR REPLACE FUNCTION shared.current_tenant_id() RETURNS uuid AS $$
SELECT CASE
WHEN setting IS NULL OR setting = '' THEN NULL
ELSE setting::uuid
END
FROM (SELECT current_setting('papercrate.tenant_id', true) AS setting) s;
$$ LANGUAGE SQL STABLE;
CREATE OR REPLACE FUNCTION shared.current_user_id() RETURNS uuid AS $$
SELECT CASE
WHEN setting IS NULL OR setting = '' THEN NULL
ELSE setting::uuid
END
FROM (SELECT current_setting('papercrate.user_id', true) AS setting) s;
$$ LANGUAGE SQL STABLE;
CREATE OR REPLACE FUNCTION shared.current_refresh_token_hash() RETURNS text AS $$
SELECT NULLIF(current_setting('papercrate.refresh_token_hash', true), '')
$$ LANGUAGE SQL STABLE;
-- Helper to create tenant isolation policy
CREATE OR REPLACE FUNCTION shared.ensure_tenant_policy(table_reg regclass) RETURNS void AS $$
BEGIN
EXECUTE format('ALTER TABLE %s ENABLE ROW LEVEL SECURITY', table_reg);
EXECUTE format('ALTER TABLE %s FORCE ROW LEVEL SECURITY', table_reg);
EXECUTE format(
'CREATE POLICY tenant_isolation_policy ON %s USING (tenant_id = shared.current_tenant_id()) WITH CHECK (tenant_id = shared.current_tenant_id())',
table_reg
);
END;
$$ LANGUAGE plpgsql;
SELECT shared.ensure_tenant_policy('tenant.correspondents');
SELECT shared.ensure_tenant_policy('tenant.document_asset_objects');
SELECT shared.ensure_tenant_policy('tenant.document_assets');
SELECT shared.ensure_tenant_policy('tenant.document_correspondents');
SELECT shared.ensure_tenant_policy('tenant.document_tags');
SELECT shared.ensure_tenant_policy('tenant.document_versions');
SELECT shared.ensure_tenant_policy('tenant.documents');
SELECT shared.ensure_tenant_policy('tenant.folders');
SELECT shared.ensure_tenant_policy('tenant.tags');
SELECT shared.ensure_tenant_policy('tenant.webdav_tokens');
-- user_memberships has a special read policy to allow tenant discovery during login
ALTER TABLE tenant.user_memberships ENABLE ROW LEVEL SECURITY;
ALTER TABLE tenant.user_memberships FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_membership_select_policy ON tenant.user_memberships
USING (
tenant_id = shared.current_tenant_id()
OR (
shared.current_user_id() IS NOT NULL
AND user_id = shared.current_user_id()
)
)
WITH CHECK (tenant_id = shared.current_tenant_id());
ALTER TABLE tenant.refresh_tokens ENABLE ROW LEVEL SECURITY;
ALTER TABLE tenant.refresh_tokens FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_refresh_token_policy ON tenant.refresh_tokens
USING (
tenant_id = shared.current_tenant_id()
OR (
shared.current_refresh_token_hash() IS NOT NULL
AND token_hash = shared.current_refresh_token_hash()
)
)
WITH CHECK (tenant_id = shared.current_tenant_id());
DROP FUNCTION shared.ensure_tenant_policy(regclass);
@@ -1,5 +0,0 @@
DROP POLICY IF EXISTS tenant_webdav_token_policy ON tenant.webdav_tokens;
ALTER TABLE tenant.webdav_tokens NO FORCE ROW LEVEL SECURITY;
ALTER TABLE tenant.webdav_tokens DISABLE ROW LEVEL SECURITY;
DROP FUNCTION IF EXISTS shared.current_webdav_token_prefix();
@@ -1,15 +0,0 @@
CREATE OR REPLACE FUNCTION shared.current_webdav_token_prefix() RETURNS text AS $$
SELECT NULLIF(current_setting('papercrate.webdav_token_prefix', true), '')
$$ LANGUAGE SQL STABLE;
ALTER TABLE tenant.webdav_tokens ENABLE ROW LEVEL SECURITY;
ALTER TABLE tenant.webdav_tokens FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_webdav_token_policy ON tenant.webdav_tokens
USING (
tenant_id = shared.current_tenant_id()
OR (
shared.current_webdav_token_prefix() IS NOT NULL
AND token_prefix = shared.current_webdav_token_prefix()
)
)
WITH CHECK (tenant_id = shared.current_tenant_id());
@@ -1,31 +0,0 @@
DROP POLICY IF EXISTS tenant_api_token_policy ON tenant.api_tokens;
DROP FUNCTION IF EXISTS shared.current_api_token_prefix();
ALTER TABLE tenant.api_tokens DISABLE ROW LEVEL SECURITY;
ALTER TABLE tenant.api_tokens NO FORCE ROW LEVEL SECURITY;
ALTER TABLE tenant.api_tokens
DROP COLUMN IF EXISTS capabilities;
DROP TYPE IF EXISTS shared.api_token_capability;
ALTER TABLE tenant.api_tokens RENAME TO webdav_tokens;
ALTER INDEX tenant.api_tokens_token_prefix_key RENAME TO webdav_tokens_token_prefix_key;
ALTER INDEX tenant.api_tokens_user_tenant_idx RENAME TO webdav_tokens_user_tenant_idx;
CREATE OR REPLACE FUNCTION shared.current_webdav_token_prefix() RETURNS text AS $$
SELECT NULLIF(current_setting('papercrate.webdav_token_prefix', true), '')
$$ LANGUAGE SQL STABLE;
ALTER TABLE tenant.webdav_tokens ENABLE ROW LEVEL SECURITY;
ALTER TABLE tenant.webdav_tokens FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_webdav_token_policy ON tenant.webdav_tokens
USING (
tenant_id = shared.current_tenant_id()
OR (
shared.current_webdav_token_prefix() IS NOT NULL
AND token_prefix = shared.current_webdav_token_prefix()
)
)
WITH CHECK (tenant_id = shared.current_tenant_id());
@@ -1,28 +0,0 @@
ALTER TABLE tenant.webdav_tokens RENAME TO api_tokens;
ALTER INDEX tenant.webdav_tokens_token_prefix_key RENAME TO api_tokens_token_prefix_key;
ALTER INDEX tenant.webdav_tokens_user_tenant_idx RENAME TO api_tokens_user_tenant_idx;
DROP POLICY IF EXISTS tenant_webdav_token_policy ON tenant.api_tokens;
DROP FUNCTION IF EXISTS shared.current_webdav_token_prefix();
CREATE TYPE shared.api_token_capability AS ENUM ('api', 'webdav');
ALTER TABLE tenant.api_tokens
ADD COLUMN capabilities shared.api_token_capability[] NOT NULL DEFAULT ARRAY['webdav']::shared.api_token_capability[];
ALTER TABLE tenant.api_tokens ENABLE ROW LEVEL SECURITY;
ALTER TABLE tenant.api_tokens FORCE ROW LEVEL SECURITY;
CREATE OR REPLACE FUNCTION shared.current_api_token_prefix() RETURNS text AS $$
SELECT NULLIF(current_setting('papercrate.api_token_prefix', true), '')
$$ LANGUAGE SQL STABLE;
CREATE POLICY tenant_api_token_policy ON tenant.api_tokens
USING (
tenant_id = shared.current_tenant_id()
OR (
shared.current_api_token_prefix() IS NOT NULL
AND token_prefix = shared.current_api_token_prefix()
)
)
WITH CHECK (tenant_id = shared.current_tenant_id());
@@ -1,26 +0,0 @@
CREATE OR REPLACE FUNCTION shared.current_refresh_token_hash() RETURNS text AS $$
SELECT NULLIF(current_setting('papercrate.refresh_token_hash', true), '')
$$ LANGUAGE SQL STABLE;
ALTER POLICY tenant_user_session_policy ON tenant.user_sessions
USING (
tenant_id = shared.current_tenant_id()
OR (
shared.current_refresh_token_hash() IS NOT NULL
AND token_hash = shared.current_refresh_token_hash()
)
);
ALTER POLICY tenant_user_session_policy ON tenant.user_sessions
WITH CHECK (tenant_id = shared.current_tenant_id());
ALTER POLICY tenant_user_session_policy ON tenant.user_sessions
RENAME TO tenant_refresh_token_policy;
ALTER INDEX tenant.idx_user_sessions_user_id RENAME TO idx_refresh_tokens_user_id;
ALTER INDEX tenant.idx_user_sessions_token_hash RENAME TO idx_refresh_tokens_token_hash;
ALTER INDEX tenant.user_sessions_tenant_id_idx RENAME TO refresh_tokens_tenant_id_idx;
ALTER TABLE tenant.user_sessions RENAME TO refresh_tokens;
DROP FUNCTION IF EXISTS shared.current_user_session_hash();
@@ -1,26 +0,0 @@
ALTER TABLE tenant.refresh_tokens RENAME TO user_sessions;
ALTER INDEX tenant.idx_refresh_tokens_user_id RENAME TO idx_user_sessions_user_id;
ALTER INDEX tenant.idx_refresh_tokens_token_hash RENAME TO idx_user_sessions_token_hash;
ALTER INDEX tenant.refresh_tokens_tenant_id_idx RENAME TO user_sessions_tenant_id_idx;
ALTER POLICY tenant_refresh_token_policy ON tenant.user_sessions
RENAME TO tenant_user_session_policy;
CREATE OR REPLACE FUNCTION shared.current_user_session_hash() RETURNS text AS $$
SELECT NULLIF(current_setting('papercrate.user_session_hash', true), '')
$$ LANGUAGE SQL STABLE;
ALTER POLICY tenant_user_session_policy ON tenant.user_sessions
USING (
tenant_id = shared.current_tenant_id()
OR (
shared.current_user_session_hash() IS NOT NULL
AND token_hash = shared.current_user_session_hash()
)
);
ALTER POLICY tenant_user_session_policy ON tenant.user_sessions
WITH CHECK (tenant_id = shared.current_tenant_id());
DROP FUNCTION IF EXISTS shared.current_refresh_token_hash();
@@ -1,28 +0,0 @@
CREATE TYPE api_token_capability AS ENUM ('api', 'webdav');
ALTER TABLE tenant.api_tokens
ADD COLUMN capabilities api_token_capability[] NOT NULL DEFAULT ARRAY[]::api_token_capability[];
UPDATE tenant.api_tokens t
SET capabilities = ARRAY['api']::api_token_capability[]
FROM tenant.capability_sets cs
WHERE t.capability_set_id = cs.id
AND cs.slug = 'owner';
UPDATE tenant.api_tokens t
SET capabilities = ARRAY['webdav']::api_token_capability[]
FROM tenant.capability_sets cs
WHERE t.capability_set_id = cs.id
AND cs.slug = 'webdav'
AND (t.capabilities IS NULL OR array_length(t.capabilities, 1) = 0);
ALTER TABLE tenant.api_tokens
DROP COLUMN capability_set_id;
ALTER TABLE tenant.user_memberships
DROP COLUMN capability_set_id;
DROP TABLE IF EXISTS tenant.capability_set_capabilities;
DROP TABLE IF EXISTS tenant.capability_sets;
DROP TYPE IF EXISTS api_capability;
@@ -1,158 +0,0 @@
CREATE TYPE api_capability AS ENUM (
'documents:read',
'documents:edit',
'documents:write',
'documents:upload',
'folders:read',
'folders:edit',
'folders:write',
'tags:read',
'tags:edit',
'tags:write',
'correspondents:read',
'correspondents:edit',
'correspondents:write',
'profile:read',
'profile:write',
'webdav:read',
'webdav:write',
'capability_sets:read',
'capability_sets:write'
);
CREATE TABLE tenant.capability_sets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES shared.tenants(id) ON DELETE CASCADE,
slug TEXT NOT NULL,
cap_version INT NOT NULL DEFAULT 1,
is_system BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (tenant_id, slug)
);
CREATE TABLE tenant.capability_set_capabilities (
capability_set_id UUID NOT NULL REFERENCES tenant.capability_sets(id) ON DELETE CASCADE,
capability api_capability NOT NULL,
PRIMARY KEY (capability_set_id, capability)
);
ALTER TABLE tenant.api_tokens
ADD COLUMN capability_set_id UUID REFERENCES tenant.capability_sets(id);
ALTER TABLE tenant.user_memberships
ADD COLUMN capability_set_id UUID REFERENCES tenant.capability_sets(id);
WITH owner_sets AS (
INSERT INTO tenant.capability_sets (tenant_id, slug, is_system)
SELECT id, 'owner', TRUE
FROM shared.tenants
RETURNING id, tenant_id
),
user_sets AS (
INSERT INTO tenant.capability_sets (tenant_id, slug, is_system)
SELECT id, 'user', TRUE
FROM shared.tenants
RETURNING id, tenant_id
),
readonly_sets AS (
INSERT INTO tenant.capability_sets (tenant_id, slug, is_system)
SELECT id, 'readonly', TRUE
FROM shared.tenants
RETURNING id, tenant_id
),
webdav_sets AS (
INSERT INTO tenant.capability_sets (tenant_id, slug, is_system)
SELECT id, 'webdav', TRUE
FROM shared.tenants
RETURNING id, tenant_id
)
INSERT INTO tenant.capability_set_capabilities (capability_set_id, capability)
SELECT set_id,
capability
FROM (
SELECT os.id AS set_id,
UNNEST(ARRAY[
'documents:read'::api_capability,
'documents:edit'::api_capability,
'documents:write'::api_capability,
'documents:upload'::api_capability,
'folders:read'::api_capability,
'folders:edit'::api_capability,
'folders:write'::api_capability,
'tags:read'::api_capability,
'tags:edit'::api_capability,
'tags:write'::api_capability,
'correspondents:read'::api_capability,
'correspondents:edit'::api_capability,
'correspondents:write'::api_capability,
'profile:read'::api_capability,
'profile:write'::api_capability,
'webdav:read'::api_capability,
'webdav:write'::api_capability,
'capability_sets:read'::api_capability,
'capability_sets:write'::api_capability
]) AS capability
FROM owner_sets os
UNION ALL
SELECT us.id,
UNNEST(ARRAY[
'documents:read'::api_capability,
'documents:edit'::api_capability,
'documents:write'::api_capability,
'documents:upload'::api_capability,
'folders:read'::api_capability,
'folders:edit'::api_capability,
'folders:write'::api_capability,
'tags:read'::api_capability,
'tags:edit'::api_capability,
'tags:write'::api_capability,
'correspondents:read'::api_capability,
'correspondents:edit'::api_capability,
'correspondents:write'::api_capability,
'profile:read'::api_capability,
'profile:write'::api_capability
]) AS capability
FROM user_sets us
UNION ALL
SELECT rs.id,
UNNEST(ARRAY[
'documents:read'::api_capability,
'folders:read'::api_capability,
'tags:read'::api_capability,
'correspondents:read'::api_capability,
'webdav:read'::api_capability
]) AS capability
FROM readonly_sets rs
UNION ALL
SELECT ws.id,
UNNEST(ARRAY['webdav:read'::api_capability]) AS capability
FROM webdav_sets ws
) seeded;
UPDATE tenant.user_memberships um
SET capability_set_id = cs.id
FROM tenant.capability_sets cs
WHERE cs.tenant_id = um.tenant_id
AND cs.slug = 'owner';
UPDATE tenant.api_tokens t
SET capability_set_id = cs.id
FROM tenant.capability_sets cs
WHERE cs.tenant_id = t.tenant_id
AND cs.slug = 'owner';
UPDATE tenant.api_tokens t
SET capability_set_id = cs.id
FROM tenant.capability_sets cs
WHERE cs.tenant_id = t.tenant_id
AND cs.slug = 'webdav'
AND t.capability_set_id IS NULL;
ALTER TABLE tenant.api_tokens
ALTER COLUMN capability_set_id SET NOT NULL;
ALTER TABLE tenant.api_tokens
DROP COLUMN capabilities;
DROP TYPE IF EXISTS api_token_capability;
@@ -1 +0,0 @@
DROP INDEX IF EXISTS shared.jobs_purge_document_pending_unique;
@@ -1,8 +0,0 @@
CREATE UNIQUE INDEX jobs_purge_document_pending_unique
ON shared.jobs (
tenant_id,
((payload ->> 'document_id')::uuid)
)
WHERE job_type = 'purge-document'
AND payload ? 'document_id'
AND status IN ('queued', 'processing');
@@ -1,5 +0,0 @@
DROP INDEX IF EXISTS tenant.idx_documents_tenant_folder_updated_at;
DROP INDEX IF EXISTS tenant.idx_documents_tenant_folder_created_at;
DROP INDEX IF EXISTS tenant.idx_documents_tenant_folder_issued_at;
DROP INDEX IF EXISTS tenant.idx_documents_tenant_folder_title_order;
DROP COLLATION IF EXISTS unicode_ci;
@@ -1,37 +0,0 @@
CREATE COLLATION IF NOT EXISTS unicode_ci
(provider = icu, locale = 'und-u-ks-level2');
CREATE INDEX idx_documents_tenant_folder_title_order
ON tenant.documents (
tenant_id,
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
title COLLATE "unicode_ci"
)
WHERE deleted_at IS NULL;
CREATE INDEX idx_documents_tenant_folder_issued_at
ON tenant.documents (
tenant_id,
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
issued_at,
title COLLATE "unicode_ci"
)
WHERE deleted_at IS NULL;
CREATE INDEX idx_documents_tenant_folder_created_at
ON tenant.documents (
tenant_id,
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
created_at,
title COLLATE "unicode_ci"
)
WHERE deleted_at IS NULL;
CREATE INDEX idx_documents_tenant_folder_updated_at
ON tenant.documents (
tenant_id,
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
updated_at,
title COLLATE "unicode_ci"
)
WHERE deleted_at IS NULL;
@@ -1,21 +0,0 @@
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'papercrate_app') THEN
CREATE ROLE papercrate_app NOLOGIN;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'papercrate_app_login') THEN
CREATE ROLE papercrate_app_login LOGIN PASSWORD 'papercrate_app';
GRANT papercrate_app TO papercrate_app_login;
END IF;
END
$$;
-- Ensure the login role inherits and uses a sensible search path by default
ALTER ROLE papercrate_app_login INHERIT;
ALTER ROLE papercrate_app_login SET search_path = 'tenant, shared, public';
GRANT CONNECT ON DATABASE papercrate TO papercrate_app;
GRANT CONNECT ON DATABASE papercrate TO papercrate_app_login;
GRANT USAGE ON SCHEMA public TO papercrate_app;
GRANT USAGE ON SCHEMA public TO papercrate_app_login;
-324
View File
@@ -1,324 +0,0 @@
use argon2::{
password_hash::{PasswordHasher, SaltString},
Argon2,
};
use chrono::{NaiveDateTime, Utc};
use diesel::prelude::*;
use rand::rngs::OsRng;
use rand::RngCore;
use uuid::Uuid;
use crate::{
auth::capability_sets::{load_capabilities_for_set, load_capability_set},
error::AppError,
models::{ApiCapability, ApiToken, CapabilitySet, NewApiToken},
schema::api_tokens,
state::PgPooledConnection,
tenants::{apply_api_token_prefix, clear_api_token_prefix},
};
use crate::schema::api_tokens::dsl as api_tokens_dsl;
const TOKEN_PREFIX_LENGTH: usize = 12;
const TOKEN_SECRET_LENGTH: usize = 32;
/// Represents a newly issued API token and the raw secret that was generated for it.
pub struct IssuedApiToken {
pub token: String,
pub record: ApiToken,
}
/// Creates a new API token for the supplied user/tenant combination.
pub fn create_api_token(
conn: &mut PgPooledConnection,
user_id: Uuid,
tenant_id: Uuid,
label: Option<String>,
expires_at: Option<NaiveDateTime>,
capability_set_id: Uuid,
) -> Result<IssuedApiToken, AppError> {
let capability_set =
validate_capability_set_belongs_to_tenant(conn, capability_set_id, tenant_id)?;
let raw_secret = generate_secret()?;
let token_prefix = raw_secret[..TOKEN_PREFIX_LENGTH].to_string();
let token_hash = hash_secret(&raw_secret)?;
let new_token = NewApiToken {
id: Uuid::new_v4(),
user_id,
tenant_id,
token_prefix,
token_hash,
label,
expires_at,
capability_set_id: capability_set.id,
};
let record = diesel::insert_into(api_tokens::table)
.values(&new_token)
.get_result::<ApiToken>(conn)?;
Ok(IssuedApiToken {
token: raw_secret,
record,
})
}
/// Lists API tokens belonging to a user within an optional tenant scope.
pub fn list_api_tokens(
conn: &mut PgPooledConnection,
user_id: Uuid,
tenant_id: Option<Uuid>,
) -> Result<Vec<ApiToken>, AppError> {
let mut query = api_tokens::table
.filter(api_tokens::user_id.eq(user_id))
.into_boxed();
if let Some(tenant_id) = tenant_id {
query = query.filter(api_tokens::tenant_id.eq(tenant_id));
}
let tokens = query
.order(api_tokens::created_at.asc())
.load::<ApiToken>(conn)?;
Ok(tokens)
}
/// Regenerates the secret value for an API token.
pub fn regenerate_api_token(
conn: &mut PgPooledConnection,
token_id: Uuid,
user_id: Uuid,
tenant_id: Option<Uuid>,
) -> Result<IssuedApiToken, AppError> {
let record = find_user_token(conn, token_id, user_id, tenant_id)?;
if record.revoked_at.is_some() {
return Err(AppError::bad_request(
"cannot regenerate a revoked API token",
));
}
let raw_secret = generate_secret()?;
let token_prefix = raw_secret[..TOKEN_PREFIX_LENGTH].to_string();
let token_hash = hash_secret(&raw_secret)?;
let updated = diesel::update(api_tokens::table.find(record.id))
.set((
api_tokens::token_prefix.eq(&token_prefix),
api_tokens::token_hash.eq(&token_hash),
api_tokens::last_used_at.eq::<Option<NaiveDateTime>>(None),
))
.get_result::<ApiToken>(conn)?;
Ok(IssuedApiToken {
token: raw_secret,
record: updated,
})
}
/// Attempts to resolve an API token by its secret value while ensuring it provides the
/// requested capability.
pub fn find_active_token_by_secret(
conn: &mut PgPooledConnection,
tenant_id: Option<Uuid>,
secret: &str,
required_capability: Option<ApiCapability>,
) -> Result<Option<ApiToken>, AppError> {
if secret.len() < TOKEN_PREFIX_LENGTH {
return Ok(None);
}
let prefix = &secret[..TOKEN_PREFIX_LENGTH];
let candidates = with_api_token_prefix(conn, prefix, |conn| {
let mut query = api_tokens::table
.filter(api_tokens::token_prefix.eq(prefix))
.filter(api_tokens::revoked_at.is_null())
.into_boxed();
let now = Utc::now().naive_utc();
query = query.filter(
api_tokens::expires_at
.is_null()
.or(api_tokens::expires_at.gt(now)),
);
if let Some(tenant_id) = tenant_id {
query = query.filter(api_tokens::tenant_id.eq(tenant_id));
}
query.load::<ApiToken>(conn).map_err(AppError::from)
})?;
for token in candidates {
if let Some(required) = required_capability {
let capabilities = load_capabilities_for_set(conn, token.capability_set_id)?;
if !capabilities.contains(&required) {
continue;
}
}
if verify_token_secret(secret, &token.token_hash)? {
return Ok(Some(token));
}
}
Ok(None)
}
/// Revokes an API token belonging to the specified user.
pub fn revoke_api_token(
conn: &mut PgPooledConnection,
token_id: Uuid,
user_id: Uuid,
) -> Result<(), AppError> {
let token = find_user_token(conn, token_id, user_id, None)?;
diesel::update(api_tokens::table.find(token.id))
.set(api_tokens::revoked_at.eq(Utc::now().naive_utc()))
.execute(conn)?;
Ok(())
}
/// Updates the last-used timestamp for a token.
pub fn touch_api_token(conn: &mut PgPooledConnection, token_id: Uuid) -> Result<(), AppError> {
diesel::update(api_tokens::table.filter(api_tokens::id.eq(token_id)))
.set(api_tokens::last_used_at.eq(Utc::now().naive_utc()))
.execute(conn)?;
Ok(())
}
/// Verifies a secret against its stored hash representation.
pub fn verify_token_secret(secret: &str, token_hash: &str) -> Result<bool, AppError> {
crate::auth::password::verify_password(secret, token_hash).map_err(|err| {
tracing::error!(error = ?err, "failed to verify token");
AppError::internal("failed to verify token")
})
}
fn find_user_token(
conn: &mut PgPooledConnection,
token_id: Uuid,
user_id: Uuid,
tenant_id: Option<Uuid>,
) -> Result<ApiToken, AppError> {
let mut query = api_tokens_dsl::api_tokens
.filter(api_tokens_dsl::id.eq(token_id))
.filter(api_tokens_dsl::user_id.eq(user_id))
.into_boxed();
if let Some(tid) = tenant_id {
query = query.filter(api_tokens_dsl::tenant_id.eq(tid));
}
query
.first::<ApiToken>(conn)
.optional()
.map_err(AppError::from)?
.ok_or_else(AppError::not_found)
}
fn validate_capability_set_belongs_to_tenant(
conn: &mut PgPooledConnection,
capability_set_id: Uuid,
tenant_id: Uuid,
) -> Result<CapabilitySet, AppError> {
let capability_set = load_capability_set(conn, capability_set_id)?;
if capability_set.tenant_id != tenant_id {
return Err(AppError::bad_request(
"capability set does not belong to the tenant",
));
}
Ok(capability_set)
}
fn with_api_token_prefix<T, F>(
conn: &mut PgPooledConnection,
prefix: &str,
operation: F,
) -> Result<T, AppError>
where
F: FnOnce(&mut PgPooledConnection) -> Result<T, AppError>,
{
apply_api_token_prefix(conn, prefix)?;
let operation_result = operation(conn);
let clear_result = clear_api_token_prefix(conn);
if let Err(err) = clear_result {
return Err(err);
}
operation_result
}
fn generate_secret() -> Result<String, AppError> {
let mut buffer = [0u8; TOKEN_SECRET_LENGTH];
OsRng.try_fill_bytes(&mut buffer).map_err(|err| {
tracing::error!(error = ?err, "failed to generate token");
AppError::internal("failed to generate token")
})?;
Ok(hex::encode(buffer))
}
fn hash_secret(secret: &str) -> Result<String, AppError> {
let salt = SaltString::generate(&mut OsRng);
let hash = Argon2::default()
.hash_password(secret.as_bytes(), &salt)
.map_err(|err| {
tracing::error!(error = ?err, "failed to hash token");
AppError::internal("failed to hash token")
})?;
Ok(hash.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::auth::capability_sets::{
compute_slug, normalize_capabilities, owner_capabilities, webdav_capabilities,
};
#[test]
fn generated_secret_has_expected_length() {
let secret = generate_secret().unwrap();
assert_eq!(secret.len(), TOKEN_SECRET_LENGTH * 2);
}
#[test]
fn hash_and_verify_secret_round_trip() {
let secret = generate_secret().unwrap();
let hash = hash_secret(&secret).unwrap();
assert!(verify_token_secret(&secret, &hash).unwrap());
assert!(!verify_token_secret("wrong", &hash).unwrap());
}
#[test]
fn normalize_capabilities_deduplicates() {
let mut caps = owner_capabilities().to_vec();
caps.push(ApiCapability::DocumentsRead);
let normalized = normalize_capabilities(caps).unwrap();
assert_eq!(normalized.len(), owner_capabilities().len());
}
#[test]
fn normalize_capabilities_rejects_empty() {
assert!(normalize_capabilities(Vec::new()).is_err());
}
#[test]
fn compute_slug_matches_system_sets() {
let owner_slug = compute_slug(owner_capabilities());
assert_eq!(owner_slug, "owner");
let webdav_slug = compute_slug(webdav_capabilities());
assert_eq!(webdav_slug, "webdav");
}
#[test]
fn prefix_length_is_less_than_secret_length() {
assert!(TOKEN_PREFIX_LENGTH < TOKEN_SECRET_LENGTH * 2);
}
}
-120
View File
@@ -1,120 +0,0 @@
use std::sync::Arc;
use axum::{
http::{Request, StatusCode},
response::IntoResponse,
};
use tower::{Layer, Service};
use crate::{auth::AuthenticatedUser, error::AppError, models::ApiCapability};
#[derive(Clone, Copy)]
pub enum CapabilityStrategy {
All,
Any,
}
#[derive(Clone)]
pub struct RequireCapabilitiesLayer {
required: Arc<Vec<ApiCapability>>,
strategy: CapabilityStrategy,
}
impl RequireCapabilitiesLayer {
pub fn all<I>(caps: I) -> Self
where
I: IntoIterator<Item = ApiCapability>,
{
Self {
required: Arc::new(caps.into_iter().collect()),
strategy: CapabilityStrategy::All,
}
}
pub fn any<I>(caps: I) -> Self
where
I: IntoIterator<Item = ApiCapability>,
{
Self {
required: Arc::new(caps.into_iter().collect()),
strategy: CapabilityStrategy::Any,
}
}
}
impl<S> Layer<S> for RequireCapabilitiesLayer {
type Service = RequireCapabilities<S>;
fn layer(&self, inner: S) -> Self::Service {
RequireCapabilities {
inner,
required: Arc::clone(&self.required),
strategy: self.strategy,
}
}
}
#[derive(Clone)]
pub struct RequireCapabilities<S> {
inner: S,
required: Arc<Vec<ApiCapability>>,
strategy: CapabilityStrategy,
}
impl<S, B> Service<Request<B>> for RequireCapabilities<S>
where
S: Service<Request<B>, Response = axum::response::Response> + Send,
S::Future: Send + 'static,
B: Send + 'static,
{
type Response = S::Response;
type Error = S::Error;
type Future = std::pin::Pin<
Box<dyn std::future::Future<Output = Result<Self::Response, Self::Error>> + Send>,
>;
fn poll_ready(
&mut self,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, req: Request<B>) -> Self::Future {
if self.required.is_empty() {
let fut = self.inner.call(req);
return Box::pin(async move { fut.await });
}
let (parts, body) = req.into_parts();
let user = match parts.extensions.get::<AuthenticatedUser>() {
Some(user) => user,
None => {
let response = AppError::unauthorized().into_response();
return Box::pin(async move { Ok(response) });
}
};
let allowed = match self.strategy {
CapabilityStrategy::All => self
.required
.iter()
.all(|cap| user.capabilities.contains(cap)),
CapabilityStrategy::Any => self
.required
.iter()
.any(|cap| user.capabilities.contains(cap)),
};
if !allowed {
let response = AppError::new(StatusCode::FORBIDDEN, "missing required capability")
.with_code("missing_capability")
.into_response();
return Box::pin(async move { Ok(response) });
}
let req = Request::from_parts(parts, body);
let fut = self.inner.call(req);
Box::pin(async move { fut.await })
}
}
-320
View File
@@ -1,320 +0,0 @@
use chrono::Utc;
use diesel::{pg::Pg, prelude::*, Connection};
use sha2::{Digest, Sha256};
use uuid::Uuid;
use crate::{
error::AppError,
models::{ApiCapability, CapabilitySet, NewCapabilitySet, NewCapabilitySetCapability},
schema::{
capability_set_capabilities, capability_set_capabilities::dsl as csc_dsl, capability_sets,
capability_sets::dsl as cs_dsl,
},
};
const OWNER_CAPABILITIES: [ApiCapability; 19] = [
ApiCapability::CorrespondentsEdit,
ApiCapability::CorrespondentsRead,
ApiCapability::CorrespondentsWrite,
ApiCapability::DocumentsEdit,
ApiCapability::DocumentsRead,
ApiCapability::DocumentsUpload,
ApiCapability::DocumentsWrite,
ApiCapability::FoldersEdit,
ApiCapability::FoldersRead,
ApiCapability::FoldersWrite,
ApiCapability::ProfileRead,
ApiCapability::ProfileWrite,
ApiCapability::TagsEdit,
ApiCapability::TagsRead,
ApiCapability::TagsWrite,
ApiCapability::WebdavRead,
ApiCapability::WebdavWrite,
ApiCapability::CapabilitySetsRead,
ApiCapability::CapabilitySetsWrite,
];
const USER_CAPABILITIES: [ApiCapability; 16] = [
ApiCapability::CorrespondentsEdit,
ApiCapability::CorrespondentsRead,
ApiCapability::CorrespondentsWrite,
ApiCapability::DocumentsEdit,
ApiCapability::DocumentsRead,
ApiCapability::DocumentsUpload,
ApiCapability::DocumentsWrite,
ApiCapability::FoldersEdit,
ApiCapability::FoldersRead,
ApiCapability::FoldersWrite,
ApiCapability::ProfileRead,
ApiCapability::ProfileWrite,
ApiCapability::TagsEdit,
ApiCapability::TagsRead,
ApiCapability::TagsWrite,
ApiCapability::WebdavRead,
];
const READONLY_CAPABILITIES: [ApiCapability; 5] = [
ApiCapability::CorrespondentsRead,
ApiCapability::DocumentsRead,
ApiCapability::FoldersRead,
ApiCapability::TagsRead,
ApiCapability::WebdavRead,
];
const WEBDAV_CAPABILITIES: [ApiCapability; 1] = [ApiCapability::WebdavRead];
pub fn owner_capabilities() -> &'static [ApiCapability] {
&OWNER_CAPABILITIES
}
pub fn user_capabilities() -> &'static [ApiCapability] {
&USER_CAPABILITIES
}
pub fn readonly_capabilities() -> &'static [ApiCapability] {
&READONLY_CAPABILITIES
}
pub fn webdav_capabilities() -> &'static [ApiCapability] {
&WEBDAV_CAPABILITIES
}
pub fn is_system_slug(slug: &str) -> bool {
matches!(slug, "owner" | "user" | "readonly" | "webdav")
}
pub fn create_capability_set<C>(
conn: &mut C,
tenant_id: Uuid,
slug: &str,
capabilities: Vec<ApiCapability>,
) -> Result<CapabilitySet, AppError>
where
C: Connection<Backend = Pg> + diesel::connection::LoadConnection,
{
let normalized = normalize_capabilities(capabilities)?;
conn.transaction::<CapabilitySet, AppError, _>(|conn| {
if cs_dsl::capability_sets
.filter(cs_dsl::tenant_id.eq(tenant_id))
.filter(cs_dsl::slug.eq(slug))
.first::<CapabilitySet>(conn)
.optional()
.map_err(AppError::from)?
.is_some()
{
return Err(AppError::conflict("capability set slug already exists"));
}
let set = NewCapabilitySet {
id: Uuid::new_v4(),
tenant_id,
slug: slug.to_owned(),
cap_version: 1,
is_system: false,
};
diesel::insert_into(capability_sets::table)
.values(&set)
.execute(conn)
.map_err(AppError::from)?;
persist_capabilities(conn, set.id, &normalized)?;
capability_sets::table
.find(set.id)
.first::<CapabilitySet>(conn)
.map_err(AppError::from)
})
}
pub fn normalize_capabilities(
mut capabilities: Vec<ApiCapability>,
) -> Result<Vec<ApiCapability>, AppError> {
if capabilities.is_empty() {
return Err(AppError::bad_request("at least one capability is required"));
}
capabilities.sort_by(|a, b| a.as_str().cmp(b.as_str()));
capabilities.dedup();
Ok(capabilities)
}
pub fn load_capabilities_for_set<C>(
conn: &mut C,
capability_set_id: Uuid,
) -> Result<Vec<ApiCapability>, AppError>
where
C: Connection<Backend = Pg> + diesel::connection::LoadConnection,
{
let mut capabilities: Vec<ApiCapability> = csc_dsl::capability_set_capabilities
.filter(csc_dsl::capability_set_id.eq(capability_set_id))
.select(csc_dsl::capability)
.load(conn)
.map_err(AppError::from)?;
capabilities.sort_by(|a, b| a.as_str().cmp(b.as_str()));
Ok(capabilities)
}
pub fn ensure_capability_set<C>(
conn: &mut C,
tenant_id: Uuid,
capabilities: &[ApiCapability],
) -> Result<CapabilitySet, AppError>
where
C: Connection<Backend = Pg> + diesel::connection::LoadConnection,
{
if capabilities.is_empty() {
return Err(AppError::bad_request("at least one capability is required"));
}
let slug = compute_slug(capabilities);
conn.transaction::<CapabilitySet, AppError, _>(|conn| {
if let Some(existing) = cs_dsl::capability_sets
.filter(cs_dsl::tenant_id.eq(tenant_id))
.filter(cs_dsl::slug.eq(&slug))
.first::<CapabilitySet>(conn)
.optional()
.map_err(AppError::from)?
{
ensure_capability_membership(conn, &existing, capabilities)?;
return Ok(existing);
}
let set = NewCapabilitySet {
id: Uuid::new_v4(),
tenant_id,
slug: slug.clone(),
cap_version: 1,
is_system: matches!(slug.as_str(), "owner" | "user" | "readonly" | "webdav"),
};
diesel::insert_into(capability_sets::table)
.values(&set)
.execute(conn)
.map_err(AppError::from)?;
persist_capabilities(conn, set.id, capabilities)?;
Ok(capability_sets::table
.find(set.id)
.first::<CapabilitySet>(conn)
.map_err(AppError::from)?)
})
}
pub fn refresh_capability_set<C>(
conn: &mut C,
set: &CapabilitySet,
capabilities: &[ApiCapability],
) -> Result<CapabilitySet, AppError>
where
C: Connection<Backend = Pg> + diesel::connection::LoadConnection,
{
conn.transaction::<CapabilitySet, AppError, _>(|conn| {
diesel::delete(
csc_dsl::capability_set_capabilities.filter(csc_dsl::capability_set_id.eq(set.id)),
)
.execute(conn)
.map_err(AppError::from)?;
persist_capabilities(conn, set.id, capabilities)?;
diesel::update(capability_sets::table.find(set.id))
.set((
cs_dsl::cap_version.eq(set.cap_version + 1),
cs_dsl::updated_at.eq(Utc::now().naive_utc()),
))
.execute(conn)
.map_err(AppError::from)?;
capability_sets::table
.find(set.id)
.first::<CapabilitySet>(conn)
.map_err(AppError::from)
})
}
pub fn load_capability_set<C>(conn: &mut C, id: Uuid) -> Result<CapabilitySet, AppError>
where
C: Connection<Backend = Pg> + diesel::connection::LoadConnection,
{
capability_sets::table
.find(id)
.first::<CapabilitySet>(conn)
.map_err(AppError::from)
}
pub fn compute_slug(capabilities: &[ApiCapability]) -> String {
if capabilities == owner_capabilities() {
return "owner".to_string();
}
if capabilities == user_capabilities() {
return "user".to_string();
}
if capabilities == readonly_capabilities() {
return "readonly".to_string();
}
if capabilities == webdav_capabilities() {
return "webdav".to_string();
}
let joined = capabilities
.iter()
.map(|cap| cap.as_str())
.collect::<Vec<_>>()
.join(",");
let digest = Sha256::digest(joined.as_bytes());
let hex = hex::encode(digest);
format!("caps-{}", &hex[..12])
}
fn ensure_capability_membership<C>(
conn: &mut C,
set: &CapabilitySet,
desired: &[ApiCapability],
) -> Result<(), AppError>
where
C: Connection<Backend = Pg> + diesel::connection::LoadConnection,
{
let current = load_capabilities_for_set(conn, set.id)?;
if current == desired {
return Ok(());
}
let _ = refresh_capability_set(conn, set, desired)?;
Ok(())
}
fn persist_capabilities<C>(
conn: &mut C,
set_id: Uuid,
capabilities: &[ApiCapability],
) -> Result<(), AppError>
where
C: Connection<Backend = Pg> + diesel::connection::LoadConnection,
{
if capabilities.is_empty() {
return Err(AppError::bad_request("at least one capability is required"));
}
let records: Vec<NewCapabilitySetCapability> = capabilities
.iter()
.map(|cap| NewCapabilitySetCapability {
capability_set_id: set_id,
capability: *cap,
})
.collect();
diesel::insert_into(capability_set_capabilities::table)
.values(&records)
.execute(conn)
.map_err(AppError::from)?;
Ok(())
}
+6 -118
View File
@@ -6,24 +6,6 @@ use uuid::Uuid;
use crate::config::AppConfig; use crate::config::AppConfig;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum PrincipalKind {
UserSession,
ApiToken,
}
#[derive(Debug, Clone)]
pub struct AccessTokenContext {
pub user_id: Uuid,
pub tenant_id: Uuid,
pub username: String,
pub principal_kind: PrincipalKind,
pub principal_id: Uuid,
pub capability_set_id: Uuid,
pub cap_version: i32,
}
#[derive(Clone)] #[derive(Clone)]
pub struct JwtService { pub struct JwtService {
encoding: EncodingKey, encoding: EncodingKey,
@@ -33,10 +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,
signup_audience: String,
signup_expiry: Duration,
} }
impl JwtService { impl JwtService {
@@ -49,24 +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),
signup_audience: format!("{}:signup", config.jwt_audience),
signup_expiry: Duration::minutes(15),
}) })
} }
pub fn generate_token(&self, context: AccessTokenContext) -> 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: context.user_id, sub: user_id,
tenant_id: context.tenant_id, username: username.to_owned(),
username: context.username, role: role.to_owned(),
principal_kind: context.principal_kind,
principal_id: context.principal_id,
capability_set_id: context.capability_set_id,
cap_version: context.cap_version,
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,
@@ -84,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,
@@ -112,68 +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)
}
pub fn generate_signup_token(
&self,
user_id: Uuid,
challenge_id: Uuid,
username: String,
) -> Result<String> {
let now = Utc::now();
let exp = now + self.signup_expiry;
let claims = SignupClaims {
sub: user_id,
challenge_id,
username,
iss: self.issuer.clone(),
aud: self.signup_audience.clone(),
iat: now.timestamp() as usize,
exp: exp.timestamp() as usize,
};
Ok(encode(&Header::default(), &claims, &self.encoding)?)
}
pub fn verify_signup_token(&self, token: &str) -> Result<SignupClaims> {
let mut validation = Validation::default();
validation.set_audience(&[self.signup_audience.clone()]);
validation.set_issuer(&[self.issuer.clone()]);
let data = decode::<SignupClaims>(token, &self.decoding, &validation)?;
Ok(data.claims)
}
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[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 principal_kind: PrincipalKind, pub role: String,
pub principal_id: Uuid,
pub capability_set_id: Uuid,
pub cap_version: i32,
pub iss: String, pub iss: String,
pub aud: String, pub aud: String,
pub iat: usize, pub iat: usize,
@@ -184,27 +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 aud: String,
pub iat: usize,
pub exp: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SignupClaims {
pub sub: Uuid,
pub challenge_id: Uuid,
pub username: String,
pub iss: String, pub iss: String,
pub aud: String, pub aud: String,
pub iat: usize, pub iat: usize,
+5 -109
View File
@@ -1,55 +1,18 @@
pub mod api_tokens;
pub mod capability_guard;
pub mod capability_sets;
pub mod jwt; pub mod jwt;
pub mod passkeys;
pub mod password; pub mod password;
use std::sync::{Arc, Mutex};
use axum::{async_trait, extract::FromRequestParts, http::request::Parts}; use axum::{async_trait, extract::FromRequestParts, http::request::Parts};
use axum_extra::headers::{authorization::Bearer, Authorization}; use axum_extra::headers::{authorization::Bearer, Authorization};
use axum_extra::TypedHeader; use axum_extra::TypedHeader;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use crate::{ use crate::{error::AppError, state::AppState};
auth::capability_sets::{load_capabilities_for_set, load_capability_set},
error::AppError,
models::ApiCapability,
state::{AppState, PgPooledConnection},
};
use uuid::Uuid;
use crate::auth::jwt::PrincipalKind; #[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Clone)]
pub struct TenantConnectionHolder {
inner: Arc<Mutex<Option<PgPooledConnection>>>,
}
impl TenantConnectionHolder {
pub fn new(conn: PgPooledConnection) -> Self {
Self {
inner: Arc::new(Mutex::new(Some(conn))),
}
}
pub fn into_conn(self) -> Option<PgPooledConnection> {
self.inner.lock().ok()?.take()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
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,
pub principal_kind: PrincipalKind,
pub principal_id: Uuid,
pub capability_set_id: Uuid,
pub cap_version: i32,
pub capabilities: Vec<ApiCapability>,
} }
#[async_trait] #[async_trait]
@@ -60,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
@@ -74,73 +33,10 @@ impl FromRequestParts<AppState> for AuthenticatedUser {
.verify_token(bearer.token()) .verify_token(bearer.token())
.map_err(|_| AppError::unauthorized())?; .map_err(|_| AppError::unauthorized())?;
let mut tenant_conn = state.db_for_tenant(claims.tenant_id)?; Ok(AuthenticatedUser {
let capability_set = load_capability_set(&mut tenant_conn, claims.capability_set_id)
.map_err(|_| AppError::unauthorized())?;
if capability_set.cap_version != claims.cap_version {
return Err(AppError::unauthorized());
}
let capabilities = load_capabilities_for_set(&mut tenant_conn, capability_set.id)
.map_err(|_| AppError::unauthorized())?;
let user = AuthenticatedUser {
user_id: claims.sub, user_id: claims.sub,
username: claims.username, username: claims.username,
tenant_id: claims.tenant_id, role: claims.role,
principal_kind: claims.principal_kind,
principal_id: claims.principal_id,
capability_set_id: claims.capability_set_id,
cap_version: claims.cap_version,
capabilities,
};
parts.extensions.insert(user.clone());
parts
.extensions
.insert(TenantConnectionHolder::new(tenant_conn));
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 = if let Some(holder) = parts.extensions.remove::<TenantConnectionHolder>() {
holder
.into_conn()
.ok_or_else(|| AppError::internal("tenant connection unavailable"))?
} else {
state.db_for_tenant(tenant_id)?
};
Ok(Self {
conn,
tenant_id,
user_id: user.user_id,
user,
}) })
} }
} }
-592
View File
@@ -1,592 +0,0 @@
use std::sync::Arc;
use anyhow::{Context, Result};
use chrono::{Duration as ChronoDuration, NaiveDateTime, Utc};
use diesel::{dsl::count_star, prelude::*};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
use webauthn_rs::prelude::{Credential, *};
use crate::{
config::AppConfig,
error::{AppError, AppResult},
models::{NewUserPasskey, NewWebauthnChallenge, User, UserPasskey, WebauthnChallenge},
schema::{user_passkeys::dsl as passkey_dsl, webauthn_challenges::dsl as challenge_dsl},
};
const PURPOSE_REGISTRATION: &str = "registration";
const PURPOSE_AUTHENTICATION: &str = "authentication";
const DEFAULT_CHALLENGE_TTL_MINUTES: i64 = 10;
#[derive(Clone)]
pub struct PasskeyService {
webauthn: Arc<Webauthn>,
challenge_ttl: ChronoDuration,
}
pub struct PreparedPasskey {
pub id: Uuid,
pub credential_id: Vec<u8>,
pub public_key: Vec<u8>,
pub credential: serde_json::Value,
pub sign_count: i64,
pub transports: Vec<Option<String>>,
pub aaguid: Option<Uuid>,
}
impl PreparedPasskey {
pub fn into_new_user_passkey(self, user_id: Uuid, nickname: Option<String>) -> NewUserPasskey {
NewUserPasskey {
id: self.id,
user_id,
credential_id: self.credential_id,
public_key: self.public_key,
credential: self.credential,
sign_count: self.sign_count,
transports: self.transports,
aaguid: self.aaguid,
nickname,
}
}
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct RegistrationChallengeResponse {
pub challenge_id: Uuid,
#[serde(flatten)]
#[schema(value_type = Object)]
pub challenge: CreationChallengeResponse,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct AuthenticationChallengeResponse {
pub challenge_id: Uuid,
#[serde(flatten)]
#[schema(value_type = Object)]
pub challenge: RequestChallengeResponse,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct PasskeySummary {
pub id: Uuid,
pub nickname: Option<String>,
pub created_at: NaiveDateTime,
pub last_used_at: Option<NaiveDateTime>,
pub transports: Vec<String>,
pub revoked_at: Option<NaiveDateTime>,
pub revoked_reason: Option<String>,
}
impl PasskeyService {
pub fn try_new(config: &AppConfig) -> Result<Option<Self>> {
let rp_id = match config.webauthn_rp_id.as_deref().map(str::trim) {
Some(rp_id) if !rp_id.is_empty() => rp_id,
_ => return Ok(None),
};
let rp_origin = match config.webauthn_origin.as_ref().map(|s| s.trim()) {
Some(origin) if !origin.is_empty() => origin,
_ => return Ok(None),
};
let origin = Url::parse(rp_origin).context("invalid webauthn_origin")?;
let builder = WebauthnBuilder::new(rp_id, &origin)
.context("failed to initialise WebAuthn builder")?
.rp_name(&config.webauthn_rp_name)
.allow_subdomains(false)
.allow_any_port(false);
let webauthn = builder
.build()
.context("failed to build WebAuthn instance")?;
Ok(Some(Self {
webauthn: Arc::new(webauthn),
challenge_ttl: ChronoDuration::minutes(DEFAULT_CHALLENGE_TTL_MINUTES),
}))
}
fn prune_expired(&self, conn: &mut PgConnection) {
let now = Utc::now().naive_utc();
let _ = diesel::delete(
challenge_dsl::webauthn_challenges.filter(challenge_dsl::expires_at.le(now)),
)
.execute(conn);
}
fn begin_registration(
&self,
conn: &mut PgConnection,
user_id: Uuid,
username: &str,
challenge_user_id: Option<Uuid>,
exclude: Option<Vec<CredentialID>>,
) -> AppResult<RegistrationChallengeResponse> {
self.prune_expired(conn);
let (challenge, state) = self
.webauthn
.start_passkey_registration(user_id, username, username, exclude)
.map_err(|err| {
tracing::error!(error = %err, "failed to start passkey registration");
AppError::internal("failed to start passkey registration")
})?;
let challenge_id = Uuid::new_v4();
let expires_at = (Utc::now() + self.challenge_ttl).naive_utc();
let challenge_bytes: Vec<u8> = challenge.public_key.challenge.clone().into();
let state_bytes = serde_json::to_vec(&state)
.context("failed to encode passkey registration state")
.map_err(AppError::internal)?;
let record = NewWebauthnChallenge {
id: challenge_id,
user_id: challenge_user_id,
purpose: PURPOSE_REGISTRATION.to_string(),
challenge: challenge_bytes,
state: state_bytes,
expires_at,
};
diesel::insert_into(challenge_dsl::webauthn_challenges)
.values(&record)
.execute(conn)?;
Ok(RegistrationChallengeResponse {
challenge_id,
challenge,
})
}
pub fn start_registration(
&self,
conn: &mut PgConnection,
user: &User,
) -> AppResult<RegistrationChallengeResponse> {
let existing: Vec<UserPasskey> = passkey_dsl::user_passkeys
.filter(passkey_dsl::user_id.eq(user.id))
.filter(passkey_dsl::revoked_at.is_null())
.load(conn)?;
let exclude = if existing.is_empty() {
None
} else {
Some(
existing
.iter()
.map(|pk| CredentialID::from(pk.credential_id.clone()))
.collect(),
)
};
self.begin_registration(conn, user.id, &user.username, Some(user.id), exclude)
}
pub fn start_signup_registration(
&self,
conn: &mut PgConnection,
user_id: Uuid,
username: &str,
) -> AppResult<RegistrationChallengeResponse> {
self.begin_registration(conn, user_id, username, None, None)
}
fn complete_registration(
&self,
conn: &mut PgConnection,
challenge_id: Uuid,
credential: &RegisterPublicKeyCredential,
expected_user: Option<Uuid>,
) -> AppResult<PreparedPasskey> {
let record: WebauthnChallenge = challenge_dsl::webauthn_challenges
.find(challenge_id)
.first(conn)
.map_err(|err| {
if matches!(err, diesel::result::Error::NotFound) {
AppError::bad_request("challenge not found")
} else {
AppError::from(err)
}
})?;
if record.purpose != PURPOSE_REGISTRATION {
return Err(AppError::bad_request("challenge is not for registration"));
}
if let Some(expected) = expected_user {
if record.user_id != Some(expected) {
return Err(AppError::unauthorized());
}
} else if record.user_id.is_some() {
return Err(AppError::bad_request(
"unexpected user context for signup registration",
));
}
if record.expires_at < Utc::now().naive_utc() {
diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?;
return Err(AppError::bad_request("challenge expired"));
}
let state: PasskeyRegistration = serde_json::from_slice(&record.state)
.context("failed to decode registration state")
.map_err(AppError::internal)?;
let passkey = self
.webauthn
.finish_passkey_registration(credential, &state)
.map_err(|err| {
tracing::warn!(error = %err, "passkey registration validation failed");
AppError::bad_request("invalid passkey attestation")
})?;
let credential_struct: Credential = passkey.clone().into();
let credential_id_vec: Vec<u8> = credential_struct.cred_id.clone().into();
let duplicate = passkey_dsl::user_passkeys
.filter(passkey_dsl::credential_id.eq(&credential_id_vec))
.first::<UserPasskey>(conn)
.optional()?;
if duplicate.is_some() {
diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?;
return Err(AppError::conflict("credential already registered"));
}
let public_key_bytes = serde_cbor_2::to_vec(&credential_struct.cred)
.context("failed to encode credential public key")
.map_err(AppError::internal)?;
let transports: Vec<Option<String>> = credential_struct
.transports
.clone()
.unwrap_or_default()
.into_iter()
.map(|transport| Some(transport.as_ref().to_string()))
.collect();
let aaguid = match credential_struct.attestation.metadata {
AttestationMetadata::Packed { aaguid } | AttestationMetadata::Tpm { aaguid, .. } => {
Some(aaguid)
}
_ => None,
};
let credential_json = serde_json::to_value(&passkey)
.context("failed to serialise passkey")
.map_err(AppError::internal)?;
diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?;
Ok(PreparedPasskey {
id: Uuid::new_v4(),
credential_id: credential_id_vec,
public_key: public_key_bytes,
credential: credential_json,
sign_count: credential_struct.counter as i64,
transports,
aaguid,
})
}
pub fn finish_registration(
&self,
conn: &mut PgConnection,
user: &User,
challenge_id: Uuid,
credential: RegisterPublicKeyCredential,
nickname: Option<String>,
) -> AppResult<UserPasskey> {
let prepared =
self.complete_registration(conn, challenge_id, &credential, Some(user.id))?;
let new_passkey = prepared.into_new_user_passkey(user.id, nickname);
diesel::insert_into(passkey_dsl::user_passkeys)
.values(&new_passkey)
.execute(conn)?;
let created: UserPasskey = passkey_dsl::user_passkeys
.find(new_passkey.id)
.select(UserPasskey::as_select())
.first(conn)?;
Ok(created)
}
pub fn start_authentication(
&self,
conn: &mut PgConnection,
user: &User,
) -> AppResult<AuthenticationChallengeResponse> {
self.prune_expired(conn);
let stored: Vec<UserPasskey> = passkey_dsl::user_passkeys
.filter(passkey_dsl::user_id.eq(user.id))
.filter(passkey_dsl::revoked_at.is_null())
.select(UserPasskey::as_select())
.load(conn)?;
if stored.is_empty() {
return Err(AppError::bad_request("no passkeys registered"));
}
let mut passkeys = Vec::with_capacity(stored.len());
for pk in &stored {
let passkey: Passkey = serde_json::from_value(pk.credential.clone())
.context("failed to parse stored passkey")
.map_err(AppError::internal)?;
passkeys.push(passkey);
}
let (challenge, state) = self
.webauthn
.start_passkey_authentication(&passkeys)
.map_err(|err| {
tracing::error!(error = %err, "failed to start passkey authentication");
AppError::internal("failed to start passkey authentication")
})?;
let challenge_id = Uuid::new_v4();
let expires_at = (Utc::now() + self.challenge_ttl).naive_utc();
let challenge_bytes: Vec<u8> = challenge.public_key.challenge.clone().into();
let state_bytes = serde_json::to_vec(&state)
.context("failed to encode authentication state")
.map_err(AppError::internal)?;
let record = NewWebauthnChallenge {
id: challenge_id,
user_id: Some(user.id),
purpose: PURPOSE_AUTHENTICATION.to_string(),
challenge: challenge_bytes,
state: state_bytes,
expires_at,
};
diesel::insert_into(challenge_dsl::webauthn_challenges)
.values(&record)
.execute(conn)?;
Ok(AuthenticationChallengeResponse {
challenge_id,
challenge,
})
}
pub fn list_for_user(
&self,
conn: &mut PgConnection,
user_id: Uuid,
) -> AppResult<Vec<PasskeySummary>> {
let passkeys: Vec<UserPasskey> = passkey_dsl::user_passkeys
.filter(passkey_dsl::user_id.eq(user_id))
.order(passkey_dsl::created_at.asc())
.select(UserPasskey::as_select())
.load(conn)?;
Ok(passkeys.into_iter().map(PasskeySummary::from).collect())
}
pub fn active_passkey_count(&self, conn: &mut PgConnection, user_id: Uuid) -> AppResult<i64> {
let count: i64 = passkey_dsl::user_passkeys
.filter(passkey_dsl::user_id.eq(user_id))
.filter(passkey_dsl::revoked_at.is_null())
.select(count_star())
.first(conn)?;
Ok(count)
}
pub fn consume_signup_challenge(
&self,
conn: &mut PgConnection,
challenge_id: Uuid,
credential: &RegisterPublicKeyCredential,
) -> AppResult<PreparedPasskey> {
self.complete_registration(conn, challenge_id, credential, None)
}
pub fn revoke_passkey(
&self,
conn: &mut PgConnection,
user_id: Uuid,
passkey_id: Uuid,
reason: Option<String>,
) -> AppResult<()> {
let now = Utc::now().naive_utc();
let updated = diesel::update(
passkey_dsl::user_passkeys
.filter(passkey_dsl::id.eq(passkey_id))
.filter(passkey_dsl::user_id.eq(user_id))
.filter(passkey_dsl::revoked_at.is_null()),
)
.set((
passkey_dsl::revoked_at.eq(Some(now)),
passkey_dsl::revoked_reason.eq(reason),
passkey_dsl::updated_at.eq(now),
))
.execute(conn)?;
if updated == 0 {
return Err(AppError::not_found());
}
Ok(())
}
pub fn finish_authentication(
&self,
conn: &mut PgConnection,
challenge_id: Uuid,
credential: PublicKeyCredential,
) -> AppResult<(User, UserPasskey, AuthenticationResult)> {
let record: WebauthnChallenge = challenge_dsl::webauthn_challenges
.find(challenge_id)
.first(conn)
.map_err(|err| {
if matches!(err, diesel::result::Error::NotFound) {
AppError::bad_request("challenge not found")
} else {
AppError::from(err)
}
})?;
if record.purpose != PURPOSE_AUTHENTICATION {
return Err(AppError::bad_request("challenge is not for authentication"));
}
let user_id = record
.user_id
.ok_or_else(|| AppError::bad_request("challenge missing user context"))?;
if record.expires_at < Utc::now().naive_utc() {
diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?;
return Err(AppError::bad_request("challenge expired"));
}
let state: PasskeyAuthentication = serde_json::from_slice(&record.state)
.context("failed to decode authentication state")
.map_err(AppError::internal)?;
let auth_result = self
.webauthn
.finish_passkey_authentication(&credential, &state)
.map_err(|err| {
tracing::warn!(error = %err, "passkey authentication failed");
AppError::unauthorized()
})?;
let credential_id_vec: Vec<u8> = auth_result.cred_id().clone().into();
let mut passkey: UserPasskey = passkey_dsl::user_passkeys
.filter(passkey_dsl::user_id.eq(user_id))
.filter(passkey_dsl::credential_id.eq(&credential_id_vec))
.filter(passkey_dsl::revoked_at.is_null())
.select(UserPasskey::as_select())
.first(conn)
.map_err(|err| {
if matches!(err, diesel::result::Error::NotFound) {
AppError::unauthorized()
} else {
AppError::from(err)
}
})?;
let mut passkey_model: Passkey = serde_json::from_value(passkey.credential.clone())
.context("failed to parse stored passkey")
.map_err(AppError::internal)?;
if auth_result.needs_update() {
let _ = passkey_model.update_credential(&auth_result);
}
let credential_struct: Credential = passkey_model.clone().into();
let public_key_bytes = serde_cbor_2::to_vec(&credential_struct.cred)
.context("failed to encode credential public key")
.map_err(AppError::internal)?;
let transports: Vec<Option<String>> = credential_struct
.transports
.clone()
.unwrap_or_default()
.into_iter()
.map(|transport| Some(transport.as_ref().to_string()))
.collect();
let credential_json = serde_json::to_value(&passkey_model)
.context("failed to serialise passkey")
.map_err(AppError::internal)?;
let now = Utc::now().naive_utc();
diesel::update(passkey_dsl::user_passkeys.find(passkey.id))
.set((
passkey_dsl::sign_count.eq(auth_result.counter() as i64),
passkey_dsl::transports.eq(&transports),
passkey_dsl::credential.eq(credential_json.clone()),
passkey_dsl::public_key.eq(public_key_bytes),
passkey_dsl::last_used_at.eq(Some(now)),
passkey_dsl::updated_at.eq(now),
))
.execute(conn)?;
passkey.sign_count = auth_result.counter() as i64;
passkey.transports = transports;
passkey.credential = credential_json;
passkey.last_used_at = Some(now);
passkey.updated_at = now;
diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?;
let user = crate::schema::users::table
.find(user_id)
.first::<User>(conn)?;
Ok((user, passkey, auth_result))
}
}
impl From<UserPasskey> for PasskeySummary {
fn from(passkey: UserPasskey) -> Self {
let transports = passkey
.transports
.into_iter()
.filter_map(|value| value)
.collect();
Self {
id: passkey.id,
nickname: passkey.nickname,
created_at: passkey.created_at,
last_used_at: passkey.last_used_at,
transports,
revoked_at: passkey.revoked_at,
revoked_reason: passkey.revoked_reason,
}
}
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct PasskeyRegistrationFinishPayload {
pub challenge_id: Uuid,
#[schema(value_type = Object)]
pub credential: RegisterPublicKeyCredential,
#[serde(default)]
pub nickname: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct PasskeyLoginStartPayload {
pub username: String,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct PasskeyLoginFinishPayload {
pub challenge_id: Uuid,
#[schema(value_type = Object)]
pub credential: PublicKeyCredential,
}
+1 -10
View File
@@ -1,9 +1,8 @@
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use argon2::{ use argon2::{
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, password_hash::{PasswordHash, PasswordVerifier},
Argon2, Argon2,
}; };
use rand::rngs::OsRng;
pub fn verify_password(password: &str, password_hash: &str) -> Result<bool> { pub fn verify_password(password: &str, password_hash: &str) -> Result<bool> {
let parsed_hash = PasswordHash::new(password_hash).map_err(|err| anyhow!(err))?; let parsed_hash = PasswordHash::new(password_hash).map_err(|err| anyhow!(err))?;
@@ -11,11 +10,3 @@ pub fn verify_password(password: &str, password_hash: &str) -> Result<bool> {
.verify_password(password.as_bytes(), &parsed_hash) .verify_password(password.as_bytes(), &parsed_hash)
.is_ok()) .is_ok())
} }
pub fn hash_password(password: &str) -> Result<String> {
let salt = SaltString::generate(&mut OsRng);
let hash = Argon2::default()
.hash_password(password.as_bytes(), &salt)
.map_err(|err| anyhow!(err))?;
Ok(hash.to_string())
}
-627
View File
@@ -1,627 +0,0 @@
use std::sync::Arc;
use anyhow::{anyhow, bail, Context, Result};
use chrono::{Duration as ChronoDuration, Utc};
use clap::{Parser, Subcommand, ValueEnum};
use diesel::{dsl::exists, prelude::*, select};
use rand::{rngs::OsRng, RngCore};
use reqwest::{Client, Method, StatusCode};
use sha2::{Digest, Sha256};
use uuid::Uuid;
use papercrate::{
auth::capability_sets::{ensure_capability_set, owner_capabilities},
config::AppConfig,
db::{self, PgPool},
documents::search::ensure_quickwit_index,
jobs::{enqueue_job, JOB_ANALYZE_DOCUMENT},
models::{
DocumentAsset, DocumentAssetObject, MagicToken, MagicTokenKind, NewUser, NewUserMembership,
Tenant, TenantStatus, User,
},
s3,
schema::{
document_asset_objects, document_assets, documents, magic_tokens, tenants,
user_memberships, users,
},
storage::{ObjectStorage, S3Storage, TenantStorage},
tenants::TenantService,
utils::{text::normalize_identifier, tracing::init_tracing},
};
#[derive(Parser)]
#[command(
name = "papercrate-admin",
version,
about = "Papercrate administration utility"
)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
CreateUser {
username: String,
},
ListUsers,
DeleteUser {
username: String,
},
CreateTenant {
name: String,
#[arg(long = "storage-root")]
storage_root: Option<String>,
#[arg(long = "quickwit-index")]
quickwit_index: Option<String>,
},
DeleteTenant {
tenant_id: Uuid,
},
AddUserToTenant {
username: String,
tenant_id: Uuid,
},
RemoveUserFromTenant {
username: String,
tenant_id: Uuid,
},
ReanalyzeDocuments {
tenant_id: Uuid,
},
ListTenants,
DeleteAssets {
tenant_id: Uuid,
},
QuickwitCreate {
tenant_id: Uuid,
},
QuickwitDelete {
tenant_id: Uuid,
},
MagicToken {
username: String,
#[arg(long = "ttl-minutes", default_value_t = 10)]
ttl_minutes: i64,
#[arg(
long = "max-uses",
value_name = "MAX_USES",
help = "Maximum number of uses before the token is rejected (default: unlimited)"
)]
max_uses: Option<i32>,
#[arg(long = "kind", value_enum, default_value_t = MagicTokenKindArg::EmailLogin)]
kind: MagicTokenKindArg,
},
}
#[derive(Copy, Clone, Debug, ValueEnum)]
enum MagicTokenKindArg {
#[value(name = "email_login")]
EmailLogin,
#[value(name = "demo_login")]
DemoLogin,
}
impl From<MagicTokenKindArg> for MagicTokenKind {
fn from(value: MagicTokenKindArg) -> Self {
match value {
MagicTokenKindArg::EmailLogin => MagicTokenKind::EmailLogin,
MagicTokenKindArg::DemoLogin => MagicTokenKind::DemoLogin,
}
}
}
#[tokio::main]
async fn main() -> Result<()> {
init_tracing("info");
let cli = Cli::parse();
let config = AppConfig::load_and_log("admin")?;
let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?;
match cli.command {
Command::CreateUser { username } => create_user(&pool, &username)?,
Command::ListUsers => list_users(&pool)?,
Command::DeleteUser { username } => delete_user(&pool, &username)?,
Command::CreateTenant {
name,
storage_root,
quickwit_index,
} => create_tenant(&pool, &name, storage_root, quickwit_index)?,
Command::DeleteTenant { tenant_id } => delete_tenant(&pool, tenant_id)?,
Command::AddUserToTenant {
username,
tenant_id,
} => add_user_to_tenant(&pool, &username, tenant_id)?,
Command::RemoveUserFromTenant {
username,
tenant_id,
} => remove_user_from_tenant(&pool, &username, tenant_id)?,
Command::ReanalyzeDocuments { tenant_id } => reanalyze_documents(&pool, tenant_id)?,
Command::ListTenants => list_tenants(&pool)?,
Command::DeleteAssets { tenant_id } => {
delete_assets_for_tenant(&config, &pool, tenant_id).await?
}
Command::QuickwitCreate { tenant_id } => {
quickwit_index(&config, &pool, tenant_id, Method::POST).await?
}
Command::QuickwitDelete { tenant_id } => {
quickwit_index(&config, &pool, tenant_id, Method::DELETE).await?
}
Command::MagicToken {
username,
ttl_minutes,
max_uses,
kind,
} => {
create_magic_token(&pool, &username, ttl_minutes, max_uses, kind.into())?;
}
}
Ok(())
}
fn create_user(pool: &PgPool, username: &str) -> Result<()> {
let username = normalize_identifier(
username,
100,
"username must not be empty",
"username must not exceed 100 characters",
Some("username may only contain printable characters"),
|ch| !ch.is_control(),
)
.map_err(|err| anyhow!("{:?}", err))?;
let mut conn = pool.get().context("failed to get database connection")?;
let exists: bool =
select(exists(users::table.filter(users::username.eq(&username)))).get_result(&mut conn)?;
if exists {
bail!("user '{}' already exists", username);
}
let new_user = NewUser {
id: Uuid::new_v4(),
username: username.clone(),
};
diesel::insert_into(users::table)
.values(&new_user)
.execute(&mut conn)?;
println!("created user '{}' (id: {})", username, new_user.id);
Ok(())
}
fn list_users(pool: &PgPool) -> Result<()> {
let mut conn = pool.get().context("failed to get database connection")?;
let users_list: Vec<User> = users::table.order(users::username.asc()).load(&mut conn)?;
if users_list.is_empty() {
println!("No users found.");
return Ok(());
}
for user in users_list {
let memberships: Vec<String> = user_memberships::table
.inner_join(tenants::table)
.filter(user_memberships::user_id.eq(user.id))
.select(tenants::name)
.order(tenants::name.asc())
.load(&mut conn)?;
if memberships.is_empty() {
println!("{} ({})", user.username, user.id);
} else {
println!(
"{} ({}) -> {}",
user.username,
user.id,
memberships.join(", ")
);
}
}
Ok(())
}
fn delete_user(pool: &PgPool, username: &str) -> Result<()> {
let mut conn = pool.get().context("failed to get database connection")?;
let user: User = users::table
.filter(users::username.eq(username))
.first(&mut conn)
.optional()?
.ok_or_else(|| anyhow!("user '{}' not found", username))?;
diesel::delete(user_memberships::table.filter(user_memberships::user_id.eq(user.id)))
.execute(&mut conn)?;
diesel::delete(users::table.filter(users::id.eq(user.id))).execute(&mut conn)?;
println!("deleted user '{}'", username);
Ok(())
}
fn create_tenant(
pool: &PgPool,
name: &str,
storage_root_arg: Option<String>,
quickwit_index_arg: Option<String>,
) -> Result<()> {
let service = TenantService::new(pool.clone());
let tenant = service
.create_tenant(
name,
storage_root_arg.as_deref(),
quickwit_index_arg.as_deref(),
TenantStatus::Creating,
&[],
None,
)
.map_err(|err| anyhow!(format!("{err:?}")))?;
let storage_root = tenant.storage_root.as_deref().unwrap_or("<none>");
let quickwit_index = tenant.quickwit_index.as_deref().unwrap_or("<none>");
println!(
"created tenant '{}' with id {}, storage_root '{}', quickwit_index '{}', status '{}'",
tenant.name,
tenant.id,
storage_root,
quickwit_index,
tenant.status.as_str()
);
Ok(())
}
fn create_magic_token(
pool: &PgPool,
username: &str,
ttl_minutes: i64,
max_uses: Option<i32>,
kind: MagicTokenKind,
) -> 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)
.with_context(|| format!("user '{}' not found", username))?;
let raw_token = generate_random_token();
let token_hash = hash_token(&raw_token);
let expires_at = Utc::now() + ChronoDuration::minutes(ttl_minutes);
let new_token = MagicToken {
id: Uuid::new_v4(),
user_id: user.id,
kind,
token_hash,
metadata: serde_json::json!({}),
expires_at: expires_at.naive_utc(),
max_uses,
used_count: 0,
created_at: Utc::now().naive_utc(),
created_by: None,
last_used_at: None,
};
diesel::insert_into(magic_tokens::table)
.values(&new_token)
.execute(&mut conn)?;
println!(
"Magic token created for '{}' (kind: {}, expires_at: {}, max_uses: {})",
username,
kind.as_str(),
expires_at,
max_uses.map_or("".to_string(), |v| v.to_string())
);
println!("Token: {}", raw_token);
Ok(())
}
fn generate_random_token() -> String {
let mut bytes = [0u8; 32];
OsRng.fill_bytes(&mut bytes);
hex::encode(bytes)
}
fn hash_token(token: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
hex::encode(hasher.finalize())
}
fn delete_tenant(pool: &PgPool, tenant_id: Uuid) -> Result<()> {
let mut conn = pool.get().context("failed to get database connection")?;
let tenant: Tenant = tenants::table
.find(tenant_id)
.first(&mut conn)
.optional()?
.ok_or_else(|| anyhow!("tenant '{}' not found", tenant_id))?;
let member_exists: bool = select(exists(
user_memberships::table.filter(user_memberships::tenant_id.eq(tenant.id)),
))
.get_result(&mut conn)?;
if member_exists {
bail!("tenant '{}' still has user memberships", tenant.name);
}
diesel::delete(tenants::table.filter(tenants::id.eq(tenant.id))).execute(&mut conn)?;
println!("deleted tenant '{}'", tenant.name);
Ok(())
}
fn add_user_to_tenant(pool: &PgPool, username: &str, tenant_id: Uuid) -> Result<()> {
let mut conn = pool.get().context("failed to get database connection")?;
let user: User = users::table
.filter(users::username.eq(username))
.first(&mut conn)
.optional()?
.ok_or_else(|| anyhow!("user '{}' not found", username))?;
let tenant: Tenant = tenants::table
.find(tenant_id)
.first(&mut conn)
.optional()?
.ok_or_else(|| anyhow!("tenant '{}' not found", tenant_id))?;
let owner_capability_set_id = ensure_capability_set(&mut conn, tenant.id, owner_capabilities())
.map_err(|err| anyhow!("failed to ensure owner capability set: {:?}", err))?
.id;
let membership = NewUserMembership {
id: Uuid::new_v4(),
user_id: user.id,
tenant_id: tenant.id,
capability_set_id: Some(owner_capability_set_id),
};
diesel::insert_into(user_memberships::table)
.values(&membership)
.on_conflict((user_memberships::user_id, user_memberships::tenant_id))
.do_nothing()
.execute(&mut conn)?;
println!("added user '{}' to tenant '{}'", username, tenant.name);
Ok(())
}
fn remove_user_from_tenant(pool: &PgPool, username: &str, tenant_id: Uuid) -> Result<()> {
let mut conn = pool.get().context("failed to get database connection")?;
let user: User = users::table
.filter(users::username.eq(username))
.first(&mut conn)
.optional()?
.ok_or_else(|| anyhow!("user '{}' not found", username))?;
let tenant: Tenant = tenants::table
.find(tenant_id)
.first(&mut conn)
.optional()?
.ok_or_else(|| anyhow!("tenant '{}' not found", tenant_id))?;
let removed = diesel::delete(
user_memberships::table
.filter(user_memberships::user_id.eq(user.id))
.filter(user_memberships::tenant_id.eq(tenant.id)),
)
.execute(&mut conn)?;
if removed == 0 {
println!(
"user '{}' was not a member of tenant '{}'",
username, tenant.name
);
} else {
println!("removed user '{}' from tenant '{}'", username, tenant.name);
}
Ok(())
}
fn reanalyze_documents(pool: &PgPool, tenant_id: Uuid) -> Result<()> {
let mut conn = pool.get().context("failed to get database connection")?;
let tenant: Tenant = tenants::table
.find(tenant_id)
.first(&mut conn)
.optional()?
.ok_or_else(|| anyhow!("tenant '{}' not found", tenant_id))?;
let targets: Vec<(Uuid, Uuid)> = documents::table
.filter(documents::tenant_id.eq(tenant.id))
.filter(documents::deleted_at.is_null())
.select((documents::id, documents::current_version_id))
.load(&mut conn)?;
if targets.is_empty() {
println!("tenant '{}' has no active documents", tenant.name);
return Ok(());
}
let mut queued = 0usize;
for (document_id, version_id) in targets {
enqueue_job(
&mut conn,
tenant.id,
JOB_ANALYZE_DOCUMENT,
serde_json::json!({
"document_id": document_id,
"document_version_id": version_id,
"force": true,
}),
None,
)
.map_err(|err| anyhow!("failed to enqueue analyze job: {}", err))?;
queued += 1;
}
println!(
"queued {} documents for re-analysis in tenant '{}'",
queued, tenant.name
);
Ok(())
}
fn list_tenants(pool: &PgPool) -> Result<()> {
let mut conn = pool.get().context("failed to get database connection")?;
let tenants: Vec<Tenant> = tenants::table
.order(tenants::name.asc())
.load(&mut conn)
.context("failed to load tenants")?;
if tenants.is_empty() {
println!("No tenants found.");
return Ok(());
}
for tenant in tenants {
println!("{} {}", tenant.id, tenant.name);
}
Ok(())
}
async fn delete_assets_for_tenant(
config: &AppConfig,
pool: &PgPool,
tenant_id: Uuid,
) -> Result<()> {
let s3_client = s3::build_client(config).await?;
let storage: Arc<dyn ObjectStorage> =
Arc::new(S3Storage::new(s3_client, config.s3_bucket.clone()));
let mut conn = pool.get().context("failed to get database connection")?;
let tenant: Tenant = tenants::table
.find(tenant_id)
.first(&mut conn)
.optional()?
.ok_or_else(|| anyhow!("tenant '{}' not found", tenant_id))?;
let tenant_storage = TenantStorage::new(Arc::clone(&storage), &tenant)
.with_context(|| format!("missing storage root for tenant {}", tenant.name))?;
let assets: Vec<DocumentAsset> = document_assets::table
.filter(document_assets::tenant_id.eq(tenant.id))
.load(&mut conn)
.with_context(|| format!("failed to load assets for tenant {}", tenant.name))?;
if assets.is_empty() {
println!("Tenant {}: no assets", tenant.name);
return Ok(());
}
println!(
"Tenant {} ({}): deleting {} assets…",
tenant.name,
tenant.id,
assets.len()
);
let asset_ids: Vec<Uuid> = assets.iter().map(|asset| asset.id).collect();
let objects: Vec<DocumentAssetObject> = document_asset_objects::table
.filter(document_asset_objects::tenant_id.eq(tenant.id))
.filter(document_asset_objects::asset_id.eq_any(&asset_ids))
.load(&mut conn)
.with_context(|| format!("failed to load asset objects for tenant {}", tenant.name))?;
for object in &objects {
if let Err(err) = tenant_storage.delete_object(&object.s3_key).await {
eprintln!(
"Failed to delete object {} (tenant {}): {err}",
object.s3_key, tenant.name
);
}
}
diesel::delete(
document_asset_objects::table
.filter(document_asset_objects::tenant_id.eq(tenant.id))
.filter(document_asset_objects::asset_id.eq_any(&asset_ids)),
)
.execute(&mut conn)
.with_context(|| format!("failed to remove asset objects for tenant {}", tenant.name))?;
diesel::delete(document_assets::table.filter(document_assets::tenant_id.eq(tenant.id)))
.execute(&mut conn)
.with_context(|| format!("failed to remove asset records for tenant {}", tenant.name))?;
println!("Tenant {}: asset records deleted.", tenant.name);
Ok(())
}
async fn quickwit_index(
config: &AppConfig,
pool: &PgPool,
tenant_id: Uuid,
method: Method,
) -> Result<()> {
let endpoint = config
.quickwit_endpoint
.as_ref()
.ok_or_else(|| anyhow!("quickwit endpoint not configured"))?;
let mut conn = pool.get().context("failed to get database connection")?;
let tenant: Tenant = tenants::table
.find(tenant_id)
.first(&mut conn)
.optional()?
.ok_or_else(|| anyhow!("tenant '{}' not found", tenant_id))?;
let client = Client::new();
let index_id = format!("documents-{}", tenant.id);
let base_endpoint = endpoint.trim_end_matches('/');
match method {
Method::POST => {
ensure_quickwit_index(&client, base_endpoint, &index_id)
.await
.context("failed to ensure quickwit index")?;
diesel::update(tenants::table.filter(tenants::id.eq(tenant.id)))
.set(tenants::quickwit_index.eq(Some(index_id.clone())))
.execute(&mut conn)
.context("failed to update tenant quickwit_index")?;
println!(
"Tenant '{}' quickwit index set to '{}'.",
tenant.name, index_id
);
}
Method::DELETE => {
let response = client
.delete(format!("{}/api/v1/indexes/{}", base_endpoint, index_id))
.send()
.await
.context("failed to send delete index request")?;
match response.status() {
status if status.is_success() || status == StatusCode::NOT_FOUND => {
diesel::update(tenants::table.filter(tenants::id.eq(tenant.id)))
.set(tenants::quickwit_index.eq::<Option<String>>(None))
.execute(&mut conn)
.context("failed to clear tenant quickwit_index")?;
println!("Tenant '{}' quickwit index cleared.", tenant.name);
}
status => {
let body = response.text().await.unwrap_or_default();
bail!(
"quickwit delete index failed with status {}: {}",
status,
body
);
}
}
}
_ => unreachable!(),
}
Ok(())
}
+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(())
}
-8
View File
@@ -1,8 +0,0 @@
use papercrate::openapi::ApiDoc;
use utoipa::OpenApi;
fn main() {
let spec = ApiDoc::openapi();
let json = serde_json::to_string_pretty(&spec).expect("serialize openapi");
println!("{}", json);
}
+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 papercrate::{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 papercrate::{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();
}
+74 -150
View File
@@ -1,93 +1,110 @@
use std::env;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use url::Url; use url::Url;
use serde::Deserialize;
use serde_aux::field_attributes::deserialize_bool_from_anything;
use crate::db::DEFAULT_MAX_POOL_SIZE; use crate::db::DEFAULT_MAX_POOL_SIZE;
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug)]
pub struct AppConfig { pub struct AppConfig {
pub database_url: String, pub database_url: String,
#[serde(default = "default_database_max_pool_size")]
pub database_max_pool_size: u32, pub database_max_pool_size: u32,
#[serde(default = "default_server_host")]
pub server_host: String, pub server_host: String,
#[serde(default = "default_server_port")]
pub server_port: u16, pub server_port: u16,
#[serde(default = "default_webdav_host")]
pub webdav_host: String, pub webdav_host: String,
#[serde(default = "default_webdav_port")]
pub webdav_port: u16, pub webdav_port: u16,
pub jwt_secret: String, pub jwt_secret: String,
#[serde(default = "default_jwt_issuer")]
pub jwt_issuer: String, pub jwt_issuer: String,
#[serde(default = "default_jwt_audience")]
pub jwt_audience: String, pub jwt_audience: String,
#[serde(default = "default_jwt_expiry_minutes")]
pub jwt_expiry_minutes: i64, pub jwt_expiry_minutes: i64,
#[serde(default = "default_download_token_audience")]
pub download_token_audience: String, pub download_token_audience: String,
#[serde(default = "default_download_token_expiry_minutes")]
pub download_token_expiry_minutes: i64, pub download_token_expiry_minutes: i64,
#[serde(default = "default_refresh_token_expiry_days")]
pub refresh_token_expiry_days: i64, pub refresh_token_expiry_days: i64,
#[serde(
default = "default_refresh_cookie_secure",
deserialize_with = "deserialize_bool_from_anything"
)]
pub refresh_cookie_secure: bool, pub refresh_cookie_secure: bool,
#[serde(default)]
pub refresh_cookie_domain: Option<String>, pub refresh_cookie_domain: Option<String>,
#[serde(default)]
pub cors_allowed_origin: Option<String>, pub cors_allowed_origin: Option<String>,
#[serde(default)]
pub aws_endpoint_url: Option<String>, pub aws_endpoint_url: Option<String>,
#[serde(default)]
pub aws_access_key_id: Option<String>, pub aws_access_key_id: Option<String>,
#[serde(default)]
pub aws_secret_access_key: Option<String>, pub aws_secret_access_key: Option<String>,
#[serde(default = "default_aws_region")]
pub aws_region: String, pub aws_region: String,
pub s3_bucket: String, pub s3_bucket: String,
#[serde(default)]
pub quickwit_endpoint: Option<String>, pub quickwit_endpoint: Option<String>,
#[serde(default)]
pub quickwit_index: Option<String>, pub quickwit_index: Option<String>,
#[serde(default = "default_worker_max_document_bytes")]
pub worker_max_document_bytes: u64,
#[serde(default = "default_upload_body_limit_bytes")]
pub upload_body_limit_bytes: u64,
#[serde(default)]
pub webauthn_rp_id: Option<String>,
#[serde(default)]
pub webauthn_origin: Option<String>,
#[serde(default = "default_webauthn_rp_name")]
pub webauthn_rp_name: String,
} }
impl AppConfig { 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(),
passkeys_enabled = config.webauthn_origin.is_some(),
s3_bucket = %config.s3_bucket,
worker_max_document_bytes = config.worker_max_document_bytes,
upload_body_limit_bytes = config.upload_body_limit_bytes,
"loaded backend configuration"
);
Ok(config)
}
pub fn from_env() -> Result<Self> { pub fn from_env() -> Result<Self> {
let config: AppConfig = envy::from_env() let database_url = env::var("DATABASE_URL").context("DATABASE_URL must be set")?;
.context("failed to parse application configuration from environment")?; let database_max_pool_size = env::var("DATABASE_MAX_POOL_SIZE")
Ok(config.normalize()) .ok()
.and_then(|value| value.parse().ok())
.unwrap_or(DEFAULT_MAX_POOL_SIZE);
let server_host = env::var("SERVER_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
let server_port = env::var("SERVER_PORT")
.unwrap_or_else(|_| "3000".to_string())
.parse()
.context("SERVER_PORT must be a valid u16")?;
let webdav_host = env::var("WEBDAV_HOST").unwrap_or_else(|_| server_host.clone());
let webdav_port = env::var("WEBDAV_PORT")
.unwrap_or_else(|_| "3001".to_string())
.parse()
.context("WEBDAV_PORT must be a valid u16")?;
let jwt_secret = env::var("JWT_SECRET").context("JWT_SECRET must be set")?;
let jwt_issuer = env::var("JWT_ISSUER").unwrap_or_else(|_| "papercrate".to_string());
let jwt_audience =
env::var("JWT_AUDIENCE").unwrap_or_else(|_| "papercrate-clients".to_string());
let jwt_expiry_minutes = env::var("JWT_EXPIRY_MINUTES")
.unwrap_or_else(|_| "60".to_string())
.parse()
.context("JWT_EXPIRY_MINUTES must be an integer")?;
let download_token_audience = env::var("DOWNLOAD_TOKEN_AUDIENCE")
.unwrap_or_else(|_| "papercrate-download".to_string());
let download_token_expiry_minutes = env::var("DOWNLOAD_TOKEN_EXPIRY_MINUTES")
.unwrap_or_else(|_| "60".to_string())
.parse()
.context("DOWNLOAD_TOKEN_EXPIRY_MINUTES must be an integer")?;
let refresh_token_expiry_days = env::var("REFRESH_TOKEN_EXPIRY_DAYS")
.unwrap_or_else(|_| "30".to_string())
.parse()
.context("REFRESH_TOKEN_EXPIRY_DAYS must be an integer")?;
let refresh_cookie_secure = env::var("REFRESH_COOKIE_SECURE")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false);
let refresh_cookie_domain = env::var("REFRESH_COOKIE_DOMAIN").ok();
let cors_allowed_origin = env::var("CORS_ALLOWED_ORIGIN").ok();
let aws_endpoint_url = env::var("AWS_ENDPOINT_URL").ok();
let aws_access_key_id = env::var("AWS_ACCESS_KEY_ID").ok();
let aws_secret_access_key = env::var("AWS_SECRET_ACCESS_KEY").ok();
let aws_region = env::var("AWS_REGION").unwrap_or_else(|_| "us-east-1".to_string());
let s3_bucket = env::var("S3_BUCKET").context("S3_BUCKET must be set")?;
let quickwit_endpoint = env::var("QUICKWIT_ENDPOINT").ok();
let quickwit_index = env::var("QUICKWIT_INDEX").ok();
Ok(Self {
database_url,
database_max_pool_size,
server_host,
server_port,
webdav_host,
webdav_port,
jwt_secret,
jwt_issuer,
jwt_audience,
jwt_expiry_minutes,
download_token_audience,
download_token_expiry_minutes,
refresh_token_expiry_days,
refresh_cookie_secure,
refresh_cookie_domain,
cors_allowed_origin,
aws_endpoint_url,
aws_access_key_id,
aws_secret_access_key,
aws_region,
s3_bucket,
quickwit_endpoint,
quickwit_index,
})
} }
pub fn redacted_database_url(&self) -> String { pub fn redacted_database_url(&self) -> String {
@@ -95,104 +112,11 @@ impl AppConfig {
} }
} }
impl AppConfig {
fn normalize(mut self) -> Self {
if self.webdav_host.is_empty() {
self.webdav_host = self.server_host.clone();
}
if self.webauthn_rp_id.is_none() {
self.webauthn_rp_id = Some(self.server_host.clone());
}
if self.webauthn_origin.is_none() {
let scheme = if self.server_host == "127.0.0.1" || self.server_host == "localhost" {
"http"
} else {
"https"
};
self.webauthn_origin = Some(format!(
"{scheme}://{}:{}",
self.server_host, self.server_port
));
}
self
}
}
fn default_database_max_pool_size() -> u32 {
DEFAULT_MAX_POOL_SIZE
}
fn default_server_host() -> String {
"127.0.0.1".to_string()
}
fn default_server_port() -> u16 {
3000
}
fn default_webdav_host() -> String {
String::new()
}
fn default_webdav_port() -> u16 {
3001
}
fn default_jwt_issuer() -> String {
"papercrate".to_string()
}
fn default_jwt_audience() -> String {
"papercrate-clients".to_string()
}
fn default_jwt_expiry_minutes() -> i64 {
60
}
fn default_download_token_audience() -> String {
"papercrate-download".to_string()
}
fn default_download_token_expiry_minutes() -> i64 {
60
}
fn default_refresh_token_expiry_days() -> i64 {
30
}
fn default_refresh_cookie_secure() -> bool {
false
}
fn default_aws_region() -> String {
"us-east-1".to_string()
}
fn default_worker_max_document_bytes() -> u64 {
200 * 1024 * 1024
}
fn default_upload_body_limit_bytes() -> u64 {
128 * 1024 * 1024
}
fn default_webauthn_rp_name() -> String {
"Papercrate".to_string()
}
fn redact_database_url(raw: &str) -> String { fn redact_database_url(raw: &str) -> String {
match Url::parse(raw) { match Url::parse(raw) {
Ok(mut parsed) => { Ok(mut parsed) => {
if parsed.password().is_some() {
let _ = parsed.set_password(Some("*****")); let _ = parsed.set_password(Some("*****"));
parsed.to_string() parsed.to_string()
} else {
raw.to_string()
}
} }
Err(_) => "***".to_string(), Err(_) => "***".to_string(),
} }
+1 -30
View File
@@ -1,40 +1,12 @@
use std::time::Duration; use std::time::Duration;
use diesel::pg::PgConnection; use diesel::pg::PgConnection;
use diesel::r2d2::{ConnectionManager, CustomizeConnection, Pool}; use diesel::r2d2::{ConnectionManager, Pool};
use diesel::RunQueryDsl;
pub type PgPool = Pool<ConnectionManager<PgConnection>>; pub type PgPool = Pool<ConnectionManager<PgConnection>>;
pub const DEFAULT_MAX_POOL_SIZE: u32 = 2; pub const DEFAULT_MAX_POOL_SIZE: u32 = 2;
#[derive(Debug)]
struct SchemaCustomizer;
impl CustomizeConnection<PgConnection, diesel::r2d2::Error> for SchemaCustomizer {
fn on_acquire(&self, conn: &mut PgConnection) -> Result<(), diesel::r2d2::Error> {
diesel::sql_query(
"SELECT set_config('search_path', (
SELECT string_agg(schema_name, ', ')
FROM (
SELECT 'tenant' AS schema_name WHERE EXISTS (
SELECT 1 FROM pg_namespace WHERE nspname = 'tenant'
)
UNION ALL
SELECT 'shared' AS schema_name WHERE EXISTS (
SELECT 1 FROM pg_namespace WHERE nspname = 'shared'
)
UNION ALL
SELECT 'public' AS schema_name
) AS schemas
), false)",
)
.execute(conn)
.map(|_| ())
.map_err(diesel::r2d2::Error::QueryError)
}
}
pub fn init_pool(database_url: &str) -> anyhow::Result<PgPool> { pub fn init_pool(database_url: &str) -> anyhow::Result<PgPool> {
init_pool_with_size(database_url, DEFAULT_MAX_POOL_SIZE) init_pool_with_size(database_url, DEFAULT_MAX_POOL_SIZE)
} }
@@ -45,7 +17,6 @@ pub fn init_pool_with_size(database_url: &str, max_size: u32) -> anyhow::Result<
let pool = Pool::builder() let pool = Pool::builder()
.max_size(pool_size) .max_size(pool_size)
.connection_timeout(Duration::from_secs(10)) .connection_timeout(Duration::from_secs(10))
.connection_customizer(Box::new(SchemaCustomizer))
.build(manager)?; .build(manager)?;
Ok(pool) Ok(pool)
} }
-283
View File
@@ -1,283 +0,0 @@
use std::collections::HashMap;
use std::path::Path as FsPath;
use diesel::prelude::*;
use serde::Serialize;
use serde_json::Value;
use utoipa::ToSchema;
use uuid::Uuid;
use crate::error::{AppError, AppResult};
use crate::models::{Document, DocumentAsset, DocumentAssetObject, DocumentVersion};
use crate::schema::{document_asset_objects, document_assets, document_versions};
use crate::state::{AppState, PgPooledConnection};
use crate::utils::time::to_iso;
#[derive(Serialize, Clone, ToSchema)]
pub struct DocumentAssetResponse {
pub id: Uuid,
pub asset_type: String,
pub mime_type: String,
#[schema(value_type = Object)]
pub metadata: Value,
#[serde(skip_serializing_if = "Option::is_none")]
#[schema(nullable)]
pub cardinality: Option<i32>,
}
#[derive(Serialize, Clone, ToSchema)]
pub struct DocumentAssetObjectResponse {
pub id: Uuid,
pub ordinal: i32,
#[schema(value_type = Object)]
pub metadata: Value,
#[serde(skip_serializing_if = "Option::is_none")]
#[schema(nullable)]
pub url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[schema(nullable)]
pub expires_at: Option<i64>,
}
#[derive(Serialize, ToSchema)]
pub struct DocumentAssetDetailResponse {
pub id: Uuid,
pub asset_type: String,
pub mime_type: String,
#[schema(value_type = Object)]
pub metadata: Value,
pub created_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
#[schema(nullable)]
pub cardinality: Option<i32>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub objects: Vec<DocumentAssetObjectResponse>,
}
#[derive(Serialize, Clone, ToSchema)]
pub struct DocumentVersionResponse {
pub id: Uuid,
pub version_number: i32,
pub size_bytes: i64,
pub checksum: String,
pub created_at: String,
#[schema(value_type = Object)]
pub metadata: Value,
}
#[derive(Serialize, Clone, ToSchema)]
pub struct DocumentVersionDetailResponse {
#[serde(flatten)]
pub version: DocumentVersionResponse,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub assets: Vec<DocumentAssetResponse>,
pub download_path: String,
}
pub fn build_download_path(
state: &AppState,
document: &Document,
user_id: Uuid,
) -> AppResult<String> {
state
.jwt
.generate_download_token(document.id, user_id, document.tenant_id)
.map(|token| format!("/download/{token}"))
.map_err(|err| {
tracing::error!(error = ?err, "failed to generate download token");
AppError::internal("failed to generate download token")
})
}
pub fn to_version_response(version: DocumentVersion) -> DocumentVersionResponse {
DocumentVersionResponse {
id: version.id,
version_number: version.version_number,
size_bytes: version.size_bytes,
checksum: version.checksum,
created_at: to_iso(version.created_at),
metadata: version.metadata,
}
}
pub fn to_asset_summary(asset: DocumentAsset) -> DocumentAssetResponse {
DocumentAssetResponse {
id: asset.id,
asset_type: asset.asset_type,
mime_type: asset.mime_type,
metadata: asset.metadata,
cardinality: asset.cardinality,
}
}
pub fn to_asset_detail_response(
asset: DocumentAsset,
objects: Vec<DocumentAssetObjectResponse>,
) -> DocumentAssetDetailResponse {
DocumentAssetDetailResponse {
id: asset.id,
asset_type: asset.asset_type,
mime_type: asset.mime_type,
metadata: asset.metadata,
created_at: to_iso(asset.created_at),
cardinality: asset.cardinality,
objects,
}
}
pub fn to_asset_object_response(
object: DocumentAssetObject,
url: Option<String>,
expires_at: Option<i64>,
) -> DocumentAssetObjectResponse {
DocumentAssetObjectResponse {
id: object.id,
ordinal: object.ordinal,
metadata: object.metadata,
url,
expires_at,
}
}
pub fn delete_asset(
conn: &mut PgPooledConnection,
tenant_id: Uuid,
asset_id: Uuid,
) -> AppResult<()> {
diesel::delete(
document_assets::table
.filter(document_assets::id.eq(asset_id))
.filter(document_assets::tenant_id.eq(tenant_id)),
)
.execute(conn)?;
Ok(())
}
pub fn load_asset_responses_with_conn(
conn: &mut PgPooledConnection,
tenant_id: Uuid,
version_id: Uuid,
) -> AppResult<Vec<DocumentAssetResponse>> {
let assets: Vec<(DocumentAsset, Option<DocumentAssetObject>)> = document_assets::table
.left_outer_join(
document_asset_objects::table.on(document_asset_objects::asset_id
.eq(document_assets::id)
.and(document_asset_objects::ordinal.eq(1))),
)
.filter(document_assets::document_version_id.eq(version_id))
.filter(document_assets::tenant_id.eq(tenant_id))
.order(document_assets::created_at.asc())
.select((
document_assets::all_columns,
document_asset_objects::all_columns.nullable(),
))
.load(conn)?;
Ok(assets
.into_iter()
.map(|(asset, _)| to_asset_summary(asset))
.collect())
}
pub fn load_primary_assets(
conn: &mut PgPooledConnection,
documents: &[Document],
) -> AppResult<HashMap<Uuid, (DocumentVersionResponse, Vec<DocumentAssetResponse>)>> {
if documents.is_empty() {
return Ok(HashMap::new());
}
let mut doc_to_version: HashMap<Uuid, Uuid> = HashMap::with_capacity(documents.len());
let mut version_ids: Vec<Uuid> = Vec::with_capacity(documents.len());
for doc in documents {
doc_to_version.insert(doc.id, doc.current_version_id);
version_ids.push(doc.current_version_id);
}
version_ids.sort();
version_ids.dedup();
let versions: Vec<DocumentVersion> = document_versions::table
.filter(document_versions::id.eq_any(&version_ids))
.load(conn)?;
let mut version_map: HashMap<Uuid, DocumentVersion> = HashMap::new();
for version in versions {
version_map.insert(version.id, version);
}
let assets: Vec<(DocumentAsset, Option<DocumentAssetObject>)> = document_assets::table
.left_outer_join(
document_asset_objects::table.on(document_asset_objects::asset_id
.eq(document_assets::id)
.and(document_asset_objects::ordinal.eq(1))),
)
.filter(document_assets::document_version_id.eq_any(&version_ids))
.order((
document_assets::document_version_id.asc(),
document_assets::created_at.asc(),
))
.select((
document_assets::all_columns,
document_asset_objects::all_columns.nullable(),
))
.load(conn)?;
let mut assets_by_version: HashMap<Uuid, Vec<DocumentAssetResponse>> = HashMap::new();
for (asset, _object) in assets {
let version_id = asset.document_version_id;
let response = to_asset_summary(asset);
assets_by_version
.entry(version_id)
.or_default()
.push(response);
}
let mut result: HashMap<Uuid, (DocumentVersionResponse, Vec<DocumentAssetResponse>)> =
HashMap::with_capacity(doc_to_version.len());
for (doc_id, version_id) in doc_to_version {
if let Some(version) = version_map.remove(&version_id) {
let assets = assets_by_version.remove(&version_id).unwrap_or_default();
result.insert(doc_id, (to_version_response(version), assets));
}
}
Ok(result)
}
pub fn derive_document_title(original: &str) -> String {
let trimmed = original.trim();
if trimmed.is_empty() {
return "Document".to_string();
}
let stem = FsPath::new(trimmed)
.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
stem.unwrap_or_else(|| trimmed.to_string())
}
pub fn filename_with_retained_extension(title: &str, current_filename: &str) -> String {
let extension = FsPath::new(current_filename)
.extension()
.and_then(|ext| ext.to_str());
if let Some(ext) = extension {
if title
.rsplit_once('.')
.map(|(_, existing_ext)| existing_ext.eq_ignore_ascii_case(ext))
.unwrap_or(false)
{
title.to_string()
} else {
format!("{title}.{ext}")
}
} else {
title.to_string()
}
}
-121
View File
@@ -1,121 +0,0 @@
use std::collections::HashMap;
use chrono::Utc;
use diesel::prelude::*;
use serde::Serialize;
use serde_json::Value;
use utoipa::ToSchema;
use uuid::Uuid;
use crate::error::{AppError, AppResult};
use crate::models::{Correspondent, DocumentCorrespondent, NewDocumentCorrespondent};
use crate::schema::{correspondents, document_correspondents, documents};
use crate::utils::time::to_iso;
#[derive(Serialize, Clone, ToSchema)]
pub struct DocumentCorrespondentResponse {
pub id: Uuid,
pub name: String,
#[schema(value_type = Object)]
pub metadata: Value,
pub assigned_at: String,
}
pub fn normalize_correspondent_ids(ids: &[Uuid]) -> AppResult<Vec<Uuid>> {
let mut unique: Vec<Uuid> = ids.iter().copied().collect();
unique.sort_unstable();
unique.dedup();
if unique.is_empty() {
return Err(AppError::bad_request(
"assignments must contain at least one correspondent",
));
}
Ok(unique)
}
pub fn insert_document_correspondents(
conn: &mut PgConnection,
tenant_id: Uuid,
document_id: Uuid,
user_id: Uuid,
correspondent_ids: &[Uuid],
) -> AppResult<usize> {
let ids = normalize_correspondent_ids(correspondent_ids)?;
let existing: Vec<Uuid> = correspondents::table
.filter(correspondents::id.eq_any(&ids))
.filter(correspondents::tenant_id.eq(tenant_id))
.select(correspondents::id)
.load(conn)?;
if existing.len() != ids.len() {
return Err(AppError::bad_request(
"one or more correspondents do not exist",
));
}
let new_rows: Vec<NewDocumentCorrespondent> = ids
.into_iter()
.map(|correspondent_id| NewDocumentCorrespondent {
document_id,
correspondent_id,
assigned_by: Some(user_id),
tenant_id,
})
.collect();
if new_rows.is_empty() {
return Ok(0);
}
let inserted = diesel::insert_into(document_correspondents::table)
.values(&new_rows)
.on_conflict_do_nothing()
.execute(conn)?;
if inserted > 0 {
diesel::update(
documents::table
.find(document_id)
.filter(documents::tenant_id.eq(tenant_id)),
)
.set(documents::updated_at.eq(Utc::now().naive_utc()))
.execute(conn)?;
}
Ok(inserted)
}
pub fn load_correspondents_for_documents(
conn: &mut PgConnection,
document_ids: &[Uuid],
) -> AppResult<HashMap<Uuid, Vec<DocumentCorrespondentResponse>>> {
if document_ids.is_empty() {
return Ok(HashMap::new());
}
let rows: Vec<(DocumentCorrespondent, Correspondent)> = document_correspondents::table
.inner_join(correspondents::table)
.filter(document_correspondents::document_id.eq_any(document_ids))
.order((
document_correspondents::document_id.asc(),
document_correspondents::assigned_at.asc(),
))
.load(conn)?;
let mut map: HashMap<Uuid, Vec<DocumentCorrespondentResponse>> = HashMap::new();
for (assignment, correspondent) in rows {
map.entry(assignment.document_id)
.or_default()
.push(DocumentCorrespondentResponse {
id: correspondent.id,
name: correspondent.name,
metadata: correspondent.metadata,
assigned_at: to_iso(assignment.assigned_at),
});
}
Ok(map)
}
-21
View File
@@ -1,21 +0,0 @@
use diesel::dsl::exists;
use diesel::prelude::*;
use uuid::Uuid;
use crate::error::AppResult;
use crate::schema::folders;
use crate::utils::validation::ensure_exists;
pub fn ensure_folder_exists_on_conn(
conn: &mut PgConnection,
tenant_id: Uuid,
folder_id: Uuid,
) -> AppResult<()> {
let exists: bool = diesel::select(exists(
folders::table
.filter(folders::id.eq(folder_id))
.filter(folders::tenant_id.eq(tenant_id)),
))
.get_result(conn)?;
ensure_exists(exists, "folder")
}
-52
View File
@@ -1,52 +0,0 @@
use serde_json::{map::Entry, Map, Value};
use crate::error::{AppError, AppResult};
pub fn merge_document_metadata(existing: Value, updates: Value) -> AppResult<Value> {
let mut base = match existing {
Value::Object(map) => map,
Value::Null => Map::new(),
_ => {
return Err(AppError::bad_request(
"existing metadata is not an object; set replace=true to overwrite",
));
}
};
let incoming = match updates {
Value::Object(map) => map,
_ => {
return Err(AppError::bad_request(
"metadata value must be a JSON object when replace is false",
));
}
};
merge_metadata_maps(&mut base, incoming);
Ok(Value::Object(base))
}
fn merge_metadata_maps(target: &mut Map<String, Value>, updates: Map<String, Value>) {
for (key, value) in updates {
match target.entry(key) {
Entry::Occupied(mut entry) => {
let existing = entry.get_mut();
match value {
Value::Object(update_map) => {
if let Value::Object(existing_map) = existing {
merge_metadata_maps(existing_map, update_map);
} else {
*existing = Value::Object(update_map);
}
}
other => {
*existing = other;
}
}
}
Entry::Vacant(entry) => {
entry.insert(value);
}
}
}
}
-10
View File
@@ -1,10 +0,0 @@
pub mod asset;
pub mod correspondents;
pub mod folders;
pub mod metadata;
pub mod ordering;
pub mod relations;
pub mod search;
pub mod tags;
pub use ordering::{DocumentSortField, SortDirection};
-58
View File
@@ -1,58 +0,0 @@
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
pub const UNICODE_COLLATION_NAME: &str = "unicode_ci";
pub const UNICODE_COLLATION_LOCALE: &str = "und-u-ks-level2";
const TITLE_ASC: &str = "title COLLATE \"unicode_ci\" ASC";
const TITLE_DESC: &str = "title COLLATE \"unicode_ci\" DESC";
const ISSUED_AT_ASC: &str = "issued_at ASC NULLS LAST";
const ISSUED_AT_DESC: &str = "issued_at DESC NULLS LAST";
const CREATED_AT_ASC: &str = "created_at ASC";
const CREATED_AT_DESC: &str = "created_at DESC";
const UPDATED_AT_ASC: &str = "updated_at ASC";
const UPDATED_AT_DESC: &str = "updated_at DESC";
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum DocumentSortField {
Title,
IssuedAt,
CreatedAt,
UpdatedAt,
}
impl Default for DocumentSortField {
fn default() -> Self {
DocumentSortField::Title
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum SortDirection {
Asc,
Desc,
}
impl Default for SortDirection {
fn default() -> Self {
SortDirection::Asc
}
}
pub fn ordering_clauses(
field: DocumentSortField,
direction: SortDirection,
) -> (&'static str, Option<&'static str>) {
match (field, direction) {
(DocumentSortField::Title, SortDirection::Asc) => (TITLE_ASC, None),
(DocumentSortField::Title, SortDirection::Desc) => (TITLE_DESC, None),
(DocumentSortField::IssuedAt, SortDirection::Asc) => (ISSUED_AT_ASC, Some(TITLE_ASC)),
(DocumentSortField::IssuedAt, SortDirection::Desc) => (ISSUED_AT_DESC, Some(TITLE_ASC)),
(DocumentSortField::CreatedAt, SortDirection::Asc) => (CREATED_AT_ASC, Some(TITLE_ASC)),
(DocumentSortField::CreatedAt, SortDirection::Desc) => (CREATED_AT_DESC, Some(TITLE_ASC)),
(DocumentSortField::UpdatedAt, SortDirection::Asc) => (UPDATED_AT_ASC, Some(TITLE_ASC)),
(DocumentSortField::UpdatedAt, SortDirection::Desc) => (UPDATED_AT_DESC, Some(TITLE_ASC)),
}
}
-33
View File
@@ -1,33 +0,0 @@
use std::collections::HashMap;
use uuid::Uuid;
use crate::documents::correspondents::{
load_correspondents_for_documents, DocumentCorrespondentResponse,
};
use crate::documents::tags::load_tags_for_documents;
use crate::error::AppResult;
use crate::models::Tag;
use crate::state::PgPooledConnection;
/// Loads tags and correspondents for the provided documents in a single pass.
pub fn load_tags_and_correspondents(
conn: &mut PgPooledConnection,
document_ids: &[Uuid],
) -> AppResult<HashMap<Uuid, (Vec<Tag>, Vec<DocumentCorrespondentResponse>)>> {
if document_ids.is_empty() {
return Ok(HashMap::new());
}
let tags_map = load_tags_for_documents(conn, document_ids)?;
let mut correspondents_map = load_correspondents_for_documents(conn, document_ids)?;
let mut result = HashMap::with_capacity(document_ids.len());
for id in document_ids {
let tags = tags_map.get(id).cloned().unwrap_or_default();
let correspondents = correspondents_map.remove(id).unwrap_or_default();
result.insert(*id, (tags, correspondents));
}
Ok(result)
}
-290
View File
@@ -1,290 +0,0 @@
use std::collections::HashSet;
use anyhow::{anyhow, bail, Result};
use reqwest::{Client, StatusCode};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use tracing::{debug, error};
use uuid::Uuid;
use crate::models::{Document, DocumentVersion};
pub const QUICKWIT_MAX_HITS: usize = 200;
pub fn build_quickwit_query(input: &str) -> Option<String> {
let tokens: Vec<String> = input
.split_whitespace()
.filter(|token| !token.is_empty())
.map(|token| {
let normalized = token.to_lowercase();
escape_quickwit_token(&normalized)
})
.collect();
if tokens.is_empty() {
return None;
}
let parts: Vec<String> = tokens
.into_iter()
.map(|token| format!("(title:{token} OR text:{token})"))
.collect();
Some(parts.join(" AND "))
}
pub fn escape_quickwit_token(token: &str) -> String {
let mut escaped = String::with_capacity(token.len());
for ch in token.chars() {
match ch {
'+' | '-' | '&' | '|' | '!' | '(' | ')' | '{' | '}' | '[' | ']' | '^' | '"' | '~'
| '*' | '?' | ':' | '\\' | '/' => {
escaped.push('\\');
escaped.push(ch);
}
_ => escaped.push(ch),
}
}
escaped
}
pub async fn quickwit_search(
endpoint: &str,
index: &str,
tenant_id: Uuid,
query: &str,
) -> Result<Vec<Uuid>> {
let tenant_clause = format!("tenant_id:{}", tenant_id);
let quickwit_query = match build_quickwit_query(query) {
Some(q) => {
debug!(%query, quickwit_query = %q, "built quickwit search query");
format!("{} AND ({})", tenant_clause, q)
}
None => {
debug!(%query, "quickwit search skipped because query produced no tokens");
return Ok(vec![]);
}
};
let client = Client::new();
let url = format!("{}/api/v1/{}/search", endpoint.trim_end_matches('/'), index);
let payload = json!({
"query": quickwit_query,
"max_hits": QUICKWIT_MAX_HITS,
});
debug!(%url, payload = %payload, "sending quickwit search request");
let response = client.post(url).json(&payload).send().await?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
error!(%status, body = %body, "quickwit search request failed");
return Err(anyhow!(
"quickwit search failed with status {status}: {body}"
));
}
let data: QuickwitSearchResponse = response.json().await?;
debug!("quickwit search response parsed successfully");
let QuickwitSearchResponse { hits } = data;
let total_hits = hits.len();
let mut seen = HashSet::new();
let mut doc_ids = Vec::with_capacity(total_hits);
for hit in hits {
if let Some(doc_id) = extract_document_id(&hit) {
if seen.insert(doc_id) {
doc_ids.push(doc_id);
}
}
}
debug!(
total_hits = total_hits,
unique_ids = doc_ids.len(),
"quickwit search completed"
);
Ok(doc_ids)
}
pub fn quickwit_index_template(index_id: &str) -> Value {
json!({
"version": "0.8",
"index_id": index_id,
"doc_mapping": {
"tokenizers": [
{
"name": "substring",
"type": "ngram",
"min_gram": 2,
"max_gram": 20,
"prefix_only": false
}
],
"field_mappings": [
{ "name": "tenant_id", "type": "text", "stored": true },
{ "name": "document_id", "type": "text", "stored": true },
{ "name": "version_id", "type": "text", "stored": true },
{ "name": "title", "type": "text", "tokenizer": "substring", "stored": true },
{ "name": "text", "type": "text", "tokenizer": "substring", "record": "position" }
]
},
"search_settings": {
"default_search_fields": ["title", "text"]
}
})
}
pub async fn ensure_quickwit_index(client: &Client, endpoint: &str, index_id: &str) -> Result<()> {
let base = endpoint.trim_end_matches('/');
let create_url = format!("{}/api/v1/indexes", base);
let payload = quickwit_index_template(index_id);
let response = client.post(&create_url).json(&payload).send().await?;
match response.status() {
status if status.is_success() => Ok(()),
StatusCode::CONFLICT => {
let lookup_url = format!("{}/api/v1/indexes/{}", base, index_id);
let lookup = client.get(&lookup_url).send().await?;
if lookup.status().is_success() {
Ok(())
} else {
let status = lookup.status();
let body = lookup.text().await.unwrap_or_default();
bail!("quickwit index lookup failed with status {status}: {body}");
}
}
status => {
let body = response.text().await.unwrap_or_default();
bail!("quickwit create index failed with status {status}: {body}");
}
}
}
pub fn extract_document_id(hit: &Value) -> Option<Uuid> {
for key in ["_source", "source", "fields", "stored_fields"] {
if let Some(value) = hit.get(key) {
if let Some(uuid) = extract_uuid_from_value(value) {
return Some(uuid);
}
}
}
if let Some(value) = hit.get("document_id") {
if let Some(uuid) = extract_uuid_from_value(value) {
return Some(uuid);
}
}
None
}
pub fn extract_uuid_from_value(value: &Value) -> Option<Uuid> {
if let Some(obj) = value.as_object() {
if let Some(inner) = obj.get("document_id") {
return parse_uuid_value(inner);
}
}
if let Some(arr) = value.as_array() {
for item in arr {
if let Some(uuid) = extract_uuid_from_value(item) {
return Some(uuid);
}
}
}
parse_uuid_value(value)
}
pub fn parse_uuid_value(value: &Value) -> Option<Uuid> {
if let Some(s) = value.as_str() {
return Uuid::parse_str(s).ok();
}
if let Some(arr) = value.as_array() {
for item in arr {
if let Some(uuid) = parse_uuid_value(item) {
return Some(uuid);
}
}
}
None
}
#[derive(Deserialize)]
struct QuickwitSearchResponse {
#[serde(default)]
hits: Vec<Value>,
}
#[derive(Serialize)]
pub struct QuickwitIngestRecord {
pub document_id: Uuid,
pub version_id: Uuid,
pub tenant_id: Uuid,
pub title: String,
pub text: String,
}
pub fn build_quickwit_ingest_record(
document: &Document,
version: &DocumentVersion,
tenant_id: Uuid,
text: &str,
) -> QuickwitIngestRecord {
QuickwitIngestRecord {
document_id: document.id,
version_id: version.id,
tenant_id,
title: document.title.to_lowercase(),
text: text.to_lowercase(),
}
}
pub async fn quickwit_ingest(
client: &Client,
endpoint: &str,
index: &str,
records: &[QuickwitIngestRecord],
) -> Result<()> {
if records.is_empty() {
return Ok(());
}
let url = format!(
"{}/api/v1/{}/ingest?commit=auto",
endpoint.trim_end_matches('/'),
index
);
let mut body = String::new();
for record in records {
let line = serde_json::to_string(record)?;
body.push_str(&line);
body.push('\n');
}
debug!(%url, lines = records.len(), "sending quickwit ingest request");
let response = client
.post(url)
.header("content-type", "application/x-ndjson")
.body(body)
.send()
.await?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
error!(%status, %body, "quickwit ingest request failed");
return Err(anyhow!(
"quickwit ingest failed with status {status}: {body}"
));
}
debug!("quickwit ingest request succeeded");
Ok(())
}
-80
View File
@@ -1,80 +0,0 @@
use std::collections::HashMap;
use diesel::prelude::*;
use uuid::Uuid;
use crate::error::{AppError, AppResult};
use crate::models::{Document, NewDocumentTag, Tag};
use crate::schema::{document_tags, tags};
pub fn assign_tags(
conn: &mut PgConnection,
tenant_id: Uuid,
document: &Document,
raw_tag_ids: &[Uuid],
assigned_by: Option<Uuid>,
) -> AppResult<usize> {
if raw_tag_ids.is_empty() {
return Ok(0);
}
let mut tag_ids: Vec<Uuid> = raw_tag_ids.iter().copied().collect();
tag_ids.sort_unstable();
tag_ids.dedup();
if tag_ids.is_empty() {
return Ok(0);
}
let existing: Vec<Uuid> = tags::table
.filter(tags::id.eq_any(&tag_ids))
.filter(tags::tenant_id.eq(tenant_id))
.select(tags::id)
.load(conn)?;
if existing.len() != tag_ids.len() {
return Err(AppError::bad_request("one or more tags do not exist"));
}
let new_tags: Vec<NewDocumentTag> = tag_ids
.into_iter()
.map(|tag_id| NewDocumentTag {
document_id: document.id,
tag_id,
assigned_by,
tenant_id,
})
.collect();
if new_tags.is_empty() {
return Ok(0);
}
let inserted = diesel::insert_into(document_tags::table)
.values(&new_tags)
.on_conflict_do_nothing()
.execute(conn)?;
Ok(inserted)
}
pub fn load_tags_for_documents(
conn: &mut PgConnection,
document_ids: &[Uuid],
) -> AppResult<HashMap<Uuid, Vec<Tag>>> {
if document_ids.is_empty() {
return Ok(HashMap::new());
}
let rows: Vec<(Uuid, Tag)> = document_tags::table
.inner_join(tags::table)
.filter(document_tags::document_id.eq_any(document_ids))
.select((document_tags::document_id, tags::all_columns))
.load(conn)?;
let mut map: HashMap<Uuid, Vec<Tag>> = HashMap::new();
for (doc_id, tag) in rows {
map.entry(doc_id).or_default().push(tag);
}
Ok(map)
}
+1 -29
View File
@@ -4,7 +4,6 @@ use axum::{
Json, Json,
}; };
use serde::Serialize; use serde::Serialize;
use serde_json::Value;
use std::fmt::Display; use std::fmt::Display;
pub type AppResult<T> = Result<T, AppError>; pub type AppResult<T> = Result<T, AppError>;
@@ -13,8 +12,6 @@ pub type AppResult<T> = Result<T, AppError>;
pub struct AppError { pub struct AppError {
status: StatusCode, status: StatusCode,
message: String, message: String,
code: Option<String>,
details: Option<Value>,
} }
impl AppError { impl AppError {
@@ -22,8 +19,6 @@ impl AppError {
Self { Self {
status, status,
message: message.into(), message: message.into(),
code: None,
details: None,
} }
} }
@@ -31,10 +26,6 @@ impl AppError {
Self::new(StatusCode::BAD_REQUEST, message) Self::new(StatusCode::BAD_REQUEST, message)
} }
pub fn conflict(message: impl Into<String>) -> Self {
Self::new(StatusCode::CONFLICT, message)
}
pub fn unauthorized() -> Self { pub fn unauthorized() -> Self {
Self::new(StatusCode::UNAUTHORIZED, "unauthorized") Self::new(StatusCode::UNAUTHORIZED, "unauthorized")
} }
@@ -46,16 +37,6 @@ impl AppError {
pub fn internal<E: Display>(error: E) -> Self { pub fn internal<E: Display>(error: E) -> Self {
Self::new(StatusCode::INTERNAL_SERVER_ERROR, error.to_string()) Self::new(StatusCode::INTERNAL_SERVER_ERROR, error.to_string())
} }
pub fn with_code(mut self, code: impl Into<String>) -> Self {
self.code = Some(code.into());
self
}
pub fn with_details(mut self, details: Value) -> Self {
self.details = Some(details);
self
}
} }
impl IntoResponse for AppError { impl IntoResponse for AppError {
@@ -63,8 +44,6 @@ impl IntoResponse for AppError {
let status = self.status; let status = self.status;
let body = Json(ErrorResponse { let body = Json(ErrorResponse {
error: self.message, error: self.message,
code: self.code,
details: self.details,
}); });
(status, body).into_response() (status, body).into_response()
} }
@@ -73,20 +52,13 @@ impl IntoResponse for AppError {
#[derive(Serialize)] #[derive(Serialize)]
struct ErrorResponse { struct ErrorResponse {
error: String, error: String,
#[serde(skip_serializing_if = "Option::is_none")]
code: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
details: Option<Value>,
} }
impl From<diesel::result::Error> for AppError { impl From<diesel::result::Error> for AppError {
fn from(value: diesel::result::Error) -> Self { fn from(value: diesel::result::Error) -> Self {
match value { match value {
diesel::result::Error::NotFound => AppError::not_found(), diesel::result::Error::NotFound => AppError::not_found(),
other => { _ => AppError::internal(value),
tracing::error!(error = ?other, "database operation failed");
AppError::internal("database operation failed")
}
} }
} }
} }
-1
View File
@@ -1 +0,0 @@
pub mod responders;
-149
View File
@@ -1,149 +0,0 @@
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde::Serialize;
use crate::error::{AppError, AppResult};
/// Helper trait to convert error-centric results into the application's error type.
pub trait IntoAppResult<T> {
fn into_app_result(self) -> AppResult<T>;
}
impl<T, E> IntoAppResult<T> for Result<T, E>
where
AppError: From<E>,
{
fn into_app_result(self) -> AppResult<T> {
self.map_err(AppError::from)
}
}
/// Extension helpers for optional values to map them into `AppResult`.
pub trait OptionAppResultExt<T> {
fn or_not_found(self) -> AppResult<T>;
fn or_bad_request(self, message: impl Into<String>) -> AppResult<T>;
}
impl<T> OptionAppResultExt<T> for Option<T> {
fn or_not_found(self) -> AppResult<T> {
self.ok_or_else(AppError::not_found)
}
fn or_bad_request(self, message: impl Into<String>) -> AppResult<T> {
self.ok_or_else(|| AppError::bad_request(message))
}
}
/// Provides helpers for statements returning number of affected rows.
pub trait RowsAffectedExt: Sized {
fn or_error(self, error: AppError) -> AppResult<usize>;
fn or_not_found(self) -> AppResult<usize> {
self.or_error(AppError::not_found())
}
}
impl RowsAffectedExt for usize {
fn or_error(self, error: AppError) -> AppResult<usize> {
if self == 0 {
Err(error)
} else {
Ok(self)
}
}
}
/// Wrapper providing a consistent JSON response with a status code.
pub struct JsonResponse<T> {
status: StatusCode,
payload: T,
}
impl<T> JsonResponse<T> {
pub fn new(status: StatusCode, payload: T) -> Self {
Self { status, payload }
}
pub fn ok(payload: T) -> Self {
Self::new(StatusCode::OK, payload)
}
pub fn created(payload: T) -> Self {
Self::new(StatusCode::CREATED, payload)
}
pub fn accepted(payload: T) -> Self {
Self::new(StatusCode::ACCEPTED, payload)
}
}
impl<T> From<T> for JsonResponse<T> {
fn from(value: T) -> Self {
Self::ok(value)
}
}
impl<T> IntoResponse for JsonResponse<T>
where
T: Serialize,
{
fn into_response(self) -> Response {
(self.status, Json(self.payload)).into_response()
}
}
/// Helper for returning empty responses with a status code.
pub fn empty(status: StatusCode) -> AppResult<StatusCode> {
Ok(status)
}
/// Helper for returning `204 No Content`.
pub fn no_content() -> AppResult<StatusCode> {
empty(StatusCode::NO_CONTENT)
}
/// Helper for returning JSON payloads with `200 OK`.
pub fn ok_json<T>(value: T) -> AppResult<JsonResponse<T>>
where
T: Serialize,
{
Ok(JsonResponse::ok(value))
}
/// Helper for returning JSON payloads with `201 Created`.
pub fn created_json<T>(value: T) -> AppResult<JsonResponse<T>>
where
T: Serialize,
{
Ok(JsonResponse::created(value))
}
/// Helper for returning JSON payloads with `202 Accepted`.
pub fn accepted_json<T>(value: T) -> AppResult<JsonResponse<T>>
where
T: Serialize,
{
Ok(JsonResponse::accepted(value))
}
/// Standard wrapper for paginated responses.
#[derive(Serialize)]
pub struct PaginatedResponse<T, M>
where
T: Serialize,
M: Serialize,
{
pub data: T,
pub meta: M,
}
pub fn paginated_json<T, M>(data: T, meta: M) -> AppResult<JsonResponse<PaginatedResponse<T, M>>>
where
T: Serialize,
M: Serialize,
{
let payload = PaginatedResponse { data, meta };
Ok(JsonResponse::ok(payload))
}
-4
View File
@@ -19,8 +19,6 @@ pub const JOB_ANALYZE_DOCUMENT: &str = "analyze-document";
pub const JOB_GENERATE_THUMBNAILS: &str = "generate-thumbnails"; pub const JOB_GENERATE_THUMBNAILS: &str = "generate-thumbnails";
pub const JOB_GENERATE_OCR_TEXT: &str = "generate-ocr-text"; pub const JOB_GENERATE_OCR_TEXT: &str = "generate-ocr-text";
pub const JOB_INDEX_DOCUMENT_TEXT: &str = "index-document-text"; pub const JOB_INDEX_DOCUMENT_TEXT: &str = "index-document-text";
pub const JOB_PROVISION_TENANT: &str = "provision-tenant";
pub const JOB_PURGE_DOCUMENT: &str = "purge-document";
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum JobQueueError { pub enum JobQueueError {
@@ -32,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>,
@@ -43,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)
-5
View File
@@ -1,19 +1,14 @@
pub mod auth; pub mod auth;
pub mod config; pub mod config;
pub mod db; pub mod db;
pub mod documents;
pub mod error; pub mod error;
pub mod http;
pub mod jobs; pub mod jobs;
pub mod models; pub mod models;
pub mod openapi;
pub mod routes; pub mod routes;
pub mod s3; pub mod s3;
pub mod schema; pub mod schema;
pub mod services;
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 papercrate::{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();
}
+16 -542
View File
@@ -1,360 +1,16 @@
use chrono::NaiveDateTime; use chrono::NaiveDateTime;
use diesel::deserialize::FromSql;
use diesel::pg::{Pg, PgValue};
use diesel::prelude::*; use diesel::prelude::*;
use diesel::serialize::{IsNull, Output, ToSql};
use diesel::{deserialize, serialize, AsExpression, FromSqlRow};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::fmt;
use std::io::Write;
use std::str;
use uuid::Uuid; use uuid::Uuid;
use utoipa::ToSchema;
use crate::schema::sql_types::{
ApiCapability as ApiCapabilitySql, MagicTokenKind as MagicTokenKindSql,
TenantStatus as TenantStatusSql,
};
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 created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
pub capability_set_id: Option<Uuid>,
}
#[derive(Debug, Insertable)]
#[diesel(table_name = user_memberships)]
pub struct NewUserMembership {
pub id: Uuid,
pub user_id: Uuid,
pub tenant_id: Uuid,
pub capability_set_id: Option<Uuid>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, AsExpression, FromSqlRow)]
#[diesel(sql_type = TenantStatusSql)]
pub enum TenantStatus {
Creating,
Active,
Suspended,
Deleting,
Error,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, AsExpression, FromSqlRow)]
#[diesel(sql_type = MagicTokenKindSql)]
pub enum MagicTokenKind {
EmailLogin,
DemoLogin,
}
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Hash,
AsExpression,
FromSqlRow,
Serialize,
Deserialize,
ToSchema,
)]
#[diesel(sql_type = ApiCapabilitySql)]
pub enum ApiCapability {
#[serde(rename = "documents:read")]
DocumentsRead,
#[serde(rename = "documents:edit")]
DocumentsEdit,
#[serde(rename = "documents:write")]
DocumentsWrite,
#[serde(rename = "documents:upload")]
DocumentsUpload,
#[serde(rename = "folders:read")]
FoldersRead,
#[serde(rename = "folders:edit")]
FoldersEdit,
#[serde(rename = "folders:write")]
FoldersWrite,
#[serde(rename = "tags:read")]
TagsRead,
#[serde(rename = "tags:edit")]
TagsEdit,
#[serde(rename = "tags:write")]
TagsWrite,
#[serde(rename = "correspondents:read")]
CorrespondentsRead,
#[serde(rename = "correspondents:edit")]
CorrespondentsEdit,
#[serde(rename = "correspondents:write")]
CorrespondentsWrite,
#[serde(rename = "profile:read")]
ProfileRead,
#[serde(rename = "profile:write")]
ProfileWrite,
#[serde(rename = "webdav:read")]
WebdavRead,
#[serde(rename = "webdav:write")]
WebdavWrite,
#[serde(rename = "capability_sets:read")]
CapabilitySetsRead,
#[serde(rename = "capability_sets:write")]
CapabilitySetsWrite,
}
impl MagicTokenKind {
pub fn as_str(&self) -> &'static str {
match self {
MagicTokenKind::EmailLogin => "email_login",
MagicTokenKind::DemoLogin => "demo_login",
}
}
pub fn variants() -> &'static [&'static str] {
&["email_login", "demo_login"]
}
}
impl ApiCapability {
pub fn as_str(&self) -> &'static str {
match self {
ApiCapability::DocumentsRead => "documents:read",
ApiCapability::DocumentsEdit => "documents:edit",
ApiCapability::DocumentsWrite => "documents:write",
ApiCapability::DocumentsUpload => "documents:upload",
ApiCapability::FoldersRead => "folders:read",
ApiCapability::FoldersEdit => "folders:edit",
ApiCapability::FoldersWrite => "folders:write",
ApiCapability::TagsRead => "tags:read",
ApiCapability::TagsEdit => "tags:edit",
ApiCapability::TagsWrite => "tags:write",
ApiCapability::CorrespondentsRead => "correspondents:read",
ApiCapability::CorrespondentsEdit => "correspondents:edit",
ApiCapability::CorrespondentsWrite => "correspondents:write",
ApiCapability::ProfileRead => "profile:read",
ApiCapability::ProfileWrite => "profile:write",
ApiCapability::WebdavRead => "webdav:read",
ApiCapability::WebdavWrite => "webdav:write",
ApiCapability::CapabilitySetsRead => "capability_sets:read",
ApiCapability::CapabilitySetsWrite => "capability_sets:write",
}
}
pub fn variants() -> &'static [&'static str] {
&[
"documents:read",
"documents:edit",
"documents:write",
"documents:upload",
"folders:read",
"folders:edit",
"folders:write",
"tags:read",
"tags:edit",
"tags:write",
"correspondents:read",
"correspondents:edit",
"correspondents:write",
"profile:read",
"profile:write",
"webdav:read",
"webdav:write",
"capability_sets:read",
"capability_sets:write",
]
}
}
impl fmt::Display for MagicTokenKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.as_str())
}
}
impl fmt::Display for ApiCapability {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.as_str())
}
}
impl ToSql<MagicTokenKindSql, Pg> for MagicTokenKind {
fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result {
out.write_all(self.as_str().as_bytes())?;
Ok(IsNull::No)
}
}
impl ToSql<ApiCapabilitySql, Pg> for ApiCapability {
fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result {
out.write_all(self.as_str().as_bytes())?;
Ok(IsNull::No)
}
}
impl FromSql<MagicTokenKindSql, Pg> for MagicTokenKind {
fn from_sql(bytes: PgValue<'_>) -> deserialize::Result<Self> {
match std::str::from_utf8(bytes.as_bytes())? {
"email_login" => Ok(MagicTokenKind::EmailLogin),
"demo_login" => Ok(MagicTokenKind::DemoLogin),
other => Err(Box::new(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("invalid magic_token_kind '{other}'"),
))),
}
}
}
impl FromSql<ApiCapabilitySql, Pg> for ApiCapability {
fn from_sql(bytes: PgValue<'_>) -> deserialize::Result<Self> {
match std::str::from_utf8(bytes.as_bytes())? {
"documents:read" => Ok(ApiCapability::DocumentsRead),
"documents:edit" => Ok(ApiCapability::DocumentsEdit),
"documents:write" => Ok(ApiCapability::DocumentsWrite),
"documents:upload" => Ok(ApiCapability::DocumentsUpload),
"folders:read" => Ok(ApiCapability::FoldersRead),
"folders:edit" => Ok(ApiCapability::FoldersEdit),
"folders:write" => Ok(ApiCapability::FoldersWrite),
"tags:read" => Ok(ApiCapability::TagsRead),
"tags:edit" => Ok(ApiCapability::TagsEdit),
"tags:write" => Ok(ApiCapability::TagsWrite),
"correspondents:read" => Ok(ApiCapability::CorrespondentsRead),
"correspondents:edit" => Ok(ApiCapability::CorrespondentsEdit),
"correspondents:write" => Ok(ApiCapability::CorrespondentsWrite),
"profile:read" => Ok(ApiCapability::ProfileRead),
"profile:write" => Ok(ApiCapability::ProfileWrite),
"webdav:read" => Ok(ApiCapability::WebdavRead),
"webdav:write" => Ok(ApiCapability::WebdavWrite),
"capability_sets:read" => Ok(ApiCapability::CapabilitySetsRead),
"capability_sets:write" => Ok(ApiCapability::CapabilitySetsWrite),
other => Err(Box::new(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("invalid api_capability '{other}'"),
))),
}
}
}
impl str::FromStr for MagicTokenKind {
type Err = &'static str;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"email_login" => Ok(MagicTokenKind::EmailLogin),
"demo_login" => Ok(MagicTokenKind::DemoLogin),
_ => Err("unsupported magic token kind"),
}
}
}
impl str::FromStr for ApiCapability {
type Err = &'static str;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"documents:read" => Ok(ApiCapability::DocumentsRead),
"documents:edit" => Ok(ApiCapability::DocumentsEdit),
"documents:write" => Ok(ApiCapability::DocumentsWrite),
"documents:upload" => Ok(ApiCapability::DocumentsUpload),
"folders:read" => Ok(ApiCapability::FoldersRead),
"folders:edit" => Ok(ApiCapability::FoldersEdit),
"folders:write" => Ok(ApiCapability::FoldersWrite),
"tags:read" => Ok(ApiCapability::TagsRead),
"tags:edit" => Ok(ApiCapability::TagsEdit),
"tags:write" => Ok(ApiCapability::TagsWrite),
"correspondents:read" => Ok(ApiCapability::CorrespondentsRead),
"correspondents:edit" => Ok(ApiCapability::CorrespondentsEdit),
"correspondents:write" => Ok(ApiCapability::CorrespondentsWrite),
"profile:read" => Ok(ApiCapability::ProfileRead),
"profile:write" => Ok(ApiCapability::ProfileWrite),
"webdav:read" => Ok(ApiCapability::WebdavRead),
"webdav:write" => Ok(ApiCapability::WebdavWrite),
"capability_sets:read" => Ok(ApiCapability::CapabilitySetsRead),
"capability_sets:write" => Ok(ApiCapability::CapabilitySetsWrite),
_ => Err("unsupported api capability"),
}
}
}
impl TenantStatus {
pub fn as_str(&self) -> &'static str {
match self {
TenantStatus::Creating => "creating",
TenantStatus::Active => "active",
TenantStatus::Suspended => "suspended",
TenantStatus::Deleting => "deleting",
TenantStatus::Error => "error",
}
}
pub fn from_str(value: &str) -> Option<Self> {
match value {
"creating" => Some(TenantStatus::Creating),
"active" => Some(TenantStatus::Active),
"suspended" => Some(TenantStatus::Suspended),
"deleting" => Some(TenantStatus::Deleting),
"error" => Some(TenantStatus::Error),
_ => None,
}
}
}
impl fmt::Display for TenantStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.as_str())
}
}
impl ToSql<TenantStatusSql, Pg> for TenantStatus {
fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result {
out.write_all(self.as_str().as_bytes())?;
Ok(IsNull::No)
}
}
impl FromSql<TenantStatusSql, Pg> for TenantStatus {
fn from_sql(bytes: PgValue<'_>) -> deserialize::Result<Self> {
let value = str::from_utf8(bytes.as_bytes())
.map_err(|err| Box::<dyn std::error::Error + Send + Sync>::from(err))?;
TenantStatus::from_str(value).ok_or_else(|| {
Box::<dyn std::error::Error + Send + Sync>::from(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("invalid tenant status '{value}'"),
))
})
}
}
#[derive(Debug, Clone, Queryable, Identifiable)]
#[diesel(table_name = tenants)]
#[diesel(primary_key(id))]
pub struct Tenant {
pub id: Uuid,
pub name: String,
pub storage_root: Option<String>,
pub quickwit_index: Option<String>,
pub config: Value,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
pub status: TenantStatus,
pub created_by: Option<Uuid>,
}
#[derive(Debug, Clone, Queryable, Identifiable)] #[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 role: String,
pub created_at: NaiveDateTime, pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime, pub updated_at: NaiveDateTime,
} }
@@ -364,135 +20,8 @@ pub struct User {
pub struct NewUser { pub struct NewUser {
pub id: Uuid, pub id: Uuid,
pub username: String, pub username: String,
} pub password_hash: String,
pub role: String,
#[derive(Debug, Clone, Queryable, Identifiable, Associations, Selectable)]
#[diesel(table_name = user_passkeys)]
#[diesel(belongs_to(User))]
pub struct UserPasskey {
pub id: Uuid,
pub user_id: Uuid,
pub credential_id: Vec<u8>,
pub public_key: Vec<u8>,
pub credential: serde_json::Value,
pub sign_count: i64,
pub transports: Vec<Option<String>>,
pub aaguid: Option<Uuid>,
pub nickname: Option<String>,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
pub last_used_at: Option<NaiveDateTime>,
pub revoked_at: Option<NaiveDateTime>,
pub revoked_by: Option<Uuid>,
pub revoked_reason: Option<String>,
}
#[derive(Debug, Insertable)]
#[diesel(table_name = user_passkeys)]
pub struct NewUserPasskey {
pub id: Uuid,
pub user_id: Uuid,
pub credential_id: Vec<u8>,
pub public_key: Vec<u8>,
pub credential: serde_json::Value,
pub sign_count: i64,
pub transports: Vec<Option<String>>,
pub aaguid: Option<Uuid>,
pub nickname: Option<String>,
}
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
#[diesel(table_name = webauthn_challenges)]
#[diesel(belongs_to(User))]
pub struct WebauthnChallenge {
pub id: Uuid,
pub user_id: Option<Uuid>,
pub purpose: String,
pub challenge: Vec<u8>,
pub state: Vec<u8>,
pub created_at: NaiveDateTime,
pub expires_at: NaiveDateTime,
}
#[derive(Debug, Insertable)]
#[diesel(table_name = webauthn_challenges)]
pub struct NewWebauthnChallenge {
pub id: Uuid,
pub user_id: Option<Uuid>,
pub purpose: String,
pub challenge: Vec<u8>,
pub state: Vec<u8>,
pub expires_at: NaiveDateTime,
}
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
#[diesel(table_name = api_tokens)]
#[diesel(belongs_to(User))]
#[diesel(belongs_to(Tenant))]
pub struct ApiToken {
pub id: Uuid,
pub user_id: Uuid,
pub tenant_id: Uuid,
pub token_prefix: String,
pub token_hash: String,
pub label: Option<String>,
pub created_at: NaiveDateTime,
pub last_used_at: Option<NaiveDateTime>,
pub expires_at: Option<NaiveDateTime>,
pub revoked_at: Option<NaiveDateTime>,
pub capability_set_id: Uuid,
}
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
#[diesel(table_name = capability_sets)]
#[diesel(belongs_to(Tenant))]
pub struct CapabilitySet {
pub id: Uuid,
pub tenant_id: Uuid,
pub slug: String,
pub cap_version: i32,
pub is_system: bool,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
}
#[derive(Debug, Insertable)]
#[diesel(table_name = capability_sets)]
pub struct NewCapabilitySet {
pub id: Uuid,
pub tenant_id: Uuid,
pub slug: String,
pub cap_version: i32,
pub is_system: bool,
}
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
#[diesel(table_name = capability_set_capabilities)]
#[diesel(primary_key(capability_set_id, capability))]
#[diesel(belongs_to(CapabilitySet, foreign_key = capability_set_id))]
pub struct CapabilitySetCapability {
pub capability_set_id: Uuid,
pub capability: ApiCapability,
}
#[derive(Debug, Insertable)]
#[diesel(table_name = capability_set_capabilities)]
pub struct NewCapabilitySetCapability {
pub capability_set_id: Uuid,
pub capability: ApiCapability,
}
#[derive(Debug, Insertable)]
#[diesel(table_name = api_tokens)]
pub struct NewApiToken {
pub id: Uuid,
pub user_id: Uuid,
pub tenant_id: Uuid,
pub token_prefix: String,
pub token_hash: String,
pub label: Option<String>,
pub expires_at: Option<NaiveDateTime>,
pub capability_set_id: Uuid,
} }
#[derive(Debug, Clone, Queryable, Identifiable)] #[derive(Debug, Clone, Queryable, Identifiable)]
@@ -503,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)]
@@ -512,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)]
@@ -524,14 +51,13 @@ pub struct Document {
pub original_name: String, pub original_name: String,
pub content_type: Option<String>, pub content_type: Option<String>,
pub folder_id: Option<Uuid>, pub folder_id: Option<Uuid>,
pub created_at: NaiveDateTime, pub uploaded_at: NaiveDateTime,
pub updated_at: NaiveDateTime, pub updated_at: NaiveDateTime,
pub deleted_at: Option<NaiveDateTime>, pub deleted_at: Option<NaiveDateTime>,
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 current_version_id: Uuid, pub current_version_id: Uuid,
pub tenant_id: Uuid,
} }
#[derive(Debug, Insertable)] #[derive(Debug, Insertable)]
@@ -546,23 +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, Insertable)]
#[diesel(table_name = magic_tokens)]
pub struct MagicToken {
pub id: Uuid,
pub user_id: Uuid,
pub kind: MagicTokenKind,
pub token_hash: String,
pub metadata: serde_json::Value,
pub expires_at: NaiveDateTime,
pub max_uses: Option<i32>,
pub used_count: i32,
pub created_at: NaiveDateTime,
pub created_by: Option<Uuid>,
pub last_used_at: Option<NaiveDateTime>,
} }
#[derive(Debug, Clone, Queryable, Identifiable, Associations)] #[derive(Debug, Clone, Queryable, Identifiable, Associations)]
@@ -576,8 +85,8 @@ pub struct DocumentVersion {
pub size_bytes: i64, pub size_bytes: i64,
pub checksum: String, pub checksum: String,
pub created_at: NaiveDateTime, pub created_at: NaiveDateTime,
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)]
@@ -589,8 +98,8 @@ pub struct NewDocumentVersion {
pub s3_key: String, pub s3_key: String,
pub size_bytes: i64, pub size_bytes: i64,
pub checksum: String, pub checksum: String,
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)]
@@ -600,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)]
@@ -613,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)]
@@ -654,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)]
@@ -665,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)]
@@ -675,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)]
@@ -684,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)]
@@ -698,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)]
@@ -707,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)]
@@ -718,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)]
@@ -727,20 +204,19 @@ 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)]
#[diesel(table_name = document_correspondents)] #[diesel(table_name = document_correspondents)]
#[diesel(belongs_to(Document))] #[diesel(belongs_to(Document))]
#[diesel(belongs_to(Correspondent))] #[diesel(belongs_to(Correspondent))]
#[diesel(primary_key(document_id, correspondent_id))] #[diesel(primary_key(document_id, correspondent_id, role))]
pub struct DocumentCorrespondent { pub struct DocumentCorrespondent {
pub document_id: Uuid, pub document_id: Uuid,
pub correspondent_id: Uuid, pub correspondent_id: Uuid,
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)]
@@ -748,14 +224,14 @@ pub struct DocumentCorrespondent {
pub struct NewDocumentCorrespondent { pub struct NewDocumentCorrespondent {
pub document_id: Uuid, pub document_id: Uuid,
pub correspondent_id: Uuid, pub correspondent_id: Uuid,
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)]
#[diesel(table_name = user_sessions)] #[diesel(table_name = refresh_tokens)]
#[diesel(belongs_to(User))] #[diesel(belongs_to(User))]
pub struct UserSession { pub struct RefreshToken {
pub id: Uuid, pub id: Uuid,
pub user_id: Uuid, pub user_id: Uuid,
pub token_hash: String, pub token_hash: String,
@@ -764,16 +240,14 @@ pub struct UserSession {
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)]
#[diesel(table_name = user_sessions)] #[diesel(table_name = refresh_tokens)]
pub struct NewUserSession { pub struct NewRefreshToken {
pub id: Uuid, pub id: Uuid,
pub user_id: Uuid, pub user_id: Uuid,
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,
} }
-126
View File
@@ -1,126 +0,0 @@
use utoipa::openapi::{self, tag::TagBuilder, InfoBuilder};
use utoipa::OpenApi;
pub struct ApiDoc;
impl OpenApi for ApiDoc {
fn openapi() -> openapi::OpenApi {
let mut doc = crate::routes::health::HealthApiDoc::openapi();
doc.merge(crate::routes::auth::AuthApiDoc::openapi());
doc.merge(crate::routes::documents::DocumentsApiDoc::openapi());
doc.merge(crate::routes::folders::FoldersApiDoc::openapi());
doc.merge(crate::routes::tags::TagsApiDoc::openapi());
doc.merge(crate::routes::correspondents::CorrespondentsApiDoc::openapi());
doc.merge(crate::routes::profile::ProfileApiDoc::openapi());
doc.merge(crate::routes::capability_sets::CapabilitySetsApiDoc::openapi());
doc.info = InfoBuilder::new()
.title("Papercrate API")
.version(env!("CARGO_PKG_VERSION"))
.build();
doc.tags = Some(vec![
TagBuilder::new()
.name("Health")
.description(Some("Service health"))
.build(),
TagBuilder::new()
.name("Auth")
.description(Some("Authentication"))
.build(),
TagBuilder::new()
.name("Documents")
.description(Some("Document management"))
.build(),
TagBuilder::new()
.name("Assets")
.description(Some("Document assets"))
.build(),
TagBuilder::new()
.name("Folders")
.description(Some("Folder management"))
.build(),
TagBuilder::new()
.name("Tags")
.description(Some("Tag catalog"))
.build(),
TagBuilder::new()
.name("Correspondents")
.description(Some("Correspondent catalog"))
.build(),
TagBuilder::new()
.name("Profile")
.description(Some("User profile and WebDAV tokens"))
.build(),
TagBuilder::new()
.name("Capability Sets")
.description(Some("Capability set management"))
.build(),
]);
doc
}
}
pub mod schemas {
pub use crate::auth::passkeys::{
AuthenticationChallengeResponse, PasskeyLoginFinishPayload, PasskeyLoginStartPayload,
PasskeyRegistrationFinishPayload, PasskeySummary, RegistrationChallengeResponse,
};
pub use crate::auth::AuthenticatedUser;
pub use crate::documents::asset::{
DocumentAssetDetailResponse, DocumentAssetObjectResponse, DocumentAssetResponse,
DocumentVersionDetailResponse, DocumentVersionResponse,
};
pub use crate::documents::correspondents::DocumentCorrespondentResponse;
pub use crate::models::ApiCapability;
pub use crate::routes::correspondents::{
CorrespondentSummary, CorrespondentUsage, CreateCorrespondentRequest,
UpdateCorrespondentRequest,
};
pub use crate::routes::documents::{
AssetObjectsQuery, AssetRequestQuery, DocumentCheckQuery, MoveDocumentRequest,
RestoreDocumentRequest, UploadDocumentForm,
};
pub use crate::routes::folders::FolderContentsResponse;
pub use crate::routes::tags::{CreateTagRequest, TagCatalogEntry, UpdateTagRequest};
pub use crate::services::auth::{
ApiTokenExchangeRequest, LoginRequest, LoginResponse, LoginResponseVariants,
SignupFinishRequest, SignupStartRequest, SignupStartResponse, TenantListResponse,
TenantSelectionRequest, TenantSelectionResponse, TenantSnippet,
};
pub use crate::services::capability_sets::{
CapabilitySetResponse, CreateCapabilitySetRequest, UpdateCapabilitySetRequest,
};
pub use crate::services::correspondents::{
AssignCorrespondentsRequest, BulkCorrespondentAction, BulkCorrespondentResponse,
BulkCorrespondentsRequest, CorrespondentAssignmentInput,
};
pub use crate::services::documents::{
BulkMoveRequest, BulkMoveResponse, BulkReanalyzeResponse, BulkReanalyzeSelectionRequest,
DocumentCheckResponse, DocumentDetailResponse, DocumentListQuery, DocumentMetadataUpdate,
DocumentResponse, DocumentStatusFilter, TagResponse, UpdateDocumentRequest,
};
pub use crate::services::folders::{
CreateFolderRequest, EnsureFolderPathRequest, FolderContentsQuery, FolderInfo,
UpdateFolderRequest,
};
pub use crate::services::profile::{
ApiTokenCreatedResponse, ApiTokenResponse, CreateApiTokenRequest, RevokePasskeyQuery,
};
pub use crate::services::tags::{
AssignTagsRequest, BulkTagAction, BulkTagRequest, BulkTagResponse,
};
}
#[cfg(test)]
mod tests {
use super::ApiDoc;
use utoipa::OpenApi;
#[test]
fn openapi_serializes() {
let spec = ApiDoc::openapi();
let _ = serde_json::to_string(&spec).expect("serialize openapi");
}
}
+221 -228
View File
@@ -1,274 +1,267 @@
use axum::{ use axum::{
extract::State, extract::State,
http::{HeaderMap, StatusCode}, http::{header::SET_COOKIE, HeaderMap, HeaderValue, StatusCode},
response::Response,
Json, Json,
}; };
use axum_extra::{ use axum_extra::{headers::Cookie, typed_header::TypedHeader};
headers::{authorization::Bearer, Authorization, Cookie}, use chrono::{Duration as ChronoDuration, Utc};
typed_header::TypedHeader, use diesel::prelude::*;
}; use rand::{rngs::OsRng, RngCore};
use utoipa::OpenApi; use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use uuid::Uuid;
use crate::{ use crate::{
auth::{ auth::{password, AuthenticatedUser},
passkeys::{
AuthenticationChallengeResponse, PasskeyLoginFinishPayload, PasskeyLoginStartPayload,
PasskeyRegistrationFinishPayload, PasskeySummary, RegistrationChallengeResponse,
},
AuthenticatedUser, TenantScopedConn,
},
error::{AppError, AppResult}, error::{AppError, AppResult},
http::responders::JsonResponse, models::{NewRefreshToken, RefreshToken, User},
services::auth::{ schema::{refresh_tokens, users::dsl},
ApiTokenExchangeRequest, AuthService, LoginRequest, LoginResponse, LoginResponseVariants,
SignupFinishRequest, SignupStartRequest, SignupStartResponse, TenantListResponse,
TenantSelectionRequest, TenantSelectionResponse, TenantSnippet, SESSION_COOKIE_NAME,
},
state::AppState, state::AppState,
}; };
#[derive(OpenApi)] use crate::schema::refresh_tokens::dsl as refresh_dsl;
#[openapi(
paths( const REFRESH_COOKIE_NAME: &str = "refresh_token";
login,
api_token_exchange, #[derive(Deserialize)]
signup_start, pub struct LoginRequest {
signup_finish, pub username: String,
refresh, pub password: String,
logout, }
me,
list_tenants, #[derive(Serialize)]
select_tenant, pub struct LoginResponse {
passkey_register_start, pub access_token: String,
passkey_register_finish, pub token_type: String,
passkey_login_start, pub expires_in: i64,
passkey_login_finish, }
),
components(schemas(
LoginRequest,
ApiTokenExchangeRequest,
SignupStartRequest,
SignupStartResponse,
SignupFinishRequest,
LoginResponse,
LoginResponseVariants,
TenantSnippet,
TenantSelectionResponse,
TenantSelectionRequest,
TenantListResponse,
crate::auth::AuthenticatedUser,
crate::auth::passkeys::RegistrationChallengeResponse,
crate::auth::passkeys::AuthenticationChallengeResponse,
crate::auth::passkeys::PasskeySummary,
crate::auth::passkeys::PasskeyRegistrationFinishPayload,
crate::auth::passkeys::PasskeyLoginStartPayload,
crate::auth::passkeys::PasskeyLoginFinishPayload,
crate::models::ApiCapability,
))
)]
pub struct AuthApiDoc;
#[utoipa::path(
post,
path = "/api/auth/login",
request_body = LoginRequest,
responses(
(status = 200, description = "Login succeeded", body = LoginResponseVariants),
(status = 401, description = "Invalid credentials")
),
tag = "Auth"
)]
pub 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>)> {
AuthService::new(&state).login(payload) let mut conn = state.db()?;
let user: User = dsl::users
.filter(dsl::username.eq(&payload.username))
.first(&mut conn)?;
let valid = password::verify_password(&payload.password, &user.password_hash)
.map_err(|_| AppError::unauthorized())?;
if !valid {
return Err(AppError::unauthorized());
} }
#[utoipa::path( let access_token = state
post, .jwt
path = "/api/auth/exchange-api-token", .generate_token(user.id, &user.username, &user.role)
request_body = ApiTokenExchangeRequest, .map_err(AppError::from)?;
responses((status = 200, description = "Access token issued", body = LoginResponse)),
tag = "Auth" let now = Utc::now();
)] let refresh_value = generate_refresh_token();
pub async fn api_token_exchange( let refresh_hash = hash_refresh_token(&refresh_value);
State(state): State<AppState>, let refresh_expires_at = now + ChronoDuration::days(state.config.refresh_token_expiry_days);
Json(payload): Json<ApiTokenExchangeRequest>,
) -> AppResult<JsonResponse<LoginResponse>> { let new_refresh = NewRefreshToken {
AuthService::new(&state).exchange_api_token(payload) id: Uuid::new_v4(),
user_id: user.id,
token_hash: refresh_hash,
issued_at: now.naive_utc(),
expires_at: refresh_expires_at.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, &refresh_value, refresh_expires_at),
);
Ok((
headers,
Json(LoginResponse {
access_token,
token_type: "Bearer".to_string(),
expires_in: state.config.jwt_expiry_minutes * 60,
}),
))
} }
#[utoipa::path(
post,
path = "/api/auth/signup/start",
request_body = SignupStartRequest,
responses(
(status = 200, description = "Signup challenge created", body = SignupStartResponse),
(status = 400, description = "Invalid signup request"),
(status = 409, description = "Username already exists")
),
tag = "Auth"
)]
pub async fn signup_start(
State(state): State<AppState>,
Json(payload): Json<SignupStartRequest>,
) -> AppResult<JsonResponse<SignupStartResponse>> {
AuthService::new(&state).signup_start(payload)
}
#[utoipa::path(
post,
path = "/api/auth/signup/finish",
request_body = SignupFinishRequest,
responses(
(status = 200, description = "Signup completed", body = LoginResponseVariants),
(status = 400, description = "Invalid signup completion"),
(status = 409, description = "Username already exists")
),
tag = "Auth"
)]
pub async fn signup_finish(
State(state): State<AppState>,
Json(payload): Json<SignupFinishRequest>,
) -> AppResult<Response> {
AuthService::new(&state).signup_finish(payload)
}
#[utoipa::path(
post,
path = "/api/auth/refresh",
responses(
(status = 200, description = "Refreshed access token", body = LoginResponse),
(status = 401, description = "Missing or invalid refresh token")
),
tag = "Auth"
)]
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(SESSION_COOKIE_NAME) .get(REFRESH_COOKIE_NAME)
.ok_or_else(AppError::unauthorized)?; .ok_or_else(AppError::unauthorized)?;
AuthService::new(&state).refresh(refresh_value) let hashed = hash_refresh_token(refresh_value);
let mut conn = state.db()?;
let now = Utc::now();
let now_naive = now.naive_utc();
let token = match refresh_dsl::refresh_tokens
.filter(refresh_dsl::token_hash.eq(&hashed))
.filter(refresh_dsl::revoked_at.is_null())
.filter(refresh_dsl::expires_at.gt(now_naive))
.first::<RefreshToken>(&mut conn)
{
Ok(token) => token,
Err(diesel::result::Error::NotFound) => return Err(AppError::unauthorized()),
Err(err) => return Err(AppError::from(err)),
};
diesel::update(refresh_dsl::refresh_tokens.filter(refresh_dsl::id.eq(token.id)))
.set((
refresh_dsl::revoked_at.eq(now_naive),
refresh_dsl::updated_at.eq(now_naive),
))
.execute(&mut conn)?;
let user: User = dsl::users
.find(token.user_id)
.first(&mut conn)
.map_err(AppError::from)?;
let access_token = state
.jwt
.generate_token(user.id, &user.username, &user.role)
.map_err(AppError::from)?;
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,
}),
))
} }
#[utoipa::path(
post,
path = "/api/auth/select-tenant",
request_body = TenantSelectionRequest,
responses((status = 200, description = "Tenant selected", body = LoginResponse)),
tag = "Auth"
)]
pub async fn select_tenant(
State(state): State<AppState>,
TypedHeader(Authorization(bearer)): TypedHeader<Authorization<Bearer>>,
Json(payload): Json<TenantSelectionRequest>,
) -> AppResult<Response> {
AuthService::new(&state).select_tenant(bearer.token(), payload.tenant_id)
}
#[utoipa::path(
post,
path = "/api/auth/logout",
responses((status = 204, description = "Session revoked")),
tag = "Auth"
)]
pub async fn logout( pub async fn logout(
State(state): State<AppState>, State(state): State<AppState>,
TenantScopedConn { mut conn, user, .. }: TenantScopedConn, user: AuthenticatedUser,
jar: Option<TypedHeader<Cookie>>, jar: Option<TypedHeader<Cookie>>,
) -> AppResult<(HeaderMap, StatusCode)> { ) -> AppResult<(HeaderMap, StatusCode)> {
let refresh_cookie = jar.as_ref().and_then(|cookies| { let mut conn = state.db()?;
cookies let now = Utc::now().naive_utc();
.get(SESSION_COOKIE_NAME) let mut rows_affected = 0;
.map(|value| value.to_owned())
}); if let Some(cookies) = jar {
AuthService::new(&state).logout(&mut conn, &user, refresh_cookie.as_deref()) if let Some(value) = cookies.get(REFRESH_COOKIE_NAME) {
let hashed = hash_refresh_token(value);
rows_affected = diesel::update(
refresh_dsl::refresh_tokens
.filter(refresh_dsl::token_hash.eq(hashed))
.filter(refresh_dsl::user_id.eq(user.user_id))
.filter(refresh_dsl::revoked_at.is_null()),
)
.set((
refresh_dsl::revoked_at.eq(now),
refresh_dsl::updated_at.eq(now),
))
.execute(&mut conn)
.unwrap_or(0);
}
}
if rows_affected == 0 {
let _ = diesel::update(
refresh_dsl::refresh_tokens
.filter(refresh_dsl::user_id.eq(user.user_id))
.filter(refresh_dsl::revoked_at.is_null()),
)
.set((
refresh_dsl::revoked_at.eq(now),
refresh_dsl::updated_at.eq(now),
))
.execute(&mut conn);
}
let mut headers = HeaderMap::new();
headers.insert(SET_COOKIE, build_clear_refresh_cookie(&state));
Ok((headers, StatusCode::NO_CONTENT))
} }
#[utoipa::path(
get,
path = "/api/auth/me",
responses((status = 200, description = "Current session", body = crate::auth::AuthenticatedUser)),
tag = "Auth"
)]
pub async fn me(user: AuthenticatedUser) -> Json<AuthenticatedUser> { pub async fn me(user: AuthenticatedUser) -> Json<AuthenticatedUser> {
Json(user) Json(user)
} }
#[utoipa::path( fn hash_refresh_token(token: &str) -> String {
get, let mut hasher = Sha256::new();
path = "/api/auth/tenants", hasher.update(token.as_bytes());
responses((status = 200, description = "List of tenants", body = TenantListResponse)), hex::encode(hasher.finalize())
tag = "Auth"
)]
pub async fn list_tenants(
State(state): State<AppState>,
user: AuthenticatedUser,
) -> AppResult<JsonResponse<TenantListResponse>> {
AuthService::new(&state).list_tenants(user)
} }
#[utoipa::path( fn generate_refresh_token() -> String {
post, let mut bytes = [0u8; 32];
path = "/api/auth/passkeys/register/start", OsRng.fill_bytes(&mut bytes);
responses((status = 200, body = crate::auth::passkeys::RegistrationChallengeResponse)), hex::encode(bytes)
tag = "Auth"
)]
pub async fn passkey_register_start(
State(state): State<AppState>,
user: AuthenticatedUser,
) -> AppResult<JsonResponse<RegistrationChallengeResponse>> {
AuthService::new(&state).passkey_register_start(user)
} }
#[utoipa::path( fn build_refresh_cookie(
post, state: &AppState,
path = "/api/auth/passkeys/register/finish", token: &str,
request_body = crate::auth::passkeys::PasskeyRegistrationFinishPayload, expires_at: chrono::DateTime<Utc>,
responses((status = 201, body = crate::auth::passkeys::PasskeySummary)), ) -> HeaderValue {
tag = "Auth" let max_age = ChronoDuration::days(state.config.refresh_token_expiry_days).num_seconds();
)]
pub async fn passkey_register_finish( let mut parts = vec![format!("{}={}", REFRESH_COOKIE_NAME, token)];
State(state): State<AppState>, parts.push("Path=/".into());
user: AuthenticatedUser, parts.push("HttpOnly".into());
Json(payload): Json<PasskeyRegistrationFinishPayload>, parts.push("SameSite=Strict".into());
) -> AppResult<JsonResponse<PasskeySummary>> { parts.push(format!("Max-Age={}", max_age));
AuthService::new(&state).passkey_register_finish(user, payload) parts.push(format!("Expires={}", expires_at.to_rfc2822()));
if state.config.refresh_cookie_secure {
parts.push("Secure".into());
}
if let Some(domain) = &state.config.refresh_cookie_domain {
parts.push(format!("Domain={}", domain));
} }
#[utoipa::path( HeaderValue::from_str(&parts.join("; ")).expect("valid refresh cookie")
post,
path = "/api/auth/passkeys/login/start",
request_body = crate::auth::passkeys::PasskeyLoginStartPayload,
responses((status = 200, body = crate::auth::passkeys::AuthenticationChallengeResponse)),
tag = "Auth"
)]
pub async fn passkey_login_start(
State(state): State<AppState>,
Json(payload): Json<PasskeyLoginStartPayload>,
) -> AppResult<JsonResponse<AuthenticationChallengeResponse>> {
AuthService::new(&state).passkey_login_start(&payload.username)
} }
#[utoipa::path( fn build_clear_refresh_cookie(state: &AppState) -> HeaderValue {
post, let mut parts = vec![format!("{}=", REFRESH_COOKIE_NAME)];
path = "/api/auth/passkeys/login/finish", parts.push("Path=/".into());
request_body = crate::auth::passkeys::PasskeyLoginFinishPayload, parts.push("HttpOnly".into());
responses( parts.push("SameSite=Strict".into());
(status = 200, description = "Passkey login successful", body = LoginResponseVariants), parts.push("Max-Age=0".into());
(status = 401, description = "Authentication failed") parts.push("Expires=Thu, 01 Jan 1970 00:00:00 GMT".into());
), if state.config.refresh_cookie_secure {
tag = "Auth" parts.push("Secure".into());
)] }
pub async fn passkey_login_finish( if let Some(domain) = &state.config.refresh_cookie_domain {
State(state): State<AppState>, parts.push(format!("Domain={}", domain));
Json(payload): Json<PasskeyLoginFinishPayload>, }
) -> AppResult<Response> {
AuthService::new(&state).passkey_login_finish(payload) HeaderValue::from_str(&parts.join("; ")).expect("valid refresh cookie")
} }
-134
View File
@@ -1,134 +0,0 @@
use axum::{extract::Path, http::StatusCode, Json};
use utoipa::OpenApi;
use uuid::Uuid;
use crate::{
auth::TenantScopedConn,
error::AppResult,
http::responders::JsonResponse,
services::capability_sets::{
CapabilitySetResponse, CapabilitySetService, CreateCapabilitySetRequest,
UpdateCapabilitySetRequest,
},
};
#[utoipa::path(
get,
path = "/api/capability-sets",
responses((status = 200, body = [CapabilitySetResponse])),
tag = "Capability Sets"
)]
pub async fn list_capability_sets(
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<JsonResponse<Vec<CapabilitySetResponse>>> {
CapabilitySetService::new().list(&mut conn, tenant_id)
}
#[utoipa::path(
get,
path = "/api/capabilities",
responses((status = 200, body = [crate::models::ApiCapability])),
tag = "Capability Sets"
)]
pub async fn list_capabilities(
TenantScopedConn { .. }: TenantScopedConn,
) -> AppResult<JsonResponse<Vec<crate::models::ApiCapability>>> {
CapabilitySetService::new().list_capabilities()
}
#[utoipa::path(
get,
path = "/api/capability-sets/{id}",
params(("id" = Uuid, Path, description = "Capability set ID")),
responses((status = 200, body = CapabilitySetResponse), (status = 404, description = "Not found")),
tag = "Capability Sets"
)]
pub async fn get_capability_set(
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
Path(id): Path<Uuid>,
) -> AppResult<JsonResponse<CapabilitySetResponse>> {
CapabilitySetService::new().get(&mut conn, tenant_id, id)
}
#[utoipa::path(
post,
path = "/api/capability-sets",
request_body = CreateCapabilitySetRequest,
responses((status = 201, body = CapabilitySetResponse)),
tag = "Capability Sets"
)]
pub async fn create_capability_set(
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
Json(payload): Json<CreateCapabilitySetRequest>,
) -> AppResult<JsonResponse<CapabilitySetResponse>> {
CapabilitySetService::new().create(&mut conn, tenant_id, payload)
}
#[utoipa::path(
patch,
path = "/api/capability-sets/{id}",
params(("id" = Uuid, Path, description = "Capability set ID")),
request_body = UpdateCapabilitySetRequest,
responses((status = 200, body = CapabilitySetResponse)),
tag = "Capability Sets"
)]
pub async fn update_capability_set(
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
Path(id): Path<Uuid>,
Json(payload): Json<UpdateCapabilitySetRequest>,
) -> AppResult<JsonResponse<CapabilitySetResponse>> {
CapabilitySetService::new().update(&mut conn, tenant_id, id, payload)
}
#[utoipa::path(
delete,
path = "/api/capability-sets/{id}",
params(("id" = Uuid, Path, description = "Capability set ID")),
responses((status = 204), (status = 409, description = "Set in use")),
tag = "Capability Sets"
)]
pub async fn delete_capability_set(
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
Path(id): Path<Uuid>,
) -> AppResult<StatusCode> {
CapabilitySetService::new().delete(&mut conn, tenant_id, id)
}
#[derive(OpenApi)]
#[openapi(
paths(
crate::routes::capability_sets::list_capability_sets,
crate::routes::capability_sets::list_capabilities,
crate::routes::capability_sets::get_capability_set,
crate::routes::capability_sets::create_capability_set,
crate::routes::capability_sets::update_capability_set,
crate::routes::capability_sets::delete_capability_set,
),
components(schemas(
crate::models::ApiCapability,
crate::services::capability_sets::CapabilitySetResponse,
crate::services::capability_sets::CreateCapabilitySetRequest,
crate::services::capability_sets::UpdateCapabilitySetRequest,
))
)]
pub struct CapabilitySetsApiDoc;
+101 -160
View File
@@ -1,54 +1,53 @@
use std::collections::HashMap; use std::collections::{BTreeMap, HashMap};
use axum::{extract::Path, http::StatusCode, Json}; use axum::{
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
Json,
};
use chrono::Utc; use 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};
use serde_json::Value; use serde_json::Value;
use utoipa::ToSchema;
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
auth::TenantScopedConn,
error::{AppError, AppResult}, error::{AppError, AppResult},
http::responders::{no_content, ok_json, IntoAppResult, JsonResponse, RowsAffectedExt},
models::{Correspondent, NewCorrespondent}, models::{Correspondent, NewCorrespondent},
schema::{correspondents, document_correspondents}, schema::{correspondents, document_correspondents},
utils::{ state::AppState,
named_entity::{ensure_name_available, normalize_name},
time::to_iso,
},
}; };
#[derive(Serialize, ToSchema)] use super::documents::to_iso;
#[derive(Serialize)]
pub struct CorrespondentUsage { pub struct CorrespondentUsage {
pub total: i64, pub total: i64,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub by_role: BTreeMap<String, i64>,
} }
#[derive(Serialize, ToSchema)] #[derive(Serialize)]
pub struct CorrespondentSummary { pub struct CorrespondentSummary {
pub id: Uuid, pub id: Uuid,
pub name: String, pub name: String,
#[schema(value_type = Object)]
pub metadata: Value, pub metadata: Value,
pub created_at: String, pub created_at: String,
pub updated_at: String, pub updated_at: String,
pub usage: CorrespondentUsage, pub usage: CorrespondentUsage,
} }
#[derive(Deserialize, ToSchema)] #[derive(Deserialize)]
pub struct CreateCorrespondentRequest { pub struct CreateCorrespondentRequest {
pub name: String, pub name: String,
#[serde(default)] #[serde(default)]
#[schema(nullable, value_type = Object)]
pub metadata: Option<Value>, pub metadata: Option<Value>,
} }
#[derive(Deserialize, ToSchema)] #[derive(Deserialize)]
pub struct UpdateCorrespondentRequest { pub struct UpdateCorrespondentRequest {
#[schema(nullable)]
pub name: Option<String>, pub name: Option<String>,
#[schema(nullable, value_type = Object)]
pub metadata: Option<Value>, pub metadata: Option<Value>,
} }
@@ -59,72 +58,62 @@ struct CorrespondentChangeset<'a> {
metadata: Option<&'a Value>, metadata: Option<&'a Value>,
} }
#[utoipa::path(
get,
path = "/api/correspondents",
responses((status = 200, description = "Correspondents", body = [CorrespondentSummary])),
tag = "Correspondents"
)]
pub async fn list_correspondents( pub async fn list_correspondents(
TenantScopedConn { State(state): State<AppState>,
mut conn, ) -> AppResult<Json<Vec<CorrespondentSummary>>> {
tenant_id, let mut conn = state.db()?;
..
}: TenantScopedConn,
) -> AppResult<JsonResponse<Vec<CorrespondentSummary>>> {
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, 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,
.select((document_correspondents::correspondent_id, count_star())) document_correspondents::role,
))
.select((
document_correspondents::correspondent_id,
document_correspondents::role,
count_star(),
))
.load(&mut conn)?; .load(&mut conn)?;
let mut usage_map: HashMap<Uuid, i64> = HashMap::new(); let mut usage_map: HashMap<Uuid, BTreeMap<String, i64>> = HashMap::new();
for (correspondent_id, count) in usage_rows { for (correspondent_id, role, count) in usage_rows {
usage_map.insert(correspondent_id, count); usage_map
.entry(correspondent_id)
.or_default()
.insert(role, count);
} }
let mut response = Vec::with_capacity(correspondents_list.len()); let mut response = Vec::with_capacity(correspondents_list.len());
for correspondent in correspondents_list { for correspondent in correspondents_list {
let total = usage_map.remove(&correspondent.id).unwrap_or(0); let role_counts = usage_map.remove(&correspondent.id).unwrap_or_default();
response.push(build_summary(correspondent, total)); response.push(build_summary(correspondent, role_counts));
} }
ok_json(response) Ok(Json(response))
} }
#[utoipa::path(
post,
path = "/api/correspondents",
request_body = CreateCorrespondentRequest,
responses((status = 200, description = "Correspondent created", body = CorrespondentSummary)),
tag = "Correspondents"
)]
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<JsonResponse<CorrespondentSummary>> { ) -> AppResult<Json<CorrespondentSummary>> {
let name = normalize_name(&payload.name, || { let name = payload.name.trim();
AppError::bad_request("name must not be empty") if name.is_empty() {
})?; return Err(AppError::bad_request("name must not be empty"));
}
let metadata_value = normalize_metadata(payload.metadata); let metadata_value = normalize_metadata(payload.metadata);
let new_id = Uuid::new_v4(); let new_id = Uuid::new_v4();
let new_correspondent = NewCorrespondent { let new_correspondent = NewCorrespondent {
id: new_id, id: new_id,
name: name.clone(), 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)
@@ -136,56 +125,36 @@ 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)
.into_app_result()?;
ok_json(build_summary(correspondent, 0))
} }
#[utoipa::path(
patch,
path = "/api/correspondents/{id}",
params(("id" = Uuid, Path, description = "Correspondent ID")),
request_body = UpdateCorrespondentRequest,
responses((status = 200, description = "Correspondent updated", body = CorrespondentSummary)),
tag = "Correspondents"
)]
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<JsonResponse<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)
.into_app_result()?;
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 {
let normalized = normalize_name(candidate, || { let trimmed = candidate.trim();
AppError::bad_request("name must not be empty") if trimmed.is_empty() {
})?; return Err(AppError::bad_request("name must not be empty"));
if normalized != existing.name { }
ensure_name_available( if trimmed != existing.name {
|| { let duplicate = correspondents::table
correspondents::table .filter(correspondents::name.eq(trimmed))
.filter(correspondents::name.eq(&normalized))
.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() {
|| AppError::bad_request("correspondent name already exists"), return Err(AppError::bad_request("correspondent name already exists"));
)?; }
new_name = Some(normalized); new_name = Some(trimmed.to_string());
} }
} }
@@ -198,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 ok_json(build_summary(existing.clone(), usage)); return Ok(Json(build_summary(existing.clone(), usage)));
} }
let mut changeset = CorrespondentChangeset::default(); let mut changeset = CorrespondentChangeset::default();
@@ -211,42 +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)?;
.into_app_result()?
.or_not_found()?;
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)?;
.into_app_result()?; Ok(Json(build_summary(updated, usage)))
let usage = load_usage_for_correspondent(&mut conn, tenant_id, correspondent_id)?;
ok_json(build_summary(updated, usage))
} }
#[utoipa::path(
delete,
path = "/api/correspondents/{id}",
params(("id" = Uuid, Path, description = "Correspondent ID")),
responses((status = 204, description = "Correspondent deleted")),
tag = "Correspondents"
)]
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)?;
@@ -257,25 +208,29 @@ pub async fn delete_correspondent(
)); ));
} }
diesel::delete( let deleted =
correspondents::table diesel::delete(correspondents::table.find(correspondent_id)).execute(&mut conn)?;
.filter(correspondents::id.eq(correspondent_id)) if deleted == 0 {
.filter(correspondents::tenant_id.eq(tenant_id)), return Err(AppError::not_found());
) }
.execute(&mut conn) Ok(StatusCode::NO_CONTENT)
.into_app_result()?
.or_not_found()?;
no_content()
} }
fn build_summary(correspondent: Correspondent, total: i64) -> CorrespondentSummary { fn build_summary(
correspondent: Correspondent,
role_counts: BTreeMap<String, i64>,
) -> CorrespondentSummary {
let total = role_counts.values().copied().sum();
CorrespondentSummary { CorrespondentSummary {
id: correspondent.id, id: correspondent.id,
name: correspondent.name, name: correspondent.name,
metadata: correspondent.metadata, metadata: correspondent.metadata,
created_at: to_iso(correspondent.created_at), created_at: to_iso(correspondent.created_at),
updated_at: to_iso(correspondent.updated_at), updated_at: to_iso(correspondent.updated_at),
usage: CorrespondentUsage { total }, usage: CorrespondentUsage {
total,
by_role: role_counts,
},
} }
} }
@@ -288,31 +243,17 @@ 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<i64> { ) -> AppResult<BTreeMap<String, i64>> {
let total: 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)
.select(count_star()) .select((document_correspondents::role, count_star()))
.get_result(conn)?; .load(conn)?;
Ok(total) let mut map = BTreeMap::new();
for (role, count) in rows {
map.insert(role, count);
}
Ok(map)
} }
#[derive(utoipa::OpenApi)]
#[openapi(
paths(
crate::routes::correspondents::list_correspondents,
crate::routes::correspondents::create_correspondent,
crate::routes::correspondents::update_correspondent,
crate::routes::correspondents::delete_correspondent
),
components(schemas(
crate::routes::correspondents::CorrespondentSummary,
crate::routes::correspondents::CorrespondentUsage,
crate::routes::correspondents::CreateCorrespondentRequest,
crate::routes::correspondents::UpdateCorrespondentRequest
))
)]
pub struct CorrespondentsApiDoc;
File diff suppressed because it is too large Load Diff
+329 -176
View File
@@ -2,129 +2,168 @@ use axum::{
extract::{Json, Path, Query, State}, extract::{Json, Path, Query, State},
http::StatusCode, http::StatusCode,
}; };
use utoipa::OpenApi; use diesel::{dsl::exists, prelude::*, PgConnection};
use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
use crate::models::{Document, Folder, NewFolder};
use crate::schema::{documents, folders};
use crate::state::AppState;
use crate::{ use crate::{
auth::TenantScopedConn, auth::AuthenticatedUser,
error::{AppError, AppResult}, error::{AppError, AppResult},
http::responders::{created_json, no_content, ok_json, JsonResponse},
services::folders::{
CreateFolderRequest, EnsureFolderPathRequest, FolderContentsData, FolderContentsQuery,
FolderInfo, FolderService, FolderTreeNode, UpdateFolderRequest,
},
state::AppState,
}; };
use crate::services::documents::DocumentResponse; use super::documents::{
load_correspondents_for_documents, load_primary_assets, load_tags_for_documents,
to_document_response, to_iso, DocumentResponse,
};
#[derive(utoipa::ToSchema, serde::Serialize)] #[derive(Deserialize)]
pub struct CreateFolderRequest {
pub name: String,
pub parent_id: Option<Uuid>,
}
#[derive(Deserialize)]
pub struct EnsureFolderPathRequest {
pub parent_id: Option<Uuid>,
pub segments: Vec<String>,
}
#[derive(Deserialize)]
pub struct UpdateFolderRequest {
#[serde(default)]
pub parent_id: Option<Option<Uuid>>,
pub name: Option<String>,
}
#[derive(Serialize)]
pub struct FolderResponse { pub struct FolderResponse {
pub folder: FolderInfo, pub folder: FolderInfo,
} }
#[derive(utoipa::ToSchema, serde::Serialize)] #[derive(Serialize)]
pub struct FolderContentsResponse { pub struct FolderContentsResponse {
#[schema(nullable)]
pub folder: Option<FolderInfo>, pub folder: Option<FolderInfo>,
pub subfolders: Vec<FolderInfo>, pub subfolders: Vec<FolderInfo>,
pub documents: Vec<DocumentResponse>, pub documents: Vec<DocumentResponse>,
} }
#[utoipa::path( #[derive(Deserialize)]
get, pub struct FolderContentsQuery {
path = "/api/folders/{id}", #[serde(default = "default_include_documents")]
params(("id" = Uuid, Path, description = "Folder ID")), pub include_documents: bool,
responses((status = 200, description = "Folder detail", body = FolderResponse)), }
tag = "Folders"
)] const fn default_include_documents() -> bool {
pub async fn get_folder( true
State(state): State<AppState>, }
Path(folder_id): Path<Uuid>,
TenantScopedConn { #[derive(Serialize)]
mut conn, pub struct FolderInfo {
tenant_id, pub id: Uuid,
.. pub name: String,
}: TenantScopedConn, pub parent_id: Option<Uuid>,
) -> AppResult<JsonResponse<FolderResponse>> { pub created_at: String,
let service = FolderService::new(&state); pub updated_at: String,
let folder = service.get_folder(&mut conn, tenant_id, folder_id)?;
ok_json(FolderResponse { folder })
} }
#[utoipa::path(
post,
path = "/api/folders/path",
request_body = EnsureFolderPathRequest,
responses((status = 200, description = "Folder path ensured", body = FolderResponse)),
tag = "Folders"
)]
pub async fn ensure_folder_path( pub async fn ensure_folder_path(
State(state): State<AppState>, State(state): State<AppState>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
Json(payload): Json<EnsureFolderPathRequest>, Json(payload): Json<EnsureFolderPathRequest>,
) -> AppResult<JsonResponse<FolderResponse>> { ) -> AppResult<Json<FolderResponse>> {
let service = FolderService::new(&state); if payload.segments.is_empty() {
let folder = service.ensure_folder_path(&mut conn, tenant_id, payload)?; return Err(AppError::bad_request("segments must not be empty"));
ok_json(FolderResponse { folder }) }
let mut conn = state.db()?;
let target_folder = conn.transaction::<Folder, AppError, _>(|conn| {
let mut current_parent = payload.parent_id;
let mut last_folder: Option<Folder> = None;
for raw_name in &payload.segments {
let name = raw_name.trim();
if name.is_empty() {
return Err(AppError::bad_request("folder names must not be empty"));
}
let existing: Option<Folder> = if let Some(parent_id) = current_parent {
folders::table
.filter(folders::parent_id.eq(Some(parent_id)))
.filter(folders::name.eq(name))
.first(conn)
.optional()?
} else {
folders::table
.filter(folders::parent_id.is_null())
.filter(folders::name.eq(name))
.first(conn)
.optional()?
};
let folder = if let Some(folder) = existing {
folder
} else {
let new_folder = NewFolder {
id: Uuid::new_v4(),
name: name.to_string(),
parent_id: current_parent,
};
diesel::insert_into(folders::table)
.values(&new_folder)
.execute(conn)?;
folders::table.find(new_folder.id).first(conn)?
};
current_parent = Some(folder.id);
last_folder = Some(folder);
}
last_folder.ok_or_else(|| AppError::internal("failed to resolve folder path".to_string()))
})?;
Ok(Json(FolderResponse {
folder: folder_to_info(target_folder),
}))
} }
#[utoipa::path(
post,
path = "/api/folders",
request_body = CreateFolderRequest,
responses(
(status = 201, description = "Folder created", body = FolderResponse),
(status = 200, description = "Folder already existed", body = FolderResponse)
),
tag = "Folders"
)]
pub async fn create_folder( pub async fn create_folder(
State(state): State<AppState>, State(state): State<AppState>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
Json(payload): Json<CreateFolderRequest>, Json(payload): Json<CreateFolderRequest>,
) -> AppResult<JsonResponse<FolderResponse>> { ) -> AppResult<Json<FolderResponse>> {
let service = FolderService::new(&state); if payload.name.trim().is_empty() {
let (folder, created) = service.create_folder(&mut conn, tenant_id, payload)?; return Err(AppError::bad_request("name must not be empty"));
let response = FolderResponse { folder };
if created {
created_json(response)
} else {
ok_json(response)
} }
let mut conn = state.db()?;
let new_folder = NewFolder {
id: Uuid::new_v4(),
name: payload.name.trim().to_string(),
parent_id: payload.parent_id,
};
diesel::insert_into(folders::table)
.values(&new_folder)
.execute(&mut conn)?;
let folder: Folder = folders::table.find(new_folder.id).first(&mut conn)?;
Ok(Json(FolderResponse {
folder: folder_to_info(folder),
}))
} }
#[utoipa::path(
get,
path = "/api/folders/{id}/contents",
params(("id" = String, Path, description = "Folder ID or 'root'"), FolderContentsQuery),
responses((status = 200, description = "Folder contents", body = FolderContentsResponse)),
tag = "Folders"
)]
pub async fn list_folder_contents( 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, ) -> AppResult<Json<FolderContentsResponse>> {
tenant_id, let mut conn = state.db()?;
user_id,
..
}: TenantScopedConn,
) -> AppResult<JsonResponse<FolderContentsResponse>> {
let FolderContentsQuery {
include_documents,
sort,
dir,
} = query;
let folder_id = if folder_identifier.eq_ignore_ascii_case("root") { let folder_id = if folder_identifier.eq_ignore_ascii_case("root") {
None None
@@ -135,114 +174,228 @@ pub async fn list_folder_contents(
) )
}; };
let service = FolderService::new(&state); let folder = match folder_id {
let FolderContentsData { Some(id) => Some(folder_to_info(
folder, folders::table.find(id).first::<Folder>(&mut conn)?,
subfolders, )),
documents, None => None,
} = service.list_folder_contents( };
&mut conn,
tenant_id,
folder_id,
sort,
dir,
include_documents,
)?;
let documents = if include_documents { let child_folders: Vec<Folder> = if let Some(parent_id) = folder_id {
service.hydrate_documents(&mut conn, user_id, documents)? folders::table
.filter(folders::parent_id.eq(parent_id))
.order(folders::name.asc())
.load(&mut conn)?
} else {
folders::table
.filter(folders::parent_id.is_null())
.order(folders::name.asc())
.load(&mut conn)?
};
let subfolders = child_folders.into_iter().map(folder_to_info).collect();
let documents = if query.include_documents {
let docs_query = documents::table
.filter(documents::deleted_at.is_null())
.order(documents::uploaded_at.desc());
let docs: Vec<Document> = if let Some(current_folder) = folder_id {
docs_query
.filter(documents::folder_id.eq(current_folder))
.load(&mut conn)?
} else {
docs_query
.filter(documents::folder_id.is_null())
.load(&mut conn)?
};
let doc_ids: Vec<Uuid> = docs.iter().map(|doc| doc.id).collect();
let tags_map = load_tags_for_documents(&mut conn, &doc_ids)?;
let mut correspondents_map = load_correspondents_for_documents(&mut conn, &doc_ids)?;
drop(conn);
let primary_versions = load_primary_assets(&state, &docs).await?;
let mut documents = Vec::with_capacity(doc_ids.len());
for doc in docs {
let tags = tags_map.get(&doc.id).cloned();
let correspondents = correspondents_map.remove(&doc.id).unwrap_or_default();
let current_version = primary_versions.get(&doc.id).cloned();
documents.push(to_document_response(
&state,
user.user_id,
doc,
tags,
correspondents,
current_version,
)?);
}
documents
} else { } else {
Vec::new() Vec::new()
}; };
ok_json(FolderContentsResponse { Ok(Json(FolderContentsResponse {
folder, folder,
subfolders, subfolders,
documents, documents,
}) }))
} }
#[utoipa::path(
get,
path = "/api/folders/tree",
responses((status = 200, description = "Folder hierarchy", body = [FolderTreeNode])),
tag = "Folders"
)]
pub async fn list_folder_tree(
State(state): State<AppState>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<JsonResponse<Vec<FolderTreeNode>>> {
let service = FolderService::new(&state);
let tree = service.list_folder_tree(&mut conn, tenant_id)?;
ok_json(tree)
}
#[utoipa::path(
delete,
path = "/api/folders/{id}",
params(("id" = Uuid, Path, description = "Folder ID")),
responses((status = 204, description = "Folder deleted")),
tag = "Folders"
)]
pub async fn delete_folder( pub async fn delete_folder(
State(state): State<AppState>, State(state): State<AppState>,
Path(folder_id): Path<Uuid>, Path(folder_id): Path<Uuid>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<StatusCode> { ) -> AppResult<StatusCode> {
FolderService::new(&state).delete_folder(&mut conn, tenant_id, folder_id)?; let mut conn = state.db()?;
no_content()
conn.transaction::<_, AppError, _>(|conn| {
folders::table.find(folder_id).first::<Folder>(conn)?;
let has_child_folders: bool = diesel::select(exists(
folders::table.filter(folders::parent_id.eq(Some(folder_id))),
))
.get_result(conn)?;
if has_child_folders {
return Err(AppError::bad_request(
"folder must be empty before deletion",
));
}
let has_documents: bool = diesel::select(exists(
documents::table
.filter(documents::folder_id.eq(Some(folder_id)))
.filter(documents::deleted_at.is_null()),
))
.get_result(conn)?;
if has_documents {
return Err(AppError::bad_request(
"folder must be empty before deletion",
));
}
diesel::delete(folders::table.find(folder_id)).execute(conn)?;
Ok(())
})?;
Ok(StatusCode::NO_CONTENT)
} }
#[utoipa::path(
patch,
path = "/api/folders/{id}",
params(("id" = Uuid, Path, description = "Folder ID")),
request_body = UpdateFolderRequest,
responses((status = 204, description = "Folder updated")),
tag = "Folders"
)]
pub async fn update_folder( pub async fn update_folder(
State(state): State<AppState>, 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> {
FolderService::new(&state).update_folder(&mut conn, tenant_id, folder_id, payload)?; let mut conn = state.db()?;
no_content()
conn.transaction::<(), AppError, _>(|conn| {
let folder: Folder = folders::table.find(folder_id).first(conn)?;
let mut next_parent = folder.parent_id;
let mut parent_changed = false;
if let Some(parent_request) = payload.parent_id {
if parent_request == Some(folder_id) {
return Err(AppError::bad_request("folder cannot be its own parent"));
} }
#[derive(OpenApi)] if let Some(parent_id) = parent_request {
#[openapi( let _parent: Folder = folders::table.find(parent_id).first(conn)?;
paths(
crate::routes::folders::create_folder, let descendant_ids = gather_descendant_folder_ids(conn, folder_id)?;
crate::routes::folders::ensure_folder_path, if descendant_ids.contains(&parent_id) {
crate::routes::folders::get_folder, return Err(AppError::bad_request(
crate::routes::folders::list_folder_contents, "cannot move folder into itself or a descendant",
crate::routes::folders::list_folder_tree, ));
crate::routes::folders::delete_folder, }
crate::routes::folders::update_folder }
),
components(schemas( parent_changed = parent_request != folder.parent_id;
crate::services::folders::CreateFolderRequest, next_parent = parent_request;
crate::services::folders::EnsureFolderPathRequest, }
crate::routes::folders::FolderResponse,
crate::services::folders::FolderInfo, let mut new_name = folder.name.clone();
crate::services::folders::FolderContentsQuery, let mut name_changed = false;
crate::routes::folders::FolderContentsResponse,
crate::services::folders::FolderTreeNode, if let Some(name) = payload.name {
crate::services::folders::UpdateFolderRequest let trimmed = name.trim();
if trimmed.is_empty() {
return Err(AppError::bad_request("name must not be empty"));
}
if trimmed != folder.name {
new_name = trimmed.to_string();
name_changed = true;
}
}
if !parent_changed && !name_changed {
return Ok(());
}
let conflict = if let Some(parent_id) = next_parent {
folders::table
.filter(folders::parent_id.eq(Some(parent_id)))
.filter(folders::name.eq(&new_name))
.filter(folders::id.ne(folder_id))
.first::<Folder>(conn)
.optional()?
} else {
folders::table
.filter(folders::parent_id.is_null())
.filter(folders::name.eq(&new_name))
.filter(folders::id.ne(folder_id))
.first::<Folder>(conn)
.optional()?
};
if conflict.is_some() {
return Err(AppError::bad_request(
"a folder with the same name already exists in the target",
));
}
diesel::update(folders::table.find(folder_id))
.set((
folders::parent_id.eq(next_parent),
folders::name.eq(&new_name),
)) ))
)] .execute(conn)?;
pub struct FoldersApiDoc;
Ok(())
})?;
Ok(StatusCode::NO_CONTENT)
}
fn folder_to_info(folder: Folder) -> FolderInfo {
FolderInfo {
id: folder.id,
name: folder.name,
parent_id: folder.parent_id,
created_at: to_iso(folder.created_at),
updated_at: to_iso(folder.updated_at),
}
}
pub(super) fn gather_descendant_folder_ids(
conn: &mut PgConnection,
folder_id: Uuid,
) -> AppResult<Vec<Uuid>> {
let mut ids = vec![folder_id];
let mut queue = vec![folder_id];
while let Some(current) = queue.pop() {
let child_ids: Vec<Uuid> = folders::table
.filter(folders::parent_id.eq(Some(current)))
.select(folders::id)
.load(conn)?;
queue.extend(child_ids.iter().copied());
ids.extend(child_ids);
}
Ok(ids)
}
+3 -43
View File
@@ -1,46 +1,6 @@
use axum::{extract::State, http::StatusCode, response::Json}; use axum::{http::StatusCode, response::Json};
use diesel::RunQueryDsl;
use serde_json::json; use serde_json::json;
use crate::state::AppState; pub async fn health_check() -> (StatusCode, Json<serde_json::Value>) {
(StatusCode::OK, Json(json!({ "status": "ok" })))
#[derive(utoipa::OpenApi)]
#[openapi(paths(crate::routes::health::health_check))]
pub struct HealthApiDoc;
#[utoipa::path(
get,
path = "/api/health",
responses((status = 200, description = "Service is healthy")),
tag = "Health"
)]
pub async fn health_check(State(state): State<AppState>) -> (StatusCode, Json<serde_json::Value>) {
let database_ok = match state.db_unscoped() {
Ok(mut conn) => diesel::sql_query("SELECT 1")
.execute(&mut conn)
.map(|_| true)
.unwrap_or_else(|err| {
tracing::error!(error = ?err, "health check database ping failed");
false
}),
Err(err) => {
tracing::error!(error = ?err, "health check database connection failed");
false
}
};
let status = if database_ok {
StatusCode::OK
} else {
StatusCode::SERVICE_UNAVAILABLE
};
let payload = json!({
"status": if database_ok { "ok" } else { "error" },
"checks": {
"database": if database_ok { "ok" } else { "unavailable" }
}
});
(status, Json(payload))
} }
+29 -342
View File
@@ -2,31 +2,18 @@ use axum::http::HeaderValue;
use axum::{ use axum::{
extract::DefaultBodyLimit, extract::DefaultBodyLimit,
middleware, middleware,
response::{Html, Json},
routing::{delete, get, patch, post}, routing::{delete, get, patch, post},
Router, Router,
}; };
use std::sync::Arc; use tower_http::cors::{AllowOrigin, CorsLayer};
use tower_http::{
cors::{AllowOrigin, CorsLayer},
trace::{DefaultMakeSpan, DefaultOnFailure, DefaultOnResponse, TraceLayer},
};
use utoipa::OpenApi;
use crate::{ use crate::{auth::AuthenticatedUser, state::AppState};
auth::{capability_guard::RequireCapabilitiesLayer, AuthenticatedUser},
models::ApiCapability,
openapi::ApiDoc,
state::AppState,
};
pub mod auth; pub mod auth;
pub mod capability_sets;
pub mod correspondents; pub mod correspondents;
pub mod documents; pub mod documents;
pub mod folders; pub mod folders;
pub mod health; pub mod health;
pub mod profile;
pub mod tags; pub mod tags;
pub mod webdav; pub mod webdav;
@@ -60,392 +47,92 @@ pub fn create_router(state: AppState) -> Router<()> {
}; };
let auth_routes = Router::new() let auth_routes = Router::new()
.route("/signup/start", post(auth::signup_start))
.route("/signup/finish", post(auth::signup_finish))
.route("/login", post(auth::login)) .route("/login", post(auth::login))
.route("/exchange-api-token", post(auth::api_token_exchange))
.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("/tenants", get(auth::list_tenants))
.route(
"/passkeys/register/start",
post(auth::passkey_register_start),
)
.route(
"/passkeys/register/finish",
post(auth::passkey_register_finish),
)
.route("/passkeys/login/start", post(auth::passkey_login_start))
.route("/passkeys/login/finish", post(auth::passkey_login_finish))
.route("/me", get(auth::me)); .route("/me", get(auth::me));
let documents_routes = Router::new() let documents_routes = Router::new()
.route(
"/check",
get(documents::check_document).layer(RequireCapabilitiesLayer::all([
ApiCapability::DocumentsRead,
])),
)
.route( .route(
"/", "/",
get(documents::list_documents).layer(RequireCapabilitiesLayer::all([ get(documents::list_documents).post(documents::upload_document),
ApiCapability::DocumentsRead,
])),
)
.route(
"/",
post(documents::upload_document).layer(RequireCapabilitiesLayer::all([
ApiCapability::DocumentsWrite,
ApiCapability::DocumentsUpload,
])),
)
.route(
"/bulk/move",
post(documents::bulk_move_documents).layer(RequireCapabilitiesLayer::all([
ApiCapability::DocumentsEdit,
])),
)
.route(
"/bulk/tags",
post(documents::bulk_update_tags).layer(RequireCapabilitiesLayer::all([
ApiCapability::DocumentsEdit,
])),
) )
.route("/reanalyze", post(documents::reanalyze_all_documents))
.route("/bulk/move", post(documents::bulk_move_documents))
.route("/bulk/tags", post(documents::bulk_update_tags))
.route( .route(
"/bulk/correspondents", "/bulk/correspondents",
post(documents::bulk_assign_correspondents).layer(RequireCapabilitiesLayer::all([ post(documents::bulk_assign_correspondents),
ApiCapability::DocumentsEdit,
])),
) )
.route( .route(
"/bulk/reanalyze", "/bulk/reanalyze",
post(documents::reanalyze_selected_documents).layer(RequireCapabilitiesLayer::all([ post(documents::reanalyze_selected_documents),
ApiCapability::DocumentsWrite,
])),
) )
.route( .route(
"/:id", "/:id",
get(documents::get_document).layer(RequireCapabilitiesLayer::all([ get(documents::get_document)
ApiCapability::DocumentsRead, .delete(documents::delete_document)
])), .patch(documents::update_document),
)
.route(
"/:id/trash",
post(documents::trash_document).layer(RequireCapabilitiesLayer::all([
ApiCapability::DocumentsWrite,
])),
)
.route(
"/:id",
delete(documents::delete_document).layer(RequireCapabilitiesLayer::all([
ApiCapability::DocumentsWrite,
])),
)
.route(
"/:id",
patch(documents::update_document).layer(RequireCapabilitiesLayer::all([
ApiCapability::DocumentsEdit,
])),
) )
.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).layer(RequireCapabilitiesLayer::all([ get(documents::list_document_assets).post(documents::request_document_assets),
ApiCapability::DocumentsRead,
])),
)
.route(
"/:id/assets",
post(documents::request_document_assets).layer(RequireCapabilitiesLayer::all([
ApiCapability::DocumentsWrite,
])),
)
.route(
"/:id/folder",
patch(documents::move_document).layer(RequireCapabilitiesLayer::all([
ApiCapability::DocumentsEdit,
])),
)
.route(
"/:id/versions",
get(documents::list_document_versions).layer(RequireCapabilitiesLayer::all([
ApiCapability::DocumentsRead,
])),
)
.route(
"/:id/versions/:version_id",
get(documents::get_document_version).layer(RequireCapabilitiesLayer::all([
ApiCapability::DocumentsRead,
])),
)
.route(
"/:id/restore",
post(documents::restore_document).layer(RequireCapabilitiesLayer::all([
ApiCapability::DocumentsEdit,
])),
)
.route(
"/:id/tags",
post(documents::assign_tags).layer(RequireCapabilitiesLayer::all([
ApiCapability::DocumentsEdit,
])),
)
.route(
"/:id/tags/:tag_id",
delete(documents::remove_tag).layer(RequireCapabilitiesLayer::all([
ApiCapability::DocumentsEdit,
])),
) )
.route("/:id/folder", patch(documents::move_document))
.route("/:id/tags", post(documents::assign_tags))
.route("/:id/tags/:tag_id", delete(documents::remove_tag))
.route( .route(
"/:id/correspondents", "/:id/correspondents",
post(documents::assign_correspondents).layer(RequireCapabilitiesLayer::all([ post(documents::assign_correspondents),
ApiCapability::DocumentsEdit,
])),
) )
.route( .route(
"/:id/correspondents/:correspondent_id", "/:id/correspondents/:correspondent_id",
delete(documents::remove_correspondent).layer(RequireCapabilitiesLayer::all([ delete(documents::remove_correspondent),
ApiCapability::DocumentsEdit,
])),
); );
let download_routes = let download_routes =
Router::new().route("/download/:token", get(documents::download_with_token)); Router::new().route("/download/:token", get(documents::download_with_token));
let folders_routes = Router::new() let folders_routes = Router::new()
.route( .route("/", post(folders::create_folder))
"/", .route("/path", post(folders::ensure_folder_path))
post(folders::create_folder)
.layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersWrite])),
)
.route(
"/path",
post(folders::ensure_folder_path)
.layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersWrite])),
)
.route(
"/tree",
get(folders::list_folder_tree)
.layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersRead])),
)
.route( .route(
"/:id", "/:id",
get(folders::get_folder) delete(folders::delete_folder).patch(folders::update_folder),
.layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersRead])),
) )
.route( .route("/:id/contents", get(folders::list_folder_contents));
"/:id",
delete(folders::delete_folder)
.layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersWrite])),
)
.route(
"/:id",
patch(folders::update_folder)
.layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersEdit])),
)
.route(
"/:id/contents",
get(folders::list_folder_contents)
.layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersRead])),
);
let tags_routes = Router::new() let tags_routes = Router::new()
.route( .route("/", get(tags::list_tags).post(tags::create_tag))
"/", .route("/:id", patch(tags::update_tag).delete(tags::delete_tag));
get(tags::list_tags).layer(RequireCapabilitiesLayer::all([ApiCapability::TagsRead])),
)
.route(
"/",
post(tags::create_tag).layer(RequireCapabilitiesLayer::all([ApiCapability::TagsWrite])),
)
.route(
"/:id",
patch(tags::update_tag).layer(RequireCapabilitiesLayer::all([ApiCapability::TagsEdit])),
)
.route(
"/:id",
delete(tags::delete_tag)
.layer(RequireCapabilitiesLayer::all([ApiCapability::TagsWrite])),
);
let correspondents_routes = Router::new() let correspondents_routes = Router::new()
.route( .route(
"/", "/",
get(correspondents::list_correspondents).layer(RequireCapabilitiesLayer::all([ get(correspondents::list_correspondents).post(correspondents::create_correspondent),
ApiCapability::CorrespondentsRead,
])),
)
.route(
"/",
post(correspondents::create_correspondent).layer(RequireCapabilitiesLayer::all([
ApiCapability::CorrespondentsWrite,
])),
) )
.route( .route(
"/:id", "/:id",
patch(correspondents::update_correspondent).layer(RequireCapabilitiesLayer::all([ patch(correspondents::update_correspondent)
ApiCapability::CorrespondentsEdit, .delete(correspondents::delete_correspondent),
])),
)
.route(
"/:id",
delete(correspondents::delete_correspondent).layer(RequireCapabilitiesLayer::all([
ApiCapability::CorrespondentsWrite,
])),
);
let profile_routes = Router::new()
.route(
"/api-tokens",
get(profile::list_api_tokens)
.layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileRead])),
)
.route(
"/api-tokens",
post(profile::create_api_token)
.layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileWrite])),
)
.route(
"/api-tokens/:id/regenerate",
post(profile::regenerate_api_token)
.layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileWrite])),
)
.route(
"/api-tokens/:id",
delete(profile::delete_api_token)
.layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileWrite])),
)
.route(
"/passkeys",
get(profile::list_passkeys)
.layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileRead])),
)
.route(
"/passkeys/:id",
delete(profile::delete_passkey)
.layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileWrite])),
);
let capability_sets_routes = Router::new()
.route(
"/",
get(capability_sets::list_capability_sets).layer(RequireCapabilitiesLayer::all([
ApiCapability::CapabilitySetsRead,
])),
)
.route(
"/",
post(capability_sets::create_capability_set).layer(RequireCapabilitiesLayer::all([
ApiCapability::CapabilitySetsWrite,
])),
)
.route(
"/:id",
get(capability_sets::get_capability_set).layer(RequireCapabilitiesLayer::all([
ApiCapability::CapabilitySetsRead,
])),
)
.route(
"/:id",
patch(capability_sets::update_capability_set).layer(RequireCapabilitiesLayer::all([
ApiCapability::CapabilitySetsWrite,
])),
)
.route(
"/:id",
delete(capability_sets::delete_capability_set).layer(RequireCapabilitiesLayer::all([
ApiCapability::CapabilitySetsWrite,
])),
);
let capabilities_routes = Router::new().route(
"/",
get(capability_sets::list_capabilities).layer(RequireCapabilitiesLayer::all([
ApiCapability::CapabilitySetsRead,
])),
); );
let protected_state = state.clone(); let protected_state = state.clone();
let assets_routes = Router::new().route(
"/:asset_id",
get(documents::get_document_asset).layer(RequireCapabilitiesLayer::all([
ApiCapability::DocumentsRead,
])),
);
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/profile", profile_routes)
.nest("/api/capability-sets", capability_sets_routes)
.nest("/api/capabilities", capabilities_routes)
.nest("/api/assets", assets_routes)
.layer(middleware::from_extractor_with_state::<AuthenticatedUser, _>(protected_state)); .layer(middleware::from_extractor_with_state::<AuthenticatedUser, _>(protected_state));
let upload_limit = state.config.upload_body_limit_bytes;
let openapi_spec = Arc::new(ApiDoc::openapi());
let docs_router = Router::new()
.route(
"/api/docs",
get(move || async { Html(render_swagger_ui("/api/docs/openapi.json")) }),
)
.route(
"/api/docs/openapi.json",
get({
let spec = openapi_spec.clone();
move || async move { Json((*spec).clone()) }
}),
);
Router::new() Router::new()
.merge(download_routes) .merge(download_routes)
.merge(protected_routes) .merge(protected_routes)
.merge(docs_router)
.nest("/api/auth", auth_routes) .nest("/api/auth", auth_routes)
.route("/api/health", get(health::health_check)) .route("/api/health", get(health::health_check))
.with_state(state) .with_state(state)
.layer(cors) .layer(cors)
.layer(DefaultBodyLimit::max( .layer(DefaultBodyLimit::max(1024 * 1024 * 512))
usize::try_from(upload_limit).unwrap_or(usize::MAX),
))
.layer(
TraceLayer::new_for_http()
.make_span_with(DefaultMakeSpan::new().level(tracing::Level::INFO))
.on_response(DefaultOnResponse::new().level(tracing::Level::INFO))
.on_failure(DefaultOnFailure::new().level(tracing::Level::ERROR)),
)
}
fn render_swagger_ui(spec_url: &str) -> String {
format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Papercrate API Docs</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
<style>
html {{ box-sizing: border-box; font-family: sans-serif; }}
*, *:before, *:after {{ box-sizing: inherit; }}
body {{ margin: 0; background: #fafafa; }}
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script>
window.addEventListener('load', () => {{
window.ui = SwaggerUIBundle({{
url: '{spec_url}',
dom_id: '#swagger-ui',
deepLinking: true,
}});
}});
</script>
</body>
</html>"#
)
} }
-150
View File
@@ -1,150 +0,0 @@
use axum::{
extract::{Path, Query, State},
http::StatusCode,
Json,
};
use utoipa::OpenApi;
use uuid::Uuid;
use crate::{
auth::{passkeys::PasskeySummary, TenantScopedConn},
error::AppResult,
http::responders::JsonResponse,
services::profile::{
ApiTokenCreatedResponse, ApiTokenResponse, CreateApiTokenRequest, ProfileService,
RevokePasskeyQuery,
},
state::AppState,
};
#[utoipa::path(
get,
path = "/api/profile/passkeys",
responses((status = 200, description = "List registered passkeys", body = [PasskeySummary])),
tag = "Profile"
)]
pub async fn list_passkeys(
State(state): State<AppState>,
TenantScopedConn {
mut conn, user_id, ..
}: TenantScopedConn,
) -> AppResult<JsonResponse<Vec<PasskeySummary>>> {
ProfileService::new(&state).list_passkeys(&mut conn, user_id)
}
#[utoipa::path(
get,
path = "/api/profile/api-tokens",
responses((status = 200, description = "List API tokens", body = [ApiTokenResponse])),
tag = "Profile"
)]
pub async fn list_api_tokens(
State(state): State<AppState>,
TenantScopedConn {
mut conn,
tenant_id,
user_id,
..
}: TenantScopedConn,
) -> AppResult<JsonResponse<Vec<ApiTokenResponse>>> {
ProfileService::new(&state).list_api_tokens(&mut conn, tenant_id, user_id)
}
#[utoipa::path(
post,
path = "/api/profile/api-tokens",
request_body = CreateApiTokenRequest,
responses((status = 201, description = "API token created", body = ApiTokenCreatedResponse)),
tag = "Profile"
)]
pub async fn create_api_token(
State(state): State<AppState>,
TenantScopedConn {
mut conn,
tenant_id,
user_id,
..
}: TenantScopedConn,
Json(payload): Json<CreateApiTokenRequest>,
) -> AppResult<JsonResponse<ApiTokenCreatedResponse>> {
ProfileService::new(&state).create_api_token(&mut conn, tenant_id, user_id, payload)
}
#[utoipa::path(
post,
path = "/api/profile/api-tokens/{id}/regenerate",
params(("id" = Uuid, Path, description = "API token ID")),
responses((status = 200, description = "API token regenerated", body = ApiTokenCreatedResponse)),
tag = "Profile"
)]
pub async fn regenerate_api_token(
State(state): State<AppState>,
TenantScopedConn {
mut conn,
tenant_id,
user_id,
..
}: TenantScopedConn,
Path(token_id): Path<Uuid>,
) -> AppResult<JsonResponse<ApiTokenCreatedResponse>> {
ProfileService::new(&state).regenerate_api_token(&mut conn, tenant_id, user_id, token_id)
}
#[utoipa::path(
delete,
path = "/api/profile/api-tokens/{id}",
params(("id" = Uuid, Path, description = "API token ID")),
responses((status = 204, description = "API token revoked")),
tag = "Profile"
)]
pub async fn delete_api_token(
State(state): State<AppState>,
TenantScopedConn {
mut conn, user_id, ..
}: TenantScopedConn,
Path(token_id): Path<Uuid>,
) -> AppResult<StatusCode> {
ProfileService::new(&state).delete_api_token(&mut conn, user_id, token_id)
}
#[utoipa::path(
delete,
path = "/api/profile/passkeys/{id}",
params(
("id" = Uuid, Path, description = "Passkey ID"),
("reason" = Option<String>, Query, description = "Optional reason for revoking the passkey")
),
responses((status = 204, description = "Passkey revoked")),
tag = "Profile"
)]
pub async fn delete_passkey(
State(state): State<AppState>,
TenantScopedConn {
mut conn, user_id, ..
}: TenantScopedConn,
Path(passkey_id): Path<Uuid>,
Query(query): Query<RevokePasskeyQuery>,
) -> AppResult<StatusCode> {
ProfileService::new(&state).delete_passkey(&mut conn, user_id, passkey_id, query.reason)
}
#[derive(OpenApi)]
#[openapi(
paths(
crate::routes::profile::list_api_tokens,
crate::routes::profile::create_api_token,
crate::routes::profile::regenerate_api_token,
crate::routes::profile::delete_api_token,
crate::routes::profile::list_passkeys,
crate::routes::profile::delete_passkey
),
components(schemas(
crate::models::ApiCapability,
crate::services::profile::ApiTokenResponse,
crate::services::profile::ApiTokenCreatedResponse,
crate::services::profile::CreateApiTokenRequest,
crate::services::profile::RevokePasskeyQuery,
crate::auth::passkeys::PasskeySummary
))
)]
pub struct ProfileApiDoc;
+76 -199
View File
@@ -1,26 +1,23 @@
use axum::{extract::Path, http::StatusCode, Json}; use crate::utils::json::{classify_nullable, NullableValue};
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 std::collections::HashMap; use std::collections::HashMap;
use utoipa::ToSchema;
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::error::{AppError, AppResult};
auth::TenantScopedConn, use crate::models::{NewTag, Tag};
error::{AppError, AppResult}, use crate::schema::{document_tags, tags};
http::responders::{no_content, ok_json, IntoAppResult, JsonResponse, RowsAffectedExt}, use crate::state::AppState;
models::{NewTag, Tag},
schema::{document_tags, tags},
utils::{
json::deserialize_patch_field,
named_entity::{ensure_name_available, normalize_name},
},
};
#[derive(Deserialize, ToSchema)] #[derive(Deserialize)]
pub struct CreateTagRequest { pub struct CreateTagRequest {
pub label: String, pub label: String,
#[schema(nullable)]
pub color: Option<String>, pub color: Option<String>,
} }
@@ -31,76 +28,27 @@ struct UpdateTagChangeset<'a> {
color: Option<Option<&'a str>>, color: Option<Option<&'a str>>,
} }
#[cfg(test)] #[derive(Serialize)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn update_tag_request_deserializes_null_fields() {
let request: UpdateTagRequest = serde_json::from_value(json!({
"label": null,
"color": null
}))
.unwrap();
assert!(matches!(request.label, Some(None)));
assert!(matches!(request.color, Some(None)));
}
#[test]
fn update_tag_request_omitted_fields_are_none() {
let request: UpdateTagRequest = serde_json::from_value(json!({})).unwrap();
assert!(request.label.is_none());
assert!(request.color.is_none());
}
}
#[derive(Serialize, ToSchema)]
pub struct TagCatalogEntry { pub struct TagCatalogEntry {
pub id: Uuid, pub id: Uuid,
pub label: String, pub label: String,
#[schema(nullable)]
pub color: Option<String>, pub color: Option<String>,
pub usage_count: i64, pub usage_count: i64,
} }
#[derive(Debug, Default, Deserialize, ToSchema)] pub async fn list_tags(State(state): State<AppState>) -> AppResult<Json<Vec<TagCatalogEntry>>> {
pub struct UpdateTagRequest { let mut conn = state.db()?;
#[serde(default, deserialize_with = "deserialize_patch_field")]
#[schema(nullable, value_type = Option<String>)]
pub label: Option<Option<String>>,
#[serde(default, deserialize_with = "deserialize_patch_field")]
#[schema(nullable, value_type = Option<String>)]
pub color: Option<Option<String>>,
}
#[utoipa::path( let tag_list: Vec<Tag> = tags::table.order(tags::label.asc()).load(&mut conn)?;
get,
path = "/api/tags",
responses((status = 200, description = "Tags", body = [TagCatalogEntry])),
tag = "Tags"
)]
pub async fn list_tags(
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<JsonResponse<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,
@@ -110,33 +58,22 @@ pub async fn list_tags(
}) })
.collect(); .collect();
ok_json(response) Ok(Json(response))
} }
#[utoipa::path(
post,
path = "/api/tags",
request_body = CreateTagRequest,
responses((status = 200, description = "Tag created", body = TagCatalogEntry)),
tag = "Tags"
)]
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<JsonResponse<TagCatalogEntry>> { ) -> AppResult<Json<TagCatalogEntry>> {
let label = normalize_name(&payload.label, || { if payload.label.trim().is_empty() {
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: label.clone(), 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)
@@ -153,80 +90,62 @@ 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)
.into_app_result()?;
ok_json(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,
}) }))
} }
#[utoipa::path(
patch,
path = "/api/tags/{id}",
params(("id" = Uuid, Path, description = "Tag ID")),
request_body = UpdateTagRequest,
responses((status = 200, description = "Tag updated", body = TagCatalogEntry)),
tag = "Tags"
)]
pub async fn update_tag( pub async fn update_tag(
State(state): State<AppState>,
Path(tag_id): Path<Uuid>, Path(tag_id): Path<Uuid>,
TenantScopedConn { Json(body): Json<Value>,
mut conn, ) -> AppResult<Json<TagCatalogEntry>> {
tenant_id, let mut conn = state.db()?;
.. let existing: Tag = tags::table.find(tag_id).first(&mut conn)?;
}: TenantScopedConn, let label_class = classify_nullable(body.get("label")).map_err(AppError::bad_request)?;
Json(payload): Json<UpdateTagRequest>, let color_class = classify_nullable(body.get("color")).map_err(AppError::bad_request)?;
) -> AppResult<JsonResponse<TagCatalogEntry>> {
let existing: Tag = tags::table
.find(tag_id)
.filter(tags::tenant_id.eq(tenant_id))
.first(&mut conn)
.into_app_result()?;
let UpdateTagRequest { label, color } = payload;
if label.is_none() && color.is_none() { if matches!(label_class, NullableValue::Omitted)
&& matches!(color_class, NullableValue::Omitted)
{
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))
.select(count_star()) .select(count_star())
.first(&mut conn)?; .first(&mut conn)?;
return ok_json(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,
}); }));
} }
let mut new_label: Option<String> = None; let mut new_label: Option<String> = None;
let mut label_changed = false; let mut label_changed = false;
match label { match label_class {
None => {} NullableValue::Omitted => {}
Some(None) => { NullableValue::Null => {
return Err(AppError::bad_request("label cannot be null")); return Err(AppError::bad_request("label cannot be null"));
} }
Some(Some(value)) => { NullableValue::String(value) => {
let normalized = let trimmed = value.trim();
normalize_name(&value, || AppError::bad_request("label must not be empty"))?; if trimmed.is_empty() {
if normalized != existing.label { return Err(AppError::bad_request("label must not be empty"));
ensure_name_available( }
|| { if trimmed != existing.label {
tags::table let duplicate = tags::table
.filter(tags::label.eq(&normalized)) .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() {
|| AppError::bad_request("tag label already exists"), return Err(AppError::bad_request("tag label already exists"));
)?; }
new_label = Some(normalized); new_label = Some(trimmed.to_string());
label_changed = true; label_changed = true;
} }
} }
@@ -234,13 +153,13 @@ pub async fn update_tag(
let mut color_change: Option<Option<String>> = None; let mut color_change: Option<Option<String>> = None;
let mut color_changed = false; let mut color_changed = false;
match color { match color_class {
None => {} NullableValue::Omitted => {}
Some(None) => { NullableValue::Null => {
color_change = Some(None); color_change = Some(None);
color_changed = true; color_changed = true;
} }
Some(Some(value)) => { NullableValue::String(value) => {
let trimmed = value.trim(); let trimmed = value.trim();
if trimmed.is_empty() { if trimmed.is_empty() {
return Err(AppError::bad_request("color must not be empty")); return Err(AppError::bad_request("color must not be empty"));
@@ -255,15 +174,14 @@ 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 {
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,
}); }));
} }
let changeset = UpdateTagChangeset { let changeset = UpdateTagChangeset {
@@ -273,53 +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)?;
.into_app_result()?
.or_not_found()?;
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)
.into_app_result()?;
let usage_count: i64 = document_tags::table let usage_count: i64 = document_tags::table
.filter(document_tags::tag_id.eq(tag_id)) .filter(document_tags::tag_id.eq(tag_id))
.filter(document_tags::tenant_id.eq(tenant_id))
.select(count_star()) .select(count_star())
.first(&mut conn)?; .first(&mut conn)?;
ok_json(TagCatalogEntry { 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,
}) }))
} }
#[utoipa::path(
delete,
path = "/api/tags/{id}",
params(("id" = Uuid, Path, description = "Tag ID")),
responses((status = 204, description = "Tag deleted")),
tag = "Tags"
)]
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)?;
@@ -329,30 +226,10 @@ pub async fn delete_tag(
)); ));
} }
diesel::delete( let deleted = diesel::delete(tags::table.find(tag_id)).execute(&mut conn)?;
tags::table if deleted == 0 {
.find(tag_id) return Err(AppError::not_found());
.filter(tags::tenant_id.eq(tenant_id)),
)
.execute(&mut conn)
.into_app_result()?
.or_not_found()?;
no_content()
} }
#[derive(utoipa::OpenApi)] Ok(StatusCode::NO_CONTENT)
#[openapi( }
paths(
crate::routes::tags::list_tags,
crate::routes::tags::create_tag,
crate::routes::tags::update_tag,
crate::routes::tags::delete_tag
),
components(schemas(
crate::routes::tags::CreateTagRequest,
crate::routes::tags::TagCatalogEntry,
crate::routes::tags::UpdateTagRequest
))
)]
pub struct TagsApiDoc;

Some files were not shown because too many files have changed in this diff Show More