40 Commits
Author SHA1 Message Date
nils c062cc6ba0 build
ci / docker (backend, backend/Dockerfile, backend) (push) Failing after 21s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Failing after 21s
2025-11-01 02:40:21 +01:00
nils 09492f452f Revert "build"
This reverts commit b6bc5391cf.
2025-11-01 02:07:58 +01:00
nils b6bc5391cf build 2025-11-01 02:00:16 +01:00
nils ac91efc421 foo 2025-11-01 01:46:14 +01:00
nils 2fba3236ad Add GHCR build/publish workflow 2025-11-01 00:34:51 +01:00
nils 18352caabe webdav token regeneration 2025-10-31 20:37:07 +01:00
nils e9731ec691 webdav fix 2025-10-31 20:10:42 +01:00
nils 175387f0d3 rls 2025-10-31 19:13:11 +01:00
nils 2d8a4432cc style 2025-10-31 18:19:52 +01:00
nils c146084f75 fix 2025-10-31 17:44:33 +01:00
nils 1fcd265eb9 breadcrumbs 2025-10-31 17:43:00 +01:00
nils 1a1e9a80c3 breadcrumbs 2025-10-31 17:24:39 +01:00
nils d14d263e8f health 2025-10-31 15:55:55 +01:00
nils 48bed9a9fe magic token login 2025-10-31 15:16:30 +01:00
nils 91dd3289a9 deskview 2025-10-31 14:27:30 +01:00
nils b969cae524 screenshot 2025-10-31 13:52:48 +01:00
nils aaf9fd1bce fix 2025-10-31 13:44:26 +01:00
nils b254c651a7 fixes 2025-10-31 13:39:06 +01:00
nils 3af656b09f modalheader 2025-10-31 13:19:39 +01:00
nils 321b0d84ae panelheader 2025-10-31 13:12:09 +01:00
nils 6b4a4af5f1 fix 2025-10-31 12:56:17 +01:00
nils faf8fec9c7 revert document types 2025-10-31 12:56:09 +01:00
nils 0a87f33d89 style 2025-10-31 02:34:11 +01:00
nils eacab2ced6 cleanup 2025-10-31 02:19:38 +01:00
nils f796932cd8 cleanup 2025-10-31 02:06:38 +01:00
nils 17db827e3a cleanup 2025-10-31 01:52:51 +01:00
nils 722c2f9a5c tests 2025-10-31 01:26:24 +01:00
nils fa43bc749e cleanup 2025-10-31 01:19:01 +01:00
nils 5b5916ff92 openapi cleanup 2025-10-31 00:39:22 +01:00
nils 5ca7dc9678 cleanup 2025-10-31 00:38:52 +01:00
nils 39d1d80383 cleanup 2025-10-31 00:28:48 +01:00
nils 813ce24aeb document types 2025-10-31 00:13:16 +01:00
nils 3941ec61d3 rename 2025-10-30 23:12:30 +01:00
nils 4a3cb63263 created_at swagger 2025-10-30 23:03:57 +01:00
nils 5386724e0d docs 2025-10-30 22:18:30 +01:00
nils 85f3d90329 frontend cleanup 2025-10-30 18:17:01 +01:00
nils bb34a47fa8 squash database migrations 2025-10-30 17:53:00 +01:00
nils 7fae0e44ac fix 2025-10-30 16:56:23 +01:00
nils 80d21ed0a1 deployment 2025-10-30 16:21:52 +01:00
nils 67fd787a14 cleanup 2025-10-30 14:41:15 +01:00
128 changed files with 12355 additions and 9063 deletions
+26 -2
View File
@@ -5,6 +5,7 @@ on:
branches: branches:
- main - main
- staging - staging
- dev
tags: tags:
- '*' - '*'
@@ -27,13 +28,34 @@ jobs:
with: with:
submodules: recursive submodules: recursive
- name: Login to Docker Registry - name: Compute base tag
id: compute_tag
run: |
short="${GITEA_SHA:0:7}"
if [ "${GITEA_REF_TYPE}" = "tag" ]; then
tag="${GITEA_REF_NAME}"
else
tag="$short"
if [ "${GITEA_REF_NAME}" = "dev" ]; then
tag="${tag}-dev"
fi
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:
@@ -49,5 +71,7 @@ jobs:
push: true push: true
provenance: false provenance: false
tags: | tags: |
${{ 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 }}/${{ gitea.repository }}-${{ matrix.service }}:${{ steps.compute_tag.outputs.base_tag }}
${{ vars.REGISTRY_URL }}/${{ gitea.repository }}-${{ matrix.service }}:${{ gitea.sha }} ${{ vars.REGISTRY_URL }}/${{ gitea.repository }}-${{ matrix.service }}:${{ gitea.sha }}
ghcr.io/${{ vars.GHCR_USERNAME }}/${{ gitea.repository }}-${{ matrix.service }}:${{ steps.compute_tag.outputs.base_tag }}
ghcr.io/${{ vars.GHCR_USERNAME }}/${{ gitea.repository }}-${{ matrix.service }}:${{ gitea.sha }}
+197
View File
@@ -0,0 +1,197 @@
name: Build & Publish Images
on:
push:
branches: [ main, dev ]
tags:
- '*'
workflow_dispatch:
env:
REGISTRY: ghcr.io
IMAGE_BACKEND: papercrate-dms/papercrate-backend
IMAGE_FRONTEND: papercrate-dms/papercrate-frontend
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
component: [backend, frontend]
platform: [linux/amd64, linux/arm64]
permissions:
contents: read
packages: write
id-token: write
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set component metadata
run: |
if [ "${{ matrix.component }}" = "backend" ]; then
echo "COMPONENT_CONTEXT=./backend" >> "$GITHUB_ENV"
echo "COMPONENT_IMAGE=${{ env.IMAGE_BACKEND }}" >> "$GITHUB_ENV"
else
echo "COMPONENT_CONTEXT=./frontend" >> "$GITHUB_ENV"
echo "COMPONENT_IMAGE=${{ env.IMAGE_FRONTEND }}" >> "$GITHUB_ENV"
fi
echo "COMPONENT_NAME=${{ matrix.component }}" >> "$GITHUB_ENV"
- name: Compute tags
env:
GITHUB_SHA: ${{ github.sha }}
GITHUB_REF: ${{ github.ref }}
REGISTRY: ${{ env.REGISTRY }}
PLATFORM: ${{ matrix.platform }}
run: |
ARCH=${PLATFORM#*/}
TAGS=("${GITHUB_SHA::7}")
BRANCH=${GITHUB_REF##*/}
if [ "$BRANCH" = "dev" ]; then
TAGS+=("${GITHUB_SHA::7}-dev")
fi
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
TAGS+=("${GITHUB_REF#refs/tags/}")
fi
UNIQUE=()
for tag in "${TAGS[@]}"; do
[[ -z "$tag" ]] && continue
skip=false
for seen in "${UNIQUE[@]}"; do
if [ "$tag" = "$seen" ]; then
skip=true
break
fi
done
if [ "$skip" = false ]; then
UNIQUE+=("$tag")
fi
done
{
echo "BASE_TAGS<<EOF"
for tag in "${UNIQUE[@]}"; do
printf '%s\n' "$tag"
done
echo "EOF"
echo "IMAGE_TAGS<<EOF"
for tag in "${UNIQUE[@]}"; do
printf '%s\n' "$REGISTRY/$COMPONENT_IMAGE:${tag}-${ARCH}"
done
echo "EOF"
echo "ARCH=$ARCH"
} >> "$GITHUB_ENV"
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build & push ${{ env.COMPONENT_NAME }} (${{ matrix.platform }})
uses: docker/build-push-action@v5
with:
context: ${{ env.COMPONENT_CONTEXT }}
push: true
platforms: ${{ matrix.platform }}
tags: ${{ env.IMAGE_TAGS }}
manifest:
runs-on: ubuntu-latest
needs: build
strategy:
matrix:
component: [backend, frontend]
permissions:
contents: read
packages: write
env:
REGISTRY: ghcr.io
ARCHES: amd64 arm64
steps:
- name: Set component metadata
run: |
if [ "${{ matrix.component }}" = "backend" ]; then
echo "COMPONENT_IMAGE=${{ env.IMAGE_BACKEND }}" >> "$GITHUB_ENV"
else
echo "COMPONENT_IMAGE=${{ env.IMAGE_FRONTEND }}" >> "$GITHUB_ENV"
fi
echo "COMPONENT_NAME=${{ matrix.component }}" >> "$GITHUB_ENV"
- name: Compute base tags
env:
GITHUB_SHA: ${{ github.sha }}
GITHUB_REF: ${{ github.ref }}
run: |
TAGS=("${GITHUB_SHA::7}")
BRANCH=${GITHUB_REF##*/}
if [ "$BRANCH" = "dev" ]; then
TAGS+=("${GITHUB_SHA::7}-dev")
fi
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
TAGS+=("${GITHUB_REF#refs/tags/}")
fi
UNIQUE=()
for tag in "${TAGS[@]}"; do
[[ -z "$tag" ]] && continue
skip=false
for seen in "${UNIQUE[@]}"; do
if [ "$tag" = "$seen" ]; then
skip=true
break
fi
done
if [ "$skip" = false ]; then
UNIQUE+=("$tag")
fi
done
{
echo "BASE_TAGS<<EOF"
for tag in "${UNIQUE[@]}"; do
printf '%s\n' "$tag"
done
echo "EOF"
} >> "$GITHUB_ENV"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create manifests for ${{ env.COMPONENT_NAME }}
run: |
while IFS= read -r tag; do
[ -n "$tag" ] || continue
args=()
for arch in $ARCHES; do
args+=("$REGISTRY/$COMPONENT_IMAGE:${tag}-${arch}")
done
docker buildx imagetools create \
--tag "$REGISTRY/$COMPONENT_IMAGE:$tag" \
"${args[@]}"
done <<< "$BASE_TAGS"
+104
View File
@@ -0,0 +1,104 @@
# 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.
+32 -63
View File
@@ -1,73 +1,42 @@
# Papercrate # Papercrate
## Local Development ![Papercrate document workspace](docs/screenshot.png)
Use the provided `papercrate.tmux` to spin up the full stack in one tmux session: ## Single-Host Deployment (Docker Compose)
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
tmux -f papercrate.tmux attach cat <<'EOF' > .env
POSTGRES_PASSWORD=change-me
MINIO_ROOT_PASSWORD=change-me-too
JWT_SECRET=generate-a-long-random-string
WEBAUTHN_RP_ID=papercrate.local
WEBAUTHN_ORIGIN=http://papercrate.local:8080
# Optional overrides
# CORS_ALLOWED_ORIGIN=http://papercrate.local:8080
# REFRESH_COOKIE_SECURE=true
EOF
docker compose build
docker compose up -d
``` ```
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. **Important:** the WebAuthn settings must match the public URL clients will use.
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.
## Backend Integration Tests The compose file builds the backend and frontend images locally, then launches
Postgres, MinIO, Quickwit, the API, background worker, WebDAV endpoint, and the
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`.
Integration tests require a running Postgres instance (and, optionally, Quickwit for OCR indexing). The repository includes a lightweight compose file for local runs: ---
```bash For development workflows (local stack, integration tests, migrations, and
docker compose -f docker-compose.test.yml up -d configuration details) see [DEVELOPMENT.md](./DEVELOPMENT.md).
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 (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.
+176 -52
View File
@@ -41,6 +41,56 @@ 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"
@@ -632,58 +682,6 @@ 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",
"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 = "backtrace" name = "backtrace"
version = "0.3.76" version = "0.3.76"
@@ -886,6 +884,46 @@ 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"
@@ -895,6 +933,12 @@ 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"
@@ -1961,6 +2005,12 @@ 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"
@@ -2280,6 +2330,12 @@ 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]] [[package]]
name = "openssl" name = "openssl"
version = "0.10.74" version = "0.10.74"
@@ -2350,6 +2406,59 @@ 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"
@@ -3781,6 +3890,12 @@ 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]] [[package]]
name = "utoipa" name = "utoipa"
version = "4.2.3" version = "4.2.3"
@@ -4143,6 +4258,15 @@ 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"
+21 -1
View File
@@ -1,5 +1,5 @@
[package] [package]
name = "backend" name = "papercrate"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
@@ -48,6 +48,7 @@ futures-util = "0.3"
url = "2.5" url = "2.5"
once_cell = "1.19" once_cell = "1.19"
utoipa = { version = "4.2", default-features = false, features = ["chrono", "uuid", "preserve_order"] } 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"
@@ -68,3 +69,22 @@ 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" 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,8 +1,10 @@
DROP TRIGGER IF EXISTS trg_jobs_updated_at ON jobs; DROP TRIGGER IF EXISTS trg_jobs_updated_at ON jobs;
DROP FUNCTION IF EXISTS touch_jobs_updated_at(); 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; ALTER TABLE documents DROP CONSTRAINT IF EXISTS documents_current_version_fk;
DROP TABLE IF EXISTS document_asset_objects; DROP TABLE IF EXISTS document_asset_objects;
DROP TABLE IF EXISTS document_assets; DROP TABLE IF EXISTS document_assets;
DROP TABLE IF EXISTS document_versions; DROP TABLE IF EXISTS document_versions;
@@ -18,4 +20,6 @@ DROP TABLE IF EXISTS user_memberships;
DROP TABLE IF EXISTS users; DROP TABLE IF EXISTS users;
DROP TABLE IF EXISTS tenants; DROP TABLE IF EXISTS tenants;
DROP TYPE IF EXISTS tenant_status;
DROP EXTENSION IF EXISTS "pgcrypto"; DROP EXTENSION IF EXISTS "pgcrypto";
@@ -1,14 +1,17 @@
CREATE EXTENSION IF NOT EXISTS "pgcrypto"; CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE TYPE tenant_status AS ENUM ('creating', 'active', 'suspended', 'deleting', 'error');
CREATE TABLE tenants ( CREATE TABLE tenants (
id UUID PRIMARY KEY, id UUID PRIMARY KEY,
slug TEXT NOT NULL UNIQUE, name TEXT NOT NULL,
storage_root TEXT, storage_root TEXT,
quickwit_index TEXT, quickwit_index TEXT,
status TEXT NOT NULL DEFAULT 'active',
config JSONB NOT NULL DEFAULT '{}'::jsonb, config JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_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 CREATE UNIQUE INDEX tenants_storage_root_unique
@@ -22,7 +25,6 @@ CREATE UNIQUE INDEX tenants_quickwit_index_unique
CREATE TABLE users ( CREATE TABLE users (
id UUID PRIMARY KEY, id UUID PRIMARY KEY,
username VARCHAR(100) NOT NULL UNIQUE, username VARCHAR(100) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
); );
@@ -31,14 +33,13 @@ CREATE TABLE user_memberships (
id UUID PRIMARY KEY, id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
role TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_id, tenant_id) UNIQUE (user_id, tenant_id)
); );
CREATE INDEX user_memberships_tenant_id_idx ON user_memberships (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 INDEX user_memberships_user_id_idx ON user_memberships(user_id);
CREATE TABLE folders ( CREATE TABLE folders (
id UUID PRIMARY KEY, id UUID PRIMARY KEY,
@@ -51,8 +52,12 @@ CREATE TABLE folders (
CREATE INDEX idx_folders_parent ON folders(parent_id); CREATE INDEX idx_folders_parent ON folders(parent_id);
CREATE INDEX folders_tenant_id_idx ON folders(tenant_id); CREATE INDEX folders_tenant_id_idx ON folders(tenant_id);
CREATE UNIQUE INDEX folders_parent_name_unique_idx CREATE UNIQUE INDEX folders_tenant_parent_name_unique_idx
ON folders (COALESCE(parent_id, '00000000-0000-0000-0000-000000000000'::uuid), name); ON folders (
tenant_id,
COALESCE(parent_id, '00000000-0000-0000-0000-000000000000'::uuid),
name
);
CREATE TABLE documents ( CREATE TABLE documents (
id UUID PRIMARY KEY, id UUID PRIMARY KEY,
@@ -70,11 +75,10 @@ CREATE TABLE documents (
tenant_id UUID NOT NULL REFERENCES tenants(id) tenant_id UUID NOT NULL REFERENCES tenants(id)
); );
CREATE INDEX idx_documents_folder ON documents (folder_id); CREATE INDEX idx_documents_folder ON documents(folder_id);
CREATE INDEX idx_documents_deleted_at ON documents (deleted_at); CREATE INDEX idx_documents_deleted_at ON documents(deleted_at);
CREATE INDEX documents_tenant_id_idx ON documents (tenant_id); 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_current_version_id ON documents(current_version_id);
CREATE INDEX idx_documents_folder_title CREATE INDEX idx_documents_folder_title
ON documents ( ON documents (
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid), COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
@@ -82,8 +86,9 @@ CREATE INDEX idx_documents_folder_title
) )
WHERE deleted_at IS NULL; WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX documents_unique_folder_filename CREATE UNIQUE INDEX documents_tenant_folder_filename_unique
ON documents ( ON documents (
tenant_id,
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid), COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
filename filename
) )
@@ -97,14 +102,13 @@ CREATE TABLE document_versions (
size_bytes BIGINT NOT NULL, size_bytes BIGINT NOT NULL,
checksum VARCHAR(64) NOT NULL, checksum VARCHAR(64) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
operations_summary JSONB NOT NULL DEFAULT '{}'::jsonb,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb, metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
tenant_id UUID NOT NULL REFERENCES tenants(id), tenant_id UUID NOT NULL REFERENCES tenants(id),
CONSTRAINT document_versions_unique_version UNIQUE (document_id, version_number) CONSTRAINT document_versions_unique_version UNIQUE (document_id, version_number)
); );
CREATE INDEX idx_document_versions_document ON document_versions (document_id); CREATE INDEX idx_document_versions_document ON document_versions(document_id);
CREATE INDEX document_versions_tenant_id_idx ON document_versions (tenant_id); CREATE INDEX document_versions_tenant_id_idx ON document_versions(tenant_id);
ALTER TABLE documents ALTER TABLE documents
ADD CONSTRAINT documents_current_version_fk ADD CONSTRAINT documents_current_version_fk
@@ -114,13 +118,14 @@ ALTER TABLE documents
CREATE TABLE tags ( CREATE TABLE tags (
id UUID PRIMARY KEY, id UUID PRIMARY KEY,
label VARCHAR(100) NOT NULL UNIQUE, label VARCHAR(100) NOT NULL,
color VARCHAR(7), color VARCHAR(7),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
tenant_id UUID NOT NULL REFERENCES tenants(id) tenant_id UUID NOT NULL REFERENCES tenants(id)
); );
CREATE INDEX tags_tenant_id_idx ON tags (tenant_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 ( CREATE TABLE document_tags (
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE, document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
@@ -131,8 +136,8 @@ CREATE TABLE document_tags (
PRIMARY KEY (document_id, tag_id) PRIMARY KEY (document_id, tag_id)
); );
CREATE INDEX idx_document_tags_tag ON document_tags (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 INDEX document_tags_tenant_id_idx ON document_tags(tenant_id);
CREATE TABLE correspondents ( CREATE TABLE correspondents (
id UUID PRIMARY KEY, id UUID PRIMARY KEY,
@@ -140,27 +145,25 @@ CREATE TABLE correspondents (
metadata JSONB NOT NULL DEFAULT '{}'::jsonb, metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
tenant_id UUID NOT NULL REFERENCES tenants(id), tenant_id UUID NOT NULL REFERENCES tenants(id)
CONSTRAINT correspondents_name_unique UNIQUE (name)
); );
CREATE INDEX correspondents_tenant_id_idx ON correspondents (tenant_id); CREATE 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 ( CREATE TABLE document_correspondents (
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE, document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
correspondent_id UUID NOT NULL REFERENCES correspondents(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_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
assigned_by UUID REFERENCES users(id), assigned_by UUID REFERENCES users(id),
tenant_id UUID NOT NULL REFERENCES tenants(id), tenant_id UUID NOT NULL REFERENCES tenants(id),
PRIMARY KEY (document_id, correspondent_id, role), PRIMARY KEY (document_id, correspondent_id)
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_document ON document_correspondents(document_id);
CREATE INDEX idx_document_correspondents_correspondent ON document_correspondents (correspondent_id); CREATE INDEX idx_document_correspondents_correspondent ON document_correspondents(correspondent_id);
CREATE INDEX idx_document_correspondents_role ON document_correspondents (role); CREATE INDEX document_correspondents_tenant_id_idx ON document_correspondents(tenant_id);
CREATE INDEX document_correspondents_tenant_id_idx ON document_correspondents (tenant_id);
CREATE TABLE document_assets ( CREATE TABLE document_assets (
id UUID PRIMARY KEY, id UUID PRIMARY KEY,
@@ -175,9 +178,9 @@ CREATE TABLE document_assets (
CONSTRAINT document_assets_cardinality_positive CHECK (cardinality IS NULL OR cardinality >= 1) 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_version ON document_assets(document_version_id);
CREATE INDEX idx_document_assets_type ON document_assets (asset_type); CREATE INDEX idx_document_assets_type ON document_assets(asset_type);
CREATE INDEX document_assets_tenant_id_idx ON document_assets (tenant_id); CREATE INDEX document_assets_tenant_id_idx ON document_assets(tenant_id);
CREATE TABLE document_asset_objects ( CREATE TABLE document_asset_objects (
id UUID PRIMARY KEY, id UUID PRIMARY KEY,
@@ -191,10 +194,8 @@ CREATE TABLE document_asset_objects (
); );
CREATE INDEX idx_document_asset_objects_asset_ordinal CREATE INDEX idx_document_asset_objects_asset_ordinal
ON document_asset_objects (asset_id, ordinal); ON document_asset_objects(asset_id, ordinal);
CREATE INDEX document_asset_objects_tenant_id_idx ON document_asset_objects(tenant_id);
CREATE INDEX document_asset_objects_tenant_id_idx
ON document_asset_objects (tenant_id);
CREATE TABLE jobs ( CREATE TABLE jobs (
id UUID PRIMARY KEY, id UUID PRIMARY KEY,
@@ -210,9 +211,9 @@ CREATE TABLE jobs (
CONSTRAINT jobs_status_check CHECK (status IN ('queued', 'processing', 'succeeded', 'failed')) 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_status_run_after ON jobs(status, run_after);
CREATE INDEX idx_jobs_job_type ON jobs (job_type); CREATE INDEX idx_jobs_job_type ON jobs(job_type);
CREATE INDEX jobs_tenant_id_idx ON jobs (tenant_id); CREATE INDEX jobs_tenant_id_idx ON jobs(tenant_id);
CREATE OR REPLACE FUNCTION touch_jobs_updated_at() CREATE OR REPLACE FUNCTION touch_jobs_updated_at()
RETURNS TRIGGER AS $$ RETURNS TRIGGER AS $$
@@ -239,6 +240,56 @@ CREATE TABLE refresh_tokens (
tenant_id UUID NOT NULL REFERENCES tenants(id) 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_user_id ON refresh_tokens(user_id);
CREATE INDEX idx_refresh_tokens_token_hash ON refresh_tokens (token_hash); 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 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);
@@ -0,0 +1,2 @@
ALTER TABLE documents
RENAME COLUMN created_at TO uploaded_at;
@@ -0,0 +1,2 @@
ALTER TABLE documents
RENAME COLUMN uploaded_at TO created_at;
@@ -0,0 +1,2 @@
DROP TABLE magic_tokens;
DROP TYPE magic_token_kind;
@@ -0,0 +1,18 @@
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);
@@ -0,0 +1,28 @@
-- 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;
@@ -0,0 +1,45 @@
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
$$;
@@ -0,0 +1,51 @@
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();
@@ -0,0 +1,69 @@
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);
@@ -0,0 +1,5 @@
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();
@@ -0,0 +1,15 @@
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,6 +0,0 @@
DROP INDEX IF EXISTS folders_tenant_parent_name_unique_idx;
CREATE UNIQUE INDEX folders_parent_name_unique_idx
ON folders (
COALESCE(parent_id, '00000000-0000-0000-0000-000000000000'::uuid),
name
);
@@ -1,7 +0,0 @@
DROP INDEX IF EXISTS folders_parent_name_unique_idx;
CREATE UNIQUE INDEX folders_tenant_parent_name_unique_idx
ON folders (
tenant_id,
COALESCE(parent_id, '00000000-0000-0000-0000-000000000000'::uuid),
name
);
@@ -1,16 +0,0 @@
-- Revert correspondent uniqueness to global name
DROP INDEX IF EXISTS correspondents_tenant_name_unique;
ALTER TABLE correspondents ADD CONSTRAINT correspondents_name_unique UNIQUE (name);
-- Revert tag uniqueness to global label
DROP INDEX IF EXISTS tags_tenant_label_unique;
ALTER TABLE tags ADD CONSTRAINT tags_label_key UNIQUE (label);
-- Revert document filename uniqueness to global folder scope
DROP INDEX IF EXISTS documents_tenant_folder_filename_unique;
CREATE UNIQUE INDEX documents_unique_folder_filename
ON documents (
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
filename
)
WHERE deleted_at IS NULL;
@@ -1,20 +0,0 @@
-- Ensure document filenames are unique per tenant + folder
DROP INDEX IF EXISTS documents_tenant_folder_filename_unique;
DROP INDEX IF EXISTS documents_unique_folder_filename;
CREATE UNIQUE INDEX documents_tenant_folder_filename_unique
ON documents (
tenant_id,
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
filename
)
WHERE deleted_at IS NULL;
-- Ensure tag labels are unique per tenant
ALTER TABLE tags DROP CONSTRAINT IF EXISTS tags_label_key;
DROP INDEX IF EXISTS tags_tenant_label_unique;
CREATE UNIQUE INDEX tags_tenant_label_unique ON tags (tenant_id, label);
-- Ensure correspondent names are unique per tenant
ALTER TABLE correspondents DROP CONSTRAINT IF EXISTS correspondents_name_unique;
DROP INDEX IF EXISTS correspondents_tenant_name_unique;
CREATE UNIQUE INDEX correspondents_tenant_name_unique ON correspondents (tenant_id, name);
@@ -1,2 +0,0 @@
ALTER TABLE document_versions
ADD COLUMN operations_summary JSONB NOT NULL DEFAULT '{}'::jsonb;
@@ -1,2 +0,0 @@
ALTER TABLE document_versions
DROP COLUMN IF EXISTS operations_summary;
@@ -1,7 +0,0 @@
ALTER TABLE document_correspondents DROP CONSTRAINT document_correspondents_pkey;
ALTER TABLE document_correspondents ADD COLUMN role VARCHAR(32) NOT NULL DEFAULT 'other';
UPDATE document_correspondents SET role = 'other';
ALTER TABLE document_correspondents ALTER COLUMN role DROP DEFAULT;
ALTER TABLE document_correspondents
ADD CONSTRAINT document_correspondents_pkey
PRIMARY KEY (document_id, correspondent_id, role);
@@ -1,24 +0,0 @@
WITH ranked AS (
SELECT
document_id,
correspondent_id,
role,
assigned_at,
assigned_by,
tenant_id,
ROW_NUMBER() OVER (PARTITION BY document_id, correspondent_id ORDER BY assigned_at DESC) AS rn
FROM document_correspondents
)
DELETE FROM document_correspondents dc
USING ranked r
WHERE dc.document_id = r.document_id
AND dc.correspondent_id = r.correspondent_id
AND dc.role = r.role
AND dc.tenant_id = r.tenant_id
AND r.rn > 1;
ALTER TABLE document_correspondents DROP CONSTRAINT document_correspondents_pkey;
ALTER TABLE document_correspondents DROP COLUMN role;
ALTER TABLE document_correspondents
ADD CONSTRAINT document_correspondents_pkey
PRIMARY KEY (document_id, correspondent_id);
@@ -1,3 +0,0 @@
DROP INDEX IF EXISTS webdav_tokens_user_tenant_idx;
DROP INDEX IF EXISTS webdav_tokens_token_prefix_key;
DROP TABLE IF EXISTS webdav_tokens;
@@ -1,16 +0,0 @@
CREATE TABLE webdav_tokens (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
token_prefix TEXT NOT NULL,
token_hash TEXT NOT NULL,
label TEXT,
scopes JSONB NOT NULL DEFAULT '["webdav"]'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_used_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX webdav_tokens_token_prefix_key ON webdav_tokens(token_prefix);
CREATE INDEX webdav_tokens_user_tenant_idx ON webdav_tokens(user_id, tenant_id);
@@ -1,2 +0,0 @@
ALTER TABLE webdav_tokens
ADD COLUMN scopes JSONB NOT NULL DEFAULT '["webdav"]'::jsonb;
@@ -1,2 +0,0 @@
ALTER TABLE webdav_tokens
DROP COLUMN IF EXISTS scopes;
@@ -1,5 +0,0 @@
ALTER TABLE tenants
DROP COLUMN IF EXISTS status,
DROP COLUMN IF EXISTS created_by;
DROP TYPE IF EXISTS tenant_status;
@@ -1,19 +0,0 @@
ALTER TABLE tenants
DROP COLUMN IF EXISTS status;
ALTER TABLE tenants
DROP COLUMN IF EXISTS created_by;
DROP TYPE IF EXISTS tenant_status;
CREATE TYPE tenant_status AS ENUM ('creating', 'active', 'suspended', 'deleting', 'error');
ALTER TABLE tenants
ADD COLUMN status tenant_status,
ADD COLUMN created_by UUID;
UPDATE tenants
SET status = 'active';
ALTER TABLE tenants
ALTER COLUMN status SET NOT NULL;
@@ -1 +0,0 @@
DROP TABLE IF EXISTS user_passkeys;
@@ -1,19 +0,0 @@
CREATE TABLE user_passkeys (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
credential_id BYTEA NOT NULL UNIQUE,
public_key BYTEA NOT NULL,
credential JSONB NOT NULL,
sign_count BIGINT NOT NULL,
transports TEXT[] NOT NULL DEFAULT '{}',
aaguid UUID,
nickname TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_used_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
revoked_by UUID,
revoked_reason TEXT
);
CREATE INDEX user_passkeys_user_id_idx ON user_passkeys (user_id);
@@ -1 +0,0 @@
DROP TABLE IF EXISTS webauthn_challenges;
@@ -1,13 +0,0 @@
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 user_memberships
ADD COLUMN role TEXT NOT NULL DEFAULT 'user';
@@ -1,2 +0,0 @@
ALTER TABLE user_memberships
DROP COLUMN role;
@@ -1,5 +0,0 @@
ALTER TABLE tenants
RENAME COLUMN name TO slug;
ALTER TABLE tenants
RENAME CONSTRAINT tenants_name_key TO tenants_slug_key;
@@ -1,5 +0,0 @@
ALTER TABLE tenants
RENAME COLUMN slug TO name;
ALTER TABLE tenants
RENAME CONSTRAINT tenants_slug_key TO tenants_name_key;
@@ -1,2 +0,0 @@
ALTER TABLE tenants
ADD CONSTRAINT tenants_name_key UNIQUE (name);
@@ -1,2 +0,0 @@
ALTER TABLE tenants
DROP CONSTRAINT IF EXISTS tenants_name_key;
@@ -1,6 +0,0 @@
ALTER TABLE users
ADD COLUMN password_hash VARCHAR(255) NOT NULL DEFAULT '';
-- Optional: remove the default if you need to reintroduce passwords later
ALTER TABLE users
ALTER COLUMN password_hash DROP DEFAULT;
@@ -1,2 +0,0 @@
ALTER TABLE users
DROP COLUMN password_hash;
@@ -0,0 +1,21 @@
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;
+2 -1
View File
@@ -7,6 +7,7 @@ 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, error::AppError,
@@ -14,7 +15,7 @@ use crate::{
}; };
use uuid::Uuid; use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)] #[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,
+81 -159
View File
@@ -56,6 +56,7 @@ impl PreparedPasskey {
pub struct RegistrationChallengeResponse { pub struct RegistrationChallengeResponse {
pub challenge_id: Uuid, pub challenge_id: Uuid,
#[serde(flatten)] #[serde(flatten)]
#[schema(value_type = Object)]
pub challenge: CreationChallengeResponse, pub challenge: CreationChallengeResponse,
} }
@@ -64,6 +65,7 @@ pub struct RegistrationChallengeResponse {
pub struct AuthenticationChallengeResponse { pub struct AuthenticationChallengeResponse {
pub challenge_id: Uuid, pub challenge_id: Uuid,
#[serde(flatten)] #[serde(flatten)]
#[schema(value_type = Object)]
pub challenge: RequestChallengeResponse, pub challenge: RequestChallengeResponse,
} }
@@ -116,13 +118,55 @@ impl PasskeyService {
.execute(conn); .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( pub fn start_registration(
&self, &self,
conn: &mut PgConnection, conn: &mut PgConnection,
user: &User, user: &User,
) -> AppResult<RegistrationChallengeResponse> { ) -> AppResult<RegistrationChallengeResponse> {
self.prune_expired(conn);
let existing: Vec<UserPasskey> = passkey_dsl::user_passkeys let existing: Vec<UserPasskey> = passkey_dsl::user_passkeys
.filter(passkey_dsl::user_id.eq(user.id)) .filter(passkey_dsl::user_id.eq(user.id))
.filter(passkey_dsl::revoked_at.is_null()) .filter(passkey_dsl::revoked_at.is_null())
@@ -139,38 +183,7 @@ impl PasskeyService {
) )
}; };
let (challenge, state) = self self.begin_registration(conn, user.id, &user.username, Some(user.id), exclude)
.webauthn
.start_passkey_registration(user.id, &user.username, &user.username, exclude)
.map_err(|err| {
tracing::error!(error = %err, "failed to start passkey registration");
AppError::internal("failed to start passkey registration")
})?;
let challenge_id = Uuid::new_v4();
let expires_at = (Utc::now() + self.challenge_ttl).naive_utc();
let challenge_bytes: Vec<u8> = challenge.public_key.challenge.clone().into();
let state_bytes = serde_json::to_vec(&state)
.context("failed to encode passkey registration state")
.map_err(AppError::internal)?;
let record = NewWebauthnChallenge {
id: challenge_id,
user_id: Some(user.id),
purpose: PURPOSE_REGISTRATION.to_string(),
challenge: challenge_bytes,
state: state_bytes,
expires_at,
};
diesel::insert_into(challenge_dsl::webauthn_challenges)
.values(&record)
.execute(conn)?;
Ok(RegistrationChallengeResponse {
challenge_id,
challenge,
})
} }
pub fn start_signup_registration( pub fn start_signup_registration(
@@ -179,50 +192,16 @@ impl PasskeyService {
user_id: Uuid, user_id: Uuid,
username: &str, username: &str,
) -> AppResult<RegistrationChallengeResponse> { ) -> AppResult<RegistrationChallengeResponse> {
self.prune_expired(conn); self.begin_registration(conn, user_id, username, None, None)
let (challenge, state) = self
.webauthn
.start_passkey_registration(user_id, username, username, None)
.map_err(|err| {
tracing::error!(error = %err, "failed to start passkey registration");
AppError::internal("failed to start passkey registration")
})?;
let challenge_id = Uuid::new_v4();
let expires_at = (Utc::now() + self.challenge_ttl).naive_utc();
let challenge_bytes: Vec<u8> = challenge.public_key.challenge.clone().into();
let state_bytes = serde_json::to_vec(&state)
.context("failed to encode passkey registration state")
.map_err(AppError::internal)?;
let record = NewWebauthnChallenge {
id: challenge_id,
user_id: None,
purpose: PURPOSE_REGISTRATION.to_string(),
challenge: challenge_bytes,
state: state_bytes,
expires_at,
};
diesel::insert_into(challenge_dsl::webauthn_challenges)
.values(&record)
.execute(conn)?;
Ok(RegistrationChallengeResponse {
challenge_id,
challenge,
})
} }
pub fn finish_registration( fn complete_registration(
&self, &self,
conn: &mut PgConnection, conn: &mut PgConnection,
user: &User,
challenge_id: Uuid, challenge_id: Uuid,
credential: RegisterPublicKeyCredential, credential: &RegisterPublicKeyCredential,
nickname: Option<String>, expected_user: Option<Uuid>,
) -> AppResult<UserPasskey> { ) -> AppResult<PreparedPasskey> {
let record: WebauthnChallenge = challenge_dsl::webauthn_challenges let record: WebauthnChallenge = challenge_dsl::webauthn_challenges
.find(challenge_id) .find(challenge_id)
.first(conn) .first(conn)
@@ -238,9 +217,15 @@ impl PasskeyService {
return Err(AppError::bad_request("challenge is not for registration")); return Err(AppError::bad_request("challenge is not for registration"));
} }
if record.user_id != Some(user.id) { if let Some(expected) = expected_user {
if record.user_id != Some(expected) {
return Err(AppError::unauthorized()); 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() { if record.expires_at < Utc::now().naive_utc() {
diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?; diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?;
@@ -253,7 +238,7 @@ impl PasskeyService {
let passkey = self let passkey = self
.webauthn .webauthn
.finish_passkey_registration(&credential, &state) .finish_passkey_registration(credential, &state)
.map_err(|err| { .map_err(|err| {
tracing::warn!(error = %err, "passkey registration validation failed"); tracing::warn!(error = %err, "passkey registration validation failed");
AppError::bad_request("invalid passkey attestation") AppError::bad_request("invalid passkey attestation")
@@ -294,24 +279,36 @@ impl PasskeyService {
.context("failed to serialise passkey") .context("failed to serialise passkey")
.map_err(AppError::internal)?; .map_err(AppError::internal)?;
let new_passkey = NewUserPasskey { diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?;
Ok(PreparedPasskey {
id: Uuid::new_v4(), id: Uuid::new_v4(),
user_id: user.id,
credential_id: credential_id_vec, credential_id: credential_id_vec,
public_key: public_key_bytes, public_key: public_key_bytes,
credential: credential_json, credential: credential_json,
sign_count: credential_struct.counter as i64, sign_count: credential_struct.counter as i64,
transports, transports,
aaguid, aaguid,
nickname, })
}; }
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) diesel::insert_into(passkey_dsl::user_passkeys)
.values(&new_passkey) .values(&new_passkey)
.execute(conn)?; .execute(conn)?;
diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?;
let created: UserPasskey = passkey_dsl::user_passkeys let created: UserPasskey = passkey_dsl::user_passkeys
.find(new_passkey.id) .find(new_passkey.id)
.select(UserPasskey::as_select()) .select(UserPasskey::as_select())
@@ -408,84 +405,7 @@ impl PasskeyService {
challenge_id: Uuid, challenge_id: Uuid,
credential: &RegisterPublicKeyCredential, credential: &RegisterPublicKeyCredential,
) -> AppResult<PreparedPasskey> { ) -> AppResult<PreparedPasskey> {
let record: WebauthnChallenge = challenge_dsl::webauthn_challenges self.complete_registration(conn, challenge_id, credential, None)
.find(challenge_id)
.first(conn)
.map_err(|err| {
if matches!(err, diesel::result::Error::NotFound) {
AppError::bad_request("challenge not found")
} else {
AppError::from(err)
}
})?;
if record.purpose != PURPOSE_REGISTRATION {
return Err(AppError::bad_request("challenge is not for registration"));
}
if record.expires_at < Utc::now().naive_utc() {
diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?;
return Err(AppError::bad_request("challenge expired"));
}
let state: PasskeyRegistration = serde_json::from_slice(&record.state)
.context("failed to decode registration state")
.map_err(AppError::internal)?;
let passkey = self
.webauthn
.finish_passkey_registration(credential, &state)
.map_err(|err| {
tracing::warn!(error = %err, "passkey registration validation failed");
AppError::bad_request("invalid passkey attestation")
})?;
let credential_struct: Credential = passkey.clone().into();
let credential_id_vec: Vec<u8> = credential_struct.cred_id.clone().into();
let duplicate = passkey_dsl::user_passkeys
.filter(passkey_dsl::credential_id.eq(&credential_id_vec))
.first::<UserPasskey>(conn)
.optional()?;
if duplicate.is_some() {
diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?;
return Err(AppError::conflict("credential already registered"));
}
let public_key_bytes = serde_cbor_2::to_vec(&credential_struct.cred)
.context("failed to encode credential public key")
.map_err(AppError::internal)?;
let transports: Vec<Option<String>> = credential_struct
.transports
.clone()
.unwrap_or_default()
.into_iter()
.map(|transport| Some(transport.as_ref().to_string()))
.collect();
let aaguid = match credential_struct.attestation.metadata {
AttestationMetadata::Packed { aaguid } | AttestationMetadata::Tpm { aaguid, .. } => {
Some(aaguid)
}
_ => None,
};
let credential_json = serde_json::to_value(&passkey)
.context("failed to serialise passkey")
.map_err(AppError::internal)?;
diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?;
Ok(PreparedPasskey {
id: Uuid::new_v4(),
credential_id: credential_id_vec,
public_key: public_key_bytes,
credential: credential_json,
sign_count: credential_struct.counter as i64,
transports,
aaguid,
})
} }
pub fn revoke_passkey( pub fn revoke_passkey(
@@ -651,6 +571,7 @@ impl From<UserPasskey> for PasskeySummary {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct PasskeyRegistrationFinishPayload { pub struct PasskeyRegistrationFinishPayload {
pub challenge_id: Uuid, pub challenge_id: Uuid,
#[schema(value_type = Object)]
pub credential: RegisterPublicKeyCredential, pub credential: RegisterPublicKeyCredential,
#[serde(default)] #[serde(default)]
pub nickname: Option<String>, pub nickname: Option<String>,
@@ -666,5 +587,6 @@ pub struct PasskeyLoginStartPayload {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct PasskeyLoginFinishPayload { pub struct PasskeyLoginFinishPayload {
pub challenge_id: Uuid, pub challenge_id: Uuid,
#[schema(value_type = Object)]
pub credential: PublicKeyCredential, pub credential: PublicKeyCredential,
} }
+51 -1
View File
@@ -13,6 +13,7 @@ use crate::{
models::{NewWebdavToken, WebdavToken}, models::{NewWebdavToken, WebdavToken},
schema::webdav_tokens, schema::webdav_tokens,
state::PgPooledConnection, state::PgPooledConnection,
tenants::{apply_webdav_token_prefix, clear_webdav_token_prefix},
}; };
const TOKEN_PREFIX_LENGTH: usize = 12; const TOKEN_PREFIX_LENGTH: usize = 12;
@@ -73,6 +74,51 @@ pub fn list_webdav_tokens(
Ok(tokens) Ok(tokens)
} }
pub fn regenerate_webdav_token(
conn: &mut PgPooledConnection,
token_id: Uuid,
user_id: Uuid,
tenant_id: Option<Uuid>,
) -> Result<IssuedWebdavToken, AppError> {
let mut query = webdav_tokens::table
.filter(webdav_tokens::id.eq(token_id))
.filter(webdav_tokens::user_id.eq(user_id))
.into_boxed();
if let Some(tenant) = tenant_id {
query = query.filter(webdav_tokens::tenant_id.eq(tenant));
}
let record = query
.first::<WebdavToken>(conn)
.optional()
.map_err(AppError::from)?
.ok_or_else(AppError::not_found)?;
if record.revoked_at.is_some() {
return Err(AppError::bad_request(
"cannot regenerate a revoked WebDAV 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(webdav_tokens::table.find(record.id))
.set((
webdav_tokens::token_prefix.eq(&token_prefix),
webdav_tokens::token_hash.eq(&token_hash),
webdav_tokens::last_used_at.eq::<Option<NaiveDateTime>>(None),
))
.get_result::<WebdavToken>(conn)?;
Ok(IssuedWebdavToken {
token: raw_secret,
record: updated,
})
}
pub fn find_active_token_by_secret( pub fn find_active_token_by_secret(
conn: &mut PgPooledConnection, conn: &mut PgPooledConnection,
user_id: Uuid, user_id: Uuid,
@@ -84,6 +130,7 @@ pub fn find_active_token_by_secret(
} }
let prefix = &secret[..TOKEN_PREFIX_LENGTH]; let prefix = &secret[..TOKEN_PREFIX_LENGTH];
apply_webdav_token_prefix(conn, prefix)?;
let mut query = webdav_tokens::table let mut query = webdav_tokens::table
.filter(webdav_tokens::user_id.eq(user_id)) .filter(webdav_tokens::user_id.eq(user_id))
.filter(webdav_tokens::token_prefix.eq(prefix)) .filter(webdav_tokens::token_prefix.eq(prefix))
@@ -101,7 +148,10 @@ pub fn find_active_token_by_secret(
query = query.filter(webdav_tokens::tenant_id.eq(tenant_id)); query = query.filter(webdav_tokens::tenant_id.eq(tenant_id));
} }
let candidates = query.load::<WebdavToken>(conn)?; let load_result = query.load::<WebdavToken>(conn);
let clear_result = clear_webdav_token_prefix(conn);
clear_result?;
let candidates = load_result?;
for token in candidates { for token in candidates {
if verify_token_secret(secret, &token.token_hash)? { if verify_token_secret(secret, &token.token_hash)? {
+130 -72
View File
@@ -1,29 +1,45 @@
use std::env;
use std::sync::Arc; use std::sync::Arc;
use anyhow::{anyhow, bail, Context, Result}; use anyhow::{anyhow, bail, Context, Result};
use chrono::{Duration as ChronoDuration, Utc};
use clap::{Parser, Subcommand, ValueEnum};
use diesel::{dsl::exists, prelude::*, select}; use diesel::{dsl::exists, prelude::*, select};
use rand::{rngs::OsRng, RngCore};
use reqwest::{Client, Method, StatusCode}; use reqwest::{Client, Method, StatusCode};
use sha2::{Digest, Sha256};
use uuid::Uuid; use uuid::Uuid;
use backend::{ use papercrate::{
config::AppConfig, config::AppConfig,
db::{self, PgPool}, db::{self, PgPool},
documents::search::ensure_quickwit_index, documents::search::ensure_quickwit_index,
jobs::{enqueue_job, JOB_ANALYZE_DOCUMENT}, jobs::{enqueue_job, JOB_ANALYZE_DOCUMENT},
models::{ models::{
DocumentAsset, DocumentAssetObject, NewUser, NewUserMembership, Tenant, TenantStatus, User, DocumentAsset, DocumentAssetObject, MagicToken, MagicTokenKind, NewUser, NewUserMembership,
Tenant, TenantStatus, User,
}, },
s3, s3,
schema::{ schema::{
document_asset_objects, document_assets, documents, tenants, user_memberships, users, document_asset_objects, document_assets, documents, magic_tokens, tenants,
user_memberships, users,
}, },
storage::{ObjectStorage, S3Storage, TenantStorage}, storage::{ObjectStorage, S3Storage, TenantStorage},
tenants::TenantService, tenants::TenantService,
utils::tracing::init_tracing, utils::tracing::init_tracing,
}; };
#[derive(Debug)] #[derive(Parser)]
#[command(
name = "papercrate-admin",
version,
about = "Papercrate administration utility"
)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command { enum Command {
CreateUser { CreateUser {
username: String, username: String,
@@ -34,7 +50,9 @@ enum Command {
}, },
CreateTenant { CreateTenant {
name: String, name: String,
#[arg(long = "storage-root")]
storage_root: Option<String>, storage_root: Option<String>,
#[arg(long = "quickwit-index")]
quickwit_index: Option<String>, quickwit_index: Option<String>,
}, },
DeleteTenant { DeleteTenant {
@@ -52,71 +70,43 @@ enum Command {
tenant_id: Uuid, tenant_id: Uuid,
}, },
ListTenants, ListTenants,
DeleteAssets(Uuid), DeleteAssets {
QuickwitCreate(Uuid), tenant_id: Uuid,
QuickwitDelete(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,
},
} }
impl Command { #[derive(Copy, Clone, Debug, ValueEnum)]
fn usage() -> &'static str { enum MagicTokenKindArg {
"Usage: admin\n\ #[value(name = "email_login")]
create-user <username>\n\ EmailLogin,
list-users\n\ #[value(name = "demo_login")]
delete-user <username>\n\ DemoLogin,
create-tenant <name> [storage_root] [quickwit_index]\n\ }
delete-tenant <tenant-id>\n\
add-user-to-tenant <username> <tenant-id>\n\
remove-user-from-tenant <username> <tenant-id>\n\
reanalyze-documents <tenant-id>\n\
list-tenants\n\
delete-assets <tenant-id>\n\
quickwit-create-index <tenant-id>\n\
quickwit-delete-index <tenant-id>"
}
fn parse_tenant_id(arg: Option<String>) -> Result<Uuid> { impl From<MagicTokenKindArg> for MagicTokenKind {
let raw = arg.ok_or_else(|| anyhow!("tenant id required"))?; fn from(value: MagicTokenKindArg) -> Self {
Uuid::parse_str(&raw).map_err(|_| anyhow!("invalid tenant id: {}", raw)) match value {
} MagicTokenKindArg::EmailLogin => MagicTokenKind::EmailLogin,
MagicTokenKindArg::DemoLogin => MagicTokenKind::DemoLogin,
fn parse() -> Result<Self> {
let mut args = env::args().skip(1);
match args.next().as_deref() {
Some("create-user") => Ok(Self::CreateUser {
username: args.next().ok_or_else(|| anyhow!("username required"))?,
}),
Some("list-users") => Ok(Self::ListUsers),
Some("delete-user") => Ok(Self::DeleteUser {
username: args.next().ok_or_else(|| anyhow!("username required"))?,
}),
Some("create-tenant") => Ok(Self::CreateTenant {
name: args.next().ok_or_else(|| anyhow!("tenant name required"))?,
storage_root: args.next(),
quickwit_index: args.next(),
}),
Some("delete-tenant") => Ok(Self::DeleteTenant {
tenant_id: Self::parse_tenant_id(args.next())?,
}),
Some("add-user-to-tenant") => Ok(Self::AddUserToTenant {
username: args.next().ok_or_else(|| anyhow!("username required"))?,
tenant_id: Self::parse_tenant_id(args.next())?,
}),
Some("remove-user-from-tenant") => Ok(Self::RemoveUserFromTenant {
username: args.next().ok_or_else(|| anyhow!("username required"))?,
tenant_id: Self::parse_tenant_id(args.next())?,
}),
Some("reanalyze-documents") => Ok(Self::ReanalyzeDocuments {
tenant_id: Self::parse_tenant_id(args.next())?,
}),
Some("list-tenants") => Ok(Self::ListTenants),
Some("delete-assets") => Ok(Self::DeleteAssets(Self::parse_tenant_id(args.next())?)),
Some("quickwit-create-index") => {
Ok(Self::QuickwitCreate(Self::parse_tenant_id(args.next())?))
}
Some("quickwit-delete-index") => {
Ok(Self::QuickwitDelete(Self::parse_tenant_id(args.next())?))
}
_ => Err(anyhow!(Self::usage())),
} }
} }
} }
@@ -124,11 +114,11 @@ impl Command {
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
init_tracing("info"); init_tracing("info");
let command = Command::parse()?; let cli = Cli::parse();
let config = AppConfig::load_and_log("admin")?; let config = AppConfig::load_and_log("admin")?;
let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?; let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?;
match command { match cli.command {
Command::CreateUser { username } => create_user(&pool, &username)?, Command::CreateUser { username } => create_user(&pool, &username)?,
Command::ListUsers => list_users(&pool)?, Command::ListUsers => list_users(&pool)?,
Command::DeleteUser { username } => delete_user(&pool, &username)?, Command::DeleteUser { username } => delete_user(&pool, &username)?,
@@ -148,15 +138,23 @@ async fn main() -> Result<()> {
} => remove_user_from_tenant(&pool, &username, tenant_id)?, } => remove_user_from_tenant(&pool, &username, tenant_id)?,
Command::ReanalyzeDocuments { tenant_id } => reanalyze_documents(&pool, tenant_id)?, Command::ReanalyzeDocuments { tenant_id } => reanalyze_documents(&pool, tenant_id)?,
Command::ListTenants => list_tenants(&pool)?, Command::ListTenants => list_tenants(&pool)?,
Command::DeleteAssets(tenant_id) => { Command::DeleteAssets { tenant_id } => {
delete_assets_for_tenant(&config, &pool, tenant_id).await? delete_assets_for_tenant(&config, &pool, tenant_id).await?
} }
Command::QuickwitCreate(tenant_id) => { Command::QuickwitCreate { tenant_id } => {
quickwit_index(&config, &pool, tenant_id, Method::POST).await? quickwit_index(&config, &pool, tenant_id, Method::POST).await?
} }
Command::QuickwitDelete(tenant_id) => { Command::QuickwitDelete { tenant_id } => {
quickwit_index(&config, &pool, tenant_id, Method::DELETE).await? 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(()) Ok(())
@@ -268,6 +266,66 @@ fn create_tenant(
Ok(()) 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<()> { fn delete_tenant(pool: &PgPool, tenant_id: Uuid) -> Result<()> {
let mut conn = pool.get().context("failed to get database connection")?; let mut conn = pool.get().context("failed to get database connection")?;
+1 -1
View File
@@ -1,4 +1,4 @@
use backend::openapi::ApiDoc; use papercrate::openapi::ApiDoc;
use utoipa::OpenApi; use utoipa::OpenApi;
fn main() { fn main() {
+1 -1
View File
@@ -3,7 +3,7 @@ use std::net::SocketAddr;
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tower::make::Shared; use tower::make::Shared;
use backend::{routes::webdav, utils::bootstrap::init_component}; use papercrate::{routes::webdav, utils::bootstrap::init_component};
#[tokio::main] #[tokio::main]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
+1 -1
View File
@@ -2,7 +2,7 @@ use std::time::Duration;
use tokio::signal; use tokio::signal;
use backend::{default_handlers, utils::bootstrap::init_component, Worker}; use papercrate::{default_handlers, utils::bootstrap::init_component, Worker};
#[tokio::main] #[tokio::main]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
+30 -1
View File
@@ -1,12 +1,40 @@
use std::time::Duration; use std::time::Duration;
use diesel::pg::PgConnection; use diesel::pg::PgConnection;
use diesel::r2d2::{ConnectionManager, Pool}; use diesel::r2d2::{ConnectionManager, CustomizeConnection, 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)
} }
@@ -17,6 +45,7 @@ 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)
} }
+8
View File
@@ -18,8 +18,10 @@ pub struct DocumentAssetResponse {
pub id: Uuid, pub id: Uuid,
pub asset_type: String, pub asset_type: String,
pub mime_type: String, pub mime_type: String,
#[schema(value_type = Object)]
pub metadata: Value, pub metadata: Value,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
#[schema(nullable)]
pub cardinality: Option<i32>, pub cardinality: Option<i32>,
} }
@@ -27,10 +29,13 @@ pub struct DocumentAssetResponse {
pub struct DocumentAssetObjectResponse { pub struct DocumentAssetObjectResponse {
pub id: Uuid, pub id: Uuid,
pub ordinal: i32, pub ordinal: i32,
#[schema(value_type = Object)]
pub metadata: Value, pub metadata: Value,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
#[schema(nullable)]
pub url: Option<String>, pub url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
#[schema(nullable)]
pub expires_at: Option<i64>, pub expires_at: Option<i64>,
} }
@@ -39,9 +44,11 @@ pub struct DocumentAssetDetailResponse {
pub id: Uuid, pub id: Uuid,
pub asset_type: String, pub asset_type: String,
pub mime_type: String, pub mime_type: String,
#[schema(value_type = Object)]
pub metadata: Value, pub metadata: Value,
pub created_at: String, pub created_at: String,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
#[schema(nullable)]
pub cardinality: Option<i32>, pub cardinality: Option<i32>,
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
pub objects: Vec<DocumentAssetObjectResponse>, pub objects: Vec<DocumentAssetObjectResponse>,
@@ -54,6 +61,7 @@ pub struct DocumentVersionResponse {
pub size_bytes: i64, pub size_bytes: i64,
pub checksum: String, pub checksum: String,
pub created_at: String, pub created_at: String,
#[schema(value_type = Object)]
pub metadata: Value, pub metadata: Value,
} }
+1
View File
@@ -16,6 +16,7 @@ use crate::utils::time::to_iso;
pub struct DocumentCorrespondentResponse { pub struct DocumentCorrespondentResponse {
pub id: Uuid, pub id: Uuid,
pub name: String, pub name: String,
#[schema(value_type = Object)]
pub metadata: Value, pub metadata: Value,
pub assigned_at: String, pub assigned_at: String,
} }
+1 -1
View File
@@ -3,7 +3,7 @@ use std::net::SocketAddr;
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tower::make::Shared; use tower::make::Shared;
use backend::{routes, utils::bootstrap::init_component}; use papercrate::{routes, utils::bootstrap::init_component};
#[tokio::main] #[tokio::main]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
+78 -2
View File
@@ -10,7 +10,9 @@ use std::io::Write;
use std::str; use std::str;
use uuid::Uuid; use uuid::Uuid;
use crate::schema::sql_types::TenantStatus as TenantStatusSql; use crate::schema::sql_types::{
MagicTokenKind as MagicTokenKindSql, TenantStatus as TenantStatusSql,
};
use crate::schema::*; use crate::schema::*;
#[derive(Debug, Clone, Queryable, Identifiable, Associations)] #[derive(Debug, Clone, Queryable, Identifiable, Associations)]
@@ -43,6 +45,64 @@ pub enum TenantStatus {
Error, Error,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, AsExpression, FromSqlRow)]
#[diesel(sql_type = MagicTokenKindSql)]
pub enum MagicTokenKind {
EmailLogin,
DemoLogin,
}
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 fmt::Display for MagicTokenKind {
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 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 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 TenantStatus { impl TenantStatus {
pub fn as_str(&self) -> &'static str { pub fn as_str(&self) -> &'static str {
match self { match self {
@@ -240,7 +300,7 @@ 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 uploaded_at: NaiveDateTime, pub created_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,
@@ -265,6 +325,22 @@ pub struct NewDocument {
pub tenant_id: Uuid, 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)]
#[diesel(table_name = document_versions)] #[diesel(table_name = document_versions)]
#[diesel(belongs_to(Document))] #[diesel(belongs_to(Document))]
+91 -1185
View File
File diff suppressed because it is too large Load Diff
+326 -45
View File
@@ -13,6 +13,7 @@ use diesel::{pg::PgConnection, prelude::*};
use rand::{rngs::OsRng, RngCore}; use rand::{rngs::OsRng, RngCore};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use utoipa::{OpenApi, ToSchema};
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
@@ -24,12 +25,18 @@ use crate::{
AuthenticatedUser, AuthenticatedUser,
}, },
error::{AppError, AppResult}, error::{AppError, AppResult},
models::{NewRefreshToken, NewUser, RefreshToken, Tenant, TenantStatus, User, UserMembership}, models::{
MagicToken, MagicTokenKind, NewRefreshToken, NewUser, RefreshToken, TenantStatus, User,
},
schema::{ schema::{
refresh_tokens, tenants::dsl as tenant_dsl, user_memberships::dsl as memberships_dsl, magic_tokens::dsl as magic_dsl, refresh_tokens, tenants::dsl as tenant_dsl,
user_passkeys::dsl as passkey_dsl, users::dsl, user_memberships::dsl as memberships_dsl, user_passkeys::dsl as passkey_dsl, users::dsl,
}, },
state::AppState, state::AppState,
tenants::{
apply_refresh_token_hash, apply_tenant_guc, apply_user_guc, clear_refresh_token_hash,
clear_user_guc,
},
}; };
use crate::schema::refresh_tokens::dsl as refresh_dsl; use crate::schema::refresh_tokens::dsl as refresh_dsl;
@@ -37,15 +44,22 @@ use webauthn_rs::prelude::RegisterPublicKeyCredential;
const REFRESH_COOKIE_NAME: &str = "refresh_token"; const REFRESH_COOKIE_NAME: &str = "refresh_token";
#[derive(Deserialize)] #[derive(Deserialize, ToSchema)]
pub struct LoginRequest { pub struct LoginRequest {
pub username: String,
pub password: String,
#[serde(default)] #[serde(default)]
pub username: String,
#[serde(default)]
#[schema(nullable)]
pub password: Option<String>,
#[serde(default)]
#[schema(nullable)]
pub magic_token: Option<String>,
#[serde(default)]
#[schema(nullable)]
pub preferred_tenant_id: Option<Uuid>, pub preferred_tenant_id: Option<Uuid>,
} }
#[derive(Deserialize, Serialize)] #[derive(Deserialize, Serialize, ToSchema)]
pub struct LoginResponse { pub struct LoginResponse {
pub access_token: String, pub access_token: String,
pub token_type: String, pub token_type: String,
@@ -53,52 +67,151 @@ pub struct LoginResponse {
pub tenant: TenantSnippet, pub tenant: TenantSnippet,
} }
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize, ToSchema)]
pub struct TenantSnippet { pub struct TenantSnippet {
pub id: Uuid, pub id: Uuid,
pub name: String, pub name: String,
} }
#[derive(Serialize)] #[derive(Serialize, Deserialize, ToSchema)]
pub struct TenantSelectionResponse { pub struct TenantSelectionResponse {
pub access_token: String, pub access_token: String,
pub tenants: Vec<TenantSnippet>, pub tenants: Vec<TenantSnippet>,
} }
#[derive(Serialize)] #[derive(Serialize, Deserialize, ToSchema)]
pub struct TenantListResponse { pub struct TenantListResponse {
pub tenants: Vec<TenantSnippet>, pub tenants: Vec<TenantSnippet>,
} }
#[derive(Deserialize)] #[derive(Deserialize, ToSchema)]
pub struct TenantSelectionRequest { pub struct TenantSelectionRequest {
pub tenant_id: Uuid, pub tenant_id: Uuid,
} }
#[derive(Deserialize)] #[derive(Deserialize, ToSchema)]
pub struct SignupStartRequest { pub struct SignupStartRequest {
pub username: String, pub username: String,
} }
#[derive(Serialize)] #[derive(Serialize, Deserialize, ToSchema)]
pub struct SignupStartResponse { pub struct SignupStartResponse {
pub signup_token: String, pub signup_token: String,
pub challenge: RegistrationChallengeResponse, pub challenge: RegistrationChallengeResponse,
} }
#[derive(Deserialize)] #[derive(Deserialize, ToSchema)]
pub struct SignupFinishRequest { pub struct SignupFinishRequest {
pub signup_token: String, pub signup_token: String,
#[schema(value_type = Object)]
pub credential: RegisterPublicKeyCredential, pub credential: RegisterPublicKeyCredential,
#[schema(nullable)]
pub nickname: Option<String>, pub nickname: Option<String>,
} }
pub async fn login(_state: State<AppState>, _payload: Json<LoginRequest>) -> AppResult<Response> { #[derive(Serialize, Deserialize, ToSchema)]
Err(AppError::bad_request( #[serde(untagged)]
"password authentication is no longer supported", pub enum LoginResponseVariants {
)) Token(LoginResponse),
Selection(TenantSelectionResponse),
} }
#[derive(OpenApi)]
#[openapi(
paths(
login,
signup_start,
signup_finish,
refresh,
logout,
me,
list_tenants,
select_tenant,
passkey_register_start,
passkey_register_finish,
passkey_login_start,
passkey_login_finish,
),
components(schemas(
LoginRequest,
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,
))
)]
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(
State(state): State<AppState>,
Json(payload): Json<LoginRequest>,
) -> AppResult<Response> {
let magic_token = payload
.magic_token
.as_ref()
.map(|value| value.trim())
.filter(|value| !value.is_empty());
if magic_token.is_none() {
if payload.password.is_some() {
return Err(AppError::bad_request(
"password authentication is no longer supported",
));
}
return Err(AppError::bad_request(
"magic_token is required for passwordless login",
));
}
let token_value = magic_token.unwrap();
let mut conn = state.db_unscoped()?;
let username_hint = payload.username.trim();
let preferred_tenant_id = payload.preferred_tenant_id;
magic_token_login(
&state,
&mut conn,
token_value,
(!username_hint.is_empty()).then_some(username_hint),
preferred_tenant_id,
)
}
#[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( pub async fn signup_start(
State(state): State<AppState>, State(state): State<AppState>,
Json(payload): Json<SignupStartRequest>, Json(payload): Json<SignupStartRequest>,
@@ -136,6 +249,17 @@ pub async fn signup_start(
})) }))
} }
#[utoipa::path(
post,
path = "/api/auth/signup/finish",
request_body = SignupFinishRequest,
responses(
(status = 200, description = "Signup completed", body = LoginResponseVariants),
(status = 400, description = "Invalid signup completion"),
(status = 409, description = "Username already exists")
),
tag = "Auth"
)]
pub async fn signup_finish( pub async fn signup_finish(
State(state): State<AppState>, State(state): State<AppState>,
Json(payload): Json<SignupFinishRequest>, Json(payload): Json<SignupFinishRequest>,
@@ -193,6 +317,15 @@ pub async fn signup_finish(
Ok(response) Ok(response)
} }
#[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>>,
@@ -207,6 +340,7 @@ pub async fn refresh(
let now = Utc::now(); let now = Utc::now();
let now_naive = now.naive_utc(); let now_naive = now.naive_utc();
apply_refresh_token_hash(&mut conn, &hashed)?;
let token = match refresh_dsl::refresh_tokens let token = match refresh_dsl::refresh_tokens
.filter(refresh_dsl::token_hash.eq(&hashed)) .filter(refresh_dsl::token_hash.eq(&hashed))
.filter(refresh_dsl::revoked_at.is_null()) .filter(refresh_dsl::revoked_at.is_null())
@@ -218,6 +352,10 @@ pub async fn refresh(
Err(err) => return Err(AppError::from(err)), Err(err) => return Err(AppError::from(err)),
}; };
clear_refresh_token_hash(&mut conn)?;
apply_tenant_guc(&mut conn, token.tenant_id)?;
clear_user_guc(&mut conn)?;
diesel::update(refresh_dsl::refresh_tokens.filter(refresh_dsl::id.eq(token.id))) diesel::update(refresh_dsl::refresh_tokens.filter(refresh_dsl::id.eq(token.id)))
.set(( .set((
refresh_dsl::revoked_at.eq(now_naive), refresh_dsl::revoked_at.eq(now_naive),
@@ -246,6 +384,13 @@ fn insert_user(conn: &mut PgConnection, id: Uuid, username: &str) -> AppResult<(
.map_err(AppError::from) .map_err(AppError::from)
} }
#[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( pub async fn select_tenant(
State(state): State<AppState>, State(state): State<AppState>,
TypedHeader(Authorization(bearer)): TypedHeader<Authorization<Bearer>>, TypedHeader(Authorization(bearer)): TypedHeader<Authorization<Bearer>>,
@@ -261,15 +406,17 @@ pub async fn select_tenant(
}; };
let mut conn = state.db_unscoped()?; let mut conn = state.db_unscoped()?;
apply_user_guc(&mut conn, user_id)?;
let membership_exists = memberships_dsl::user_memberships let membership_exists = memberships_dsl::user_memberships
.filter(memberships_dsl::user_id.eq(user_id)) .filter(memberships_dsl::user_id.eq(user_id))
.filter(memberships_dsl::tenant_id.eq(payload.tenant_id)) .filter(memberships_dsl::tenant_id.eq(payload.tenant_id))
.inner_join(tenant_dsl::tenants) .select(memberships_dsl::tenant_id)
.select(memberships_dsl::id)
.first::<Uuid>(&mut conn) .first::<Uuid>(&mut conn)
.optional()?; .optional()?;
clear_user_guc(&mut conn)?;
if membership_exists.is_none() { if membership_exists.is_none() {
return Err(AppError::unauthorized()); return Err(AppError::unauthorized());
} }
@@ -282,12 +429,18 @@ pub async fn select_tenant(
issue_session(&state, &mut conn, &user, payload.tenant_id) issue_session(&state, &mut conn, &user, 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>,
user: AuthenticatedUser, user: AuthenticatedUser,
jar: Option<TypedHeader<Cookie>>, jar: Option<TypedHeader<Cookie>>,
) -> AppResult<(HeaderMap, StatusCode)> { ) -> AppResult<(HeaderMap, StatusCode)> {
let mut conn = state.db_unscoped()?; let mut conn = state.db_for_tenant(user.tenant_id)?;
let now = Utc::now().naive_utc(); let now = Utc::now().naive_utc();
let mut rows_affected = 0; let mut rows_affected = 0;
@@ -327,10 +480,22 @@ pub async fn logout(
Ok((headers, StatusCode::NO_CONTENT)) Ok((headers, StatusCode::NO_CONTENT))
} }
#[utoipa::path(
get,
path = "/api/auth/me",
responses((status = 200, description = "Authenticated principal", body = 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(
get,
path = "/api/auth/tenants",
responses((status = 200, description = "Available tenants", body = TenantListResponse)),
tag = "Auth"
)]
pub async fn list_tenants( pub async fn list_tenants(
State(state): State<AppState>, State(state): State<AppState>,
auth: Option<TypedHeader<Authorization<Bearer>>>, auth: Option<TypedHeader<Authorization<Bearer>>>,
@@ -350,19 +515,39 @@ pub async fn list_tenants(
}; };
let mut conn = state.db_unscoped()?; let mut conn = state.db_unscoped()?;
apply_user_guc(&mut conn, user_id)?;
let tenants = memberships_dsl::user_memberships let tenant_ids: Vec<Uuid> = memberships_dsl::user_memberships
.inner_join(tenant_dsl::tenants)
.filter(memberships_dsl::user_id.eq(user_id)) .filter(memberships_dsl::user_id.eq(user_id))
.select((tenant_dsl::id, tenant_dsl::name)) .select(memberships_dsl::tenant_id)
.load::<(Uuid, String)>(&mut conn)? .load(&mut conn)?;
.into_iter()
.map(|(id, name)| TenantSnippet { id, name }) clear_user_guc(&mut conn)?;
.collect(); drop(conn);
let mut tenants = Vec::with_capacity(tenant_ids.len());
for tenant_id in tenant_ids {
let mut tenant_conn = state.db_for_tenant(tenant_id)?;
let name: String = tenant_dsl::tenants
.find(tenant_id)
.select(tenant_dsl::name)
.first(&mut tenant_conn)
.map_err(AppError::from)?;
tenants.push(TenantSnippet {
id: tenant_id,
name,
});
}
Ok(Json(TenantListResponse { tenants })) Ok(Json(TenantListResponse { tenants }))
} }
#[utoipa::path(
post,
path = "/api/auth/passkeys/register/start",
responses((status = 200, description = "Passkey registration challenge", body = RegistrationChallengeResponse)),
tag = "Auth"
)]
pub async fn passkey_register_start( pub async fn passkey_register_start(
State(state): State<AppState>, State(state): State<AppState>,
user: AuthenticatedUser, user: AuthenticatedUser,
@@ -378,6 +563,13 @@ pub async fn passkey_register_start(
Ok(Json(challenge)) Ok(Json(challenge))
} }
#[utoipa::path(
post,
path = "/api/auth/passkeys/register/finish",
request_body = PasskeyRegistrationFinishPayload,
responses((status = 200, description = "Passkey registered", body = PasskeySummary)),
tag = "Auth"
)]
pub async fn passkey_register_finish( pub async fn passkey_register_finish(
State(state): State<AppState>, State(state): State<AppState>,
user: AuthenticatedUser, user: AuthenticatedUser,
@@ -408,6 +600,13 @@ pub async fn passkey_register_finish(
Ok(Json(PasskeySummary::from(passkey))) Ok(Json(PasskeySummary::from(passkey)))
} }
#[utoipa::path(
post,
path = "/api/auth/passkeys/login/start",
request_body = PasskeyLoginStartPayload,
responses((status = 200, description = "Passkey authentication challenge", body = AuthenticationChallengeResponse)),
tag = "Auth"
)]
pub async fn passkey_login_start( pub async fn passkey_login_start(
State(state): State<AppState>, State(state): State<AppState>,
Json(payload): Json<PasskeyLoginStartPayload>, Json(payload): Json<PasskeyLoginStartPayload>,
@@ -431,6 +630,16 @@ pub async fn passkey_login_start(
Ok(Json(challenge)) Ok(Json(challenge))
} }
#[utoipa::path(
post,
path = "/api/auth/passkeys/login/finish",
request_body = PasskeyLoginFinishPayload,
responses(
(status = 200, description = "Passkey login successful", body = LoginResponseVariants),
(status = 401, description = "Authentication failed")
),
tag = "Auth"
)]
pub async fn passkey_login_finish( pub async fn passkey_login_finish(
State(state): State<AppState>, State(state): State<AppState>,
Json(payload): Json<PasskeyLoginFinishPayload>, Json(payload): Json<PasskeyLoginFinishPayload>,
@@ -457,26 +666,27 @@ fn complete_login(
user: &User, user: &User,
preferred_tenant_id: Option<Uuid>, preferred_tenant_id: Option<Uuid>,
) -> AppResult<Response> { ) -> AppResult<Response> {
let memberships: Vec<(UserMembership, Tenant)> = memberships_dsl::user_memberships apply_user_guc(conn, user.id)?;
.inner_join(tenant_dsl::tenants) let tenant_ids: Vec<Uuid> = memberships_dsl::user_memberships
.filter(memberships_dsl::user_id.eq(user.id)) .filter(memberships_dsl::user_id.eq(user.id))
.select(memberships_dsl::tenant_id)
.load(conn)?; .load(conn)?;
clear_user_guc(conn)?;
if memberships.is_empty() { tracing::debug!(user_id = %user.id, tenants = tenant_ids.len(), "passkey login memberships");
if tenant_ids.is_empty() {
return Err(AppError::unauthorized()); return Err(AppError::unauthorized());
} }
if let Some(preferred_id) = preferred_tenant_id { if let Some(preferred_id) = preferred_tenant_id {
if let Some((_, tenant)) = memberships if tenant_ids.iter().any(|id| *id == preferred_id) {
.iter() return issue_session(state, conn, user, preferred_id);
.find(|(_, tenant)| tenant.id == preferred_id)
{
return issue_session(state, conn, user, tenant.id);
} }
} }
if memberships.len() == 1 { if tenant_ids.len() == 1 {
return issue_session(state, conn, user, memberships[0].1.id); return issue_session(state, conn, user, tenant_ids[0]);
} }
let selection_token = state let selection_token = state
@@ -484,13 +694,19 @@ fn complete_login(
.generate_tenant_selector_token(user.id) .generate_tenant_selector_token(user.id)
.map_err(AppError::from)?; .map_err(AppError::from)?;
let tenants = memberships let mut tenants = Vec::with_capacity(tenant_ids.len());
.into_iter() for tenant_id in tenant_ids {
.map(|(_, tenant)| TenantSnippet { let mut tenant_conn = state.db_for_tenant(tenant_id)?;
id: tenant.id, let name: String = tenant_dsl::tenants
name: tenant.name, .find(tenant_id)
}) .select(tenant_dsl::name)
.collect(); .first(&mut tenant_conn)
.map_err(AppError::from)?;
tenants.push(TenantSnippet {
id: tenant_id,
name,
});
}
Ok(Json(TenantSelectionResponse { Ok(Json(TenantSelectionResponse {
access_token: selection_token, access_token: selection_token,
@@ -499,12 +715,73 @@ fn complete_login(
.into_response()) .into_response())
} }
fn magic_token_login(
state: &AppState,
conn: &mut PgConnection,
token_value: &str,
username_hint: Option<&str>,
preferred_tenant_id: Option<Uuid>,
) -> AppResult<Response> {
if token_value.is_empty() {
return Err(AppError::bad_request("magic_token must not be empty"));
}
let token_hash = hash_magic_token(token_value);
let now = Utc::now();
let now_naive = now.naive_utc();
conn.transaction::<Response, AppError, _>(|conn| {
let magic = magic_dsl::magic_tokens
.filter(magic_dsl::token_hash.eq(&token_hash))
.filter(magic_dsl::expires_at.gt(now_naive))
.first::<MagicToken>(conn)
.map_err(|err| match err {
diesel::result::Error::NotFound => AppError::unauthorized(),
_ => AppError::from(err),
})?;
if let Some(limit) = magic.max_uses {
if magic.used_count >= limit {
return Err(AppError::unauthorized());
}
}
match magic.kind {
MagicTokenKind::EmailLogin | MagicTokenKind::DemoLogin => {}
}
let user: User = dsl::users
.find(magic.user_id)
.first(conn)
.map_err(AppError::from)?;
if let Some(expected) = username_hint {
if expected != user.username {
return Err(AppError::unauthorized());
}
}
diesel::update(magic_dsl::magic_tokens.filter(magic_dsl::id.eq(magic.id)))
.set((
magic_dsl::used_count.eq(magic.used_count + 1),
magic_dsl::last_used_at.eq(Some(now_naive)),
))
.execute(conn)?;
complete_login(state, conn, &user, preferred_tenant_id)
})
}
fn issue_session( fn issue_session(
state: &AppState, state: &AppState,
conn: &mut PgConnection, conn: &mut PgConnection,
user: &User, user: &User,
tenant_id: Uuid, tenant_id: Uuid,
) -> AppResult<Response> { ) -> AppResult<Response> {
apply_tenant_guc(conn, tenant_id)?;
clear_user_guc(conn)?;
clear_refresh_token_hash(conn)?;
let now = Utc::now(); let now = Utc::now();
let access_token = state let access_token = state
.jwt .jwt
@@ -559,6 +836,10 @@ fn hash_refresh_token(token: &str) -> String {
hex::encode(hasher.finalize()) hex::encode(hasher.finalize())
} }
fn hash_magic_token(token: &str) -> String {
hash_refresh_token(token)
}
fn generate_refresh_token() -> String { fn generate_refresh_token() -> String {
let mut bytes = [0u8; 32]; let mut bytes = [0u8; 32];
OsRng.fill_bytes(&mut bytes); OsRng.fill_bytes(&mut bytes);
+72 -21
View File
@@ -5,6 +5,7 @@ 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::{
@@ -14,35 +15,40 @@ use crate::{
schema::{correspondents, document_correspondents}, schema::{correspondents, document_correspondents},
utils::{ utils::{
db::{no_content, EnsureEntity, IntoJsonResponse}, db::{no_content, EnsureEntity, IntoJsonResponse},
named_entity::{ensure_name_available, normalize_name},
time::to_iso, time::to_iso,
}, },
}; };
#[derive(Serialize)] #[derive(Serialize, ToSchema)]
pub struct CorrespondentUsage { pub struct CorrespondentUsage {
pub total: i64, pub total: i64,
} }
#[derive(Serialize)] #[derive(Serialize, ToSchema)]
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)] #[derive(Deserialize, ToSchema)]
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)] #[derive(Deserialize, ToSchema)]
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>,
} }
@@ -53,6 +59,12 @@ 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 { TenantScopedConn {
mut conn, mut conn,
@@ -85,6 +97,13 @@ pub async fn list_correspondents(
response.into_json() response.into_json()
} }
#[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 { TenantScopedConn {
mut conn, mut conn,
@@ -93,16 +112,15 @@ pub async fn create_correspondent(
}: TenantScopedConn, }: TenantScopedConn,
Json(payload): Json<CreateCorrespondentRequest>, Json(payload): Json<CreateCorrespondentRequest>,
) -> AppResult<Json<CorrespondentSummary>> { ) -> AppResult<Json<CorrespondentSummary>> {
let name = payload.name.trim(); let name = normalize_name(&payload.name, || {
if name.is_empty() { AppError::bad_request("name must not be 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.to_string(), name: name.clone(),
metadata: metadata_value, metadata: metadata_value,
tenant_id, tenant_id,
}; };
@@ -127,6 +145,14 @@ pub async fn create_correspondent(
build_summary(correspondent, 0).into_json() build_summary(correspondent, 0).into_json()
} }
#[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(
Path(correspondent_id): Path<Uuid>, Path(correspondent_id): Path<Uuid>,
TenantScopedConn { TenantScopedConn {
@@ -144,21 +170,22 @@ pub async fn update_correspondent(
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 trimmed = candidate.trim(); let normalized = normalize_name(candidate, || {
if trimmed.is_empty() { AppError::bad_request("name must not be empty")
return Err(AppError::bad_request("name must not be empty")); })?;
} if normalized != existing.name {
if trimmed != existing.name { ensure_name_available(
let duplicate = correspondents::table || {
.filter(correspondents::name.eq(trimmed)) correspondents::table
.filter(correspondents::name.eq(&normalized))
.filter(correspondents::id.ne(correspondent_id)) .filter(correspondents::id.ne(correspondent_id))
.filter(correspondents::tenant_id.eq(tenant_id)) .filter(correspondents::tenant_id.eq(tenant_id))
.first::<Correspondent>(&mut conn) .first::<Correspondent>(&mut conn)
.optional()?; .optional()
if duplicate.is_some() { },
return Err(AppError::bad_request("correspondent name already exists")); || AppError::bad_request("correspondent name already exists"),
} )?;
new_name = Some(trimmed.to_string()); new_name = Some(normalized);
} }
} }
@@ -201,6 +228,13 @@ pub async fn update_correspondent(
build_summary(updated, usage).into_json() build_summary(updated, usage).into_json()
} }
#[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(
Path(correspondent_id): Path<Uuid>, Path(correspondent_id): Path<Uuid>,
TenantScopedConn { TenantScopedConn {
@@ -264,3 +298,20 @@ fn load_usage_for_correspondent(
Ok(total) Ok(total)
} }
#[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;
+300 -31
View File
@@ -8,7 +8,7 @@ use axum::http::StatusCode;
use axum::response::IntoResponse; use axum::response::IntoResponse;
use chrono::{DateTime, NaiveDateTime, Utc}; use chrono::{DateTime, NaiveDateTime, Utc};
use diesel::dsl::exists; use diesel::dsl::exists;
use diesel::{prelude::*, result::DatabaseErrorKind, select}; use diesel::{prelude::*, result::DatabaseErrorKind, select, OptionalExtension};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{json, Value}; use serde_json::{json, Value};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
@@ -59,13 +59,16 @@ const PRESIGNED_URL_EXPIRY_SECONDS: u64 = 300;
#[derive(Deserialize, IntoParams, ToSchema)] #[derive(Deserialize, IntoParams, ToSchema)]
#[into_params(parameter_in = Query)] #[into_params(parameter_in = Query)]
pub struct DocumentListQuery { pub struct DocumentListQuery {
#[schema(nullable)]
pub folder_id: Option<Uuid>, pub folder_id: Option<Uuid>,
#[serde(default)] #[serde(default)]
#[schema(nullable)]
pub include_descendants: Option<bool>, pub include_descendants: Option<bool>,
pub query: Option<String>, pub query: Option<String>,
pub tags: Option<String>, pub tags: Option<String>,
pub correspondents: Option<String>, pub correspondents: Option<String>,
#[serde(default = "default_document_status_filter")] #[serde(default = "default_document_status_filter")]
#[schema(default = "active")]
pub status: DocumentStatusFilter, pub status: DocumentStatusFilter,
} }
@@ -85,6 +88,7 @@ pub enum DocumentStatusFilter {
#[into_params(parameter_in = Query)] #[into_params(parameter_in = Query)]
pub struct AssetRequestQuery { pub struct AssetRequestQuery {
#[serde(default)] #[serde(default)]
#[schema(default = false)]
pub force: bool, pub force: bool,
} }
@@ -98,23 +102,30 @@ pub struct DocumentCheckQuery {
pub struct DocumentCheckResponse { pub struct DocumentCheckResponse {
pub exists: bool, pub exists: bool,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
#[schema(nullable)]
pub document_id: Option<Uuid>, pub document_id: Option<Uuid>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
#[schema(nullable)]
pub title: Option<String>, pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
#[schema(nullable)]
pub filename: Option<String>, pub filename: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
#[schema(nullable)]
pub version_id: Option<Uuid>, pub version_id: Option<Uuid>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
#[schema(nullable)]
pub version_number: Option<i32>, pub version_number: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub uploaded_at: Option<String>, #[schema(nullable)]
pub created_at: Option<String>,
} }
#[derive(Serialize, ToSchema)] #[derive(Serialize, ToSchema)]
pub struct TagResponse { pub struct TagResponse {
pub id: Uuid, pub id: Uuid,
pub label: String, pub label: String,
#[schema(nullable)]
pub color: Option<String>, pub color: Option<String>,
} }
@@ -134,17 +145,23 @@ pub struct DocumentResponse {
pub filename: String, pub filename: String,
pub title: String, pub title: String,
pub original_name: String, pub original_name: String,
#[schema(nullable)]
pub content_type: Option<String>, pub content_type: Option<String>,
#[schema(nullable)]
pub folder_id: Option<Uuid>, pub folder_id: Option<Uuid>,
pub uploaded_at: String, pub created_at: String,
pub updated_at: String, pub updated_at: String,
#[schema(nullable)]
pub deleted_at: Option<String>, pub deleted_at: Option<String>,
#[schema(nullable)]
pub issued_at: Option<String>, pub issued_at: Option<String>,
#[schema(value_type = Object)]
pub metadata: Value, pub metadata: Value,
pub tags: Vec<TagResponse>, pub tags: Vec<TagResponse>,
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
pub correspondents: Vec<DocumentCorrespondentResponse>, pub correspondents: Vec<DocumentCorrespondentResponse>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
#[schema(nullable)]
pub current_version: Option<DocumentVersionDetailResponse>, pub current_version: Option<DocumentVersionDetailResponse>,
} }
#[derive(Serialize, ToSchema)] #[derive(Serialize, ToSchema)]
@@ -160,11 +177,13 @@ pub struct BulkReanalyzeResponse {
#[derive(Deserialize, ToSchema)] #[derive(Deserialize, ToSchema)]
pub struct BulkMoveRequest { pub struct BulkMoveRequest {
pub document_ids: Vec<Uuid>, pub document_ids: Vec<Uuid>,
#[schema(nullable)]
pub folder_id: Option<Uuid>, pub folder_id: Option<Uuid>,
} }
#[derive(Deserialize, ToSchema)] #[derive(Deserialize, ToSchema)]
pub struct DocumentMetadataUpdate { pub struct DocumentMetadataUpdate {
#[schema(value_type = Object)]
pub value: Value, pub value: Value,
#[serde(default)] #[serde(default)]
#[schema(default = false)] #[schema(default = false)]
@@ -179,7 +198,7 @@ pub struct UpdateDocumentRequest {
#[schema(nullable, value_type = Option<String>)] #[schema(nullable, value_type = Option<String>)]
pub issued_at: Option<Value>, pub issued_at: Option<Value>,
#[serde(default)] #[serde(default)]
#[schema(nullable)] #[schema(nullable, value_type = Object)]
pub metadata: Option<DocumentMetadataUpdate>, pub metadata: Option<DocumentMetadataUpdate>,
} }
@@ -223,6 +242,7 @@ pub struct CorrespondentAssignmentInput {
pub struct AssignCorrespondentsRequest { pub struct AssignCorrespondentsRequest {
pub assignments: Vec<CorrespondentAssignmentInput>, pub assignments: Vec<CorrespondentAssignmentInput>,
#[serde(default)] #[serde(default)]
#[schema(default = false)]
pub replace: bool, pub replace: bool,
} }
@@ -259,6 +279,7 @@ pub struct BulkCorrespondentsRequest {
pub struct BulkReanalyzeSelectionRequest { pub struct BulkReanalyzeSelectionRequest {
pub document_ids: Vec<Uuid>, pub document_ids: Vec<Uuid>,
#[serde(default = "default_true")] #[serde(default = "default_true")]
#[schema(default = true)]
pub force: bool, pub force: bool,
} }
@@ -291,7 +312,7 @@ pub struct UploadDocumentForm {
pub file: String, pub file: String,
#[schema(nullable)] #[schema(nullable)]
pub folder_id: Option<Uuid>, pub folder_id: Option<Uuid>,
#[schema(nullable)] #[schema(nullable, value_type = Object)]
pub metadata: Option<Value>, pub metadata: Option<Value>,
#[schema(nullable)] #[schema(nullable)]
pub title: Option<String>, pub title: Option<String>,
@@ -330,6 +351,13 @@ pub struct AssetObjectsQuery {
pub limit: Option<i32>, pub limit: Option<i32>,
} }
#[utoipa::path(
get,
path = "/api/documents",
params(DocumentListQuery),
responses((status = 200, description = "List documents", body = [DocumentResponse])),
tag = "Documents"
)]
pub async fn list_documents( pub async fn list_documents(
State(state): State<AppState>, State(state): State<AppState>,
Query(params): Query<DocumentListQuery>, Query(params): Query<DocumentListQuery>,
@@ -378,7 +406,6 @@ pub async fn list_documents(
.map(|s| s.trim()) .map(|s| s.trim())
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
.map(|s| s.to_owned()); .map(|s| s.to_owned());
let include_descendants = include_descendants.unwrap_or(true); let include_descendants = include_descendants.unwrap_or(true);
match (folder_id, include_descendants) { match (folder_id, include_descendants) {
@@ -554,41 +581,29 @@ pub async fn list_documents(
if !by_id.is_empty() { if !by_id.is_empty() {
let mut remaining: Vec<Document> = by_id.into_values().collect(); let mut remaining: Vec<Document> = by_id.into_values().collect();
remaining.sort_by(|a, b| b.uploaded_at.cmp(&a.uploaded_at)); remaining.sort_by(|a, b| b.created_at.cmp(&a.created_at));
ordered.extend(remaining); ordered.extend(remaining);
} }
ordered ordered
} else { } else {
docs_query docs_query
.order(documents::uploaded_at.desc()) .order(documents::created_at.desc())
.load(&mut conn)? .load(&mut conn)?
}; };
let doc_ids: Vec<Uuid> = docs.iter().map(|doc| doc.id).collect(); let response = hydrate_documents(&state, &mut conn, tenant_id, user_id, docs)?;
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, tenant_id, &docs)?;
let mut response = 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();
response.push(to_document_response(
&state,
user_id,
doc,
tags,
correspondents,
current_version,
)?);
}
Ok(Json(response)) Ok(Json(response))
} }
#[utoipa::path(
get,
path = "/api/documents/check",
params(DocumentCheckQuery),
responses((status = 200, description = "Checksum lookup", body = DocumentCheckResponse)),
tag = "Documents"
)]
pub async fn check_document( pub async fn check_document(
Query(query): Query<DocumentCheckQuery>, Query(query): Query<DocumentCheckQuery>,
TenantScopedConn { TenantScopedConn {
@@ -627,7 +642,7 @@ pub async fn check_document(
filename: Some(document.filename.clone()), filename: Some(document.filename.clone()),
version_id: Some(version.id), version_id: Some(version.id),
version_number: Some(version.version_number), version_number: Some(version.version_number),
uploaded_at: Some(to_iso(document.uploaded_at)), created_at: Some(to_iso(document.created_at)),
})) }))
} else { } else {
Ok(Json(DocumentCheckResponse { Ok(Json(DocumentCheckResponse {
@@ -637,11 +652,18 @@ pub async fn check_document(
filename: None, filename: None,
version_id: None, version_id: None,
version_number: None, version_number: None,
uploaded_at: None, created_at: None,
})) }))
} }
} }
#[utoipa::path(
get,
path = "/api/documents/{id}",
params(("id" = Uuid, Path, description = "Document ID")),
responses((status = 200, description = "Document detail", body = DocumentDetailResponse)),
tag = "Documents"
)]
pub async fn get_document( pub async fn get_document(
State(state): State<AppState>, State(state): State<AppState>,
Path(document_id): Path<Uuid>, Path(document_id): Path<Uuid>,
@@ -684,6 +706,17 @@ pub async fn get_document(
})) }))
} }
#[utoipa::path(
post,
path = "/api/documents",
request_body = UploadDocumentForm,
responses(
(status = 201, description = "Document created", body = DocumentDetailResponse),
(status = 200, description = "Existing document reused", body = DocumentDetailResponse),
(status = 204, description = "Upload skipped because the document already exists")
),
tag = "Documents"
)]
pub async fn upload_document( pub async fn upload_document(
State(state): State<AppState>, State(state): State<AppState>,
TenantScopedConn { TenantScopedConn {
@@ -889,6 +922,13 @@ pub async fn upload_document(
Ok(response) Ok(response)
} }
#[utoipa::path(
post,
path = "/api/documents/{id}/assets",
params(("id" = Uuid, Path, description = "Document ID"), AssetRequestQuery),
responses((status = 202, description = "Asset generation requested")),
tag = "Assets"
)]
pub async fn request_document_assets( pub async fn request_document_assets(
Path(document_id): Path<Uuid>, Path(document_id): Path<Uuid>,
Query(query): Query<AssetRequestQuery>, Query(query): Query<AssetRequestQuery>,
@@ -925,6 +965,13 @@ pub async fn request_document_assets(
Ok(StatusCode::ACCEPTED) Ok(StatusCode::ACCEPTED)
} }
#[utoipa::path(
post,
path = "/api/documents/bulk/reanalyze",
request_body = BulkReanalyzeSelectionRequest,
responses((status = 200, description = "Reanalyze queued", body = BulkReanalyzeResponse)),
tag = "Documents"
)]
pub async fn reanalyze_selected_documents( pub async fn reanalyze_selected_documents(
TenantScopedConn { TenantScopedConn {
mut conn, mut conn,
@@ -976,6 +1023,13 @@ pub async fn reanalyze_selected_documents(
Ok((StatusCode::ACCEPTED, Json(BulkReanalyzeResponse { queued }))) Ok((StatusCode::ACCEPTED, Json(BulkReanalyzeResponse { queued })))
} }
#[utoipa::path(
get,
path = "/api/documents/{id}/assets",
params(("id" = Uuid, Path, description = "Document ID")),
responses((status = 200, description = "Document assets", body = [DocumentAssetResponse])),
tag = "Assets"
)]
pub async fn list_document_assets( pub async fn list_document_assets(
State(state): State<AppState>, State(state): State<AppState>,
Path(document_id): Path<Uuid>, Path(document_id): Path<Uuid>,
@@ -1000,6 +1054,13 @@ pub async fn list_document_assets(
Ok(Json(assets)) Ok(Json(assets))
} }
#[utoipa::path(
get,
path = "/api/assets/{asset_id}",
params(("asset_id" = Uuid, Path, description = "Asset ID"), AssetObjectsQuery),
responses((status = 200, description = "Asset detail", body = DocumentAssetDetailResponse)),
tag = "Assets"
)]
pub async fn get_document_asset( pub async fn get_document_asset(
State(state): State<AppState>, State(state): State<AppState>,
Path(asset_id): Path<Uuid>, Path(asset_id): Path<Uuid>,
@@ -1074,6 +1135,13 @@ pub async fn get_document_asset(
Ok(Json(to_asset_detail_response(asset, object_responses))) Ok(Json(to_asset_detail_response(asset, object_responses)))
} }
#[utoipa::path(
get,
path = "/api/documents/{id}/versions",
params(("id" = Uuid, Path, description = "Document ID")),
responses((status = 200, description = "Document versions", body = [DocumentVersionResponse])),
tag = "Documents"
)]
pub async fn list_document_versions( pub async fn list_document_versions(
Path(document_id): Path<Uuid>, Path(document_id): Path<Uuid>,
TenantScopedConn { TenantScopedConn {
@@ -1103,6 +1171,16 @@ pub async fn list_document_versions(
Ok(Json(versions)) Ok(Json(versions))
} }
#[utoipa::path(
get,
path = "/api/documents/{id}/versions/{version_id}",
params(
("id" = Uuid, Path, description = "Document ID"),
("version_id" = Uuid, Path, description = "Version ID"),
),
responses((status = 200, description = "Document version detail", body = DocumentVersionDetailResponse)),
tag = "Documents"
)]
pub async fn get_document_version( pub async fn get_document_version(
State(state): State<AppState>, State(state): State<AppState>,
Path((document_id, version_id)): Path<(Uuid, Uuid)>, Path((document_id, version_id)): Path<(Uuid, Uuid)>,
@@ -1141,6 +1219,13 @@ pub async fn get_document_version(
})) }))
} }
#[utoipa::path(
get,
path = "/download/{token}",
params(("token" = String, Path, description = "Download token")),
responses((status = 302, description = "Redirect to pre-signed URL")),
tag = "Documents"
)]
pub async fn download_with_token( pub async fn download_with_token(
State(state): State<AppState>, State(state): State<AppState>,
Path(token): Path<String>, Path(token): Path<String>,
@@ -1193,6 +1278,13 @@ pub async fn download_with_token(
Ok(axum::response::Redirect::temporary(&presigned_url)) Ok(axum::response::Redirect::temporary(&presigned_url))
} }
#[utoipa::path(
delete,
path = "/api/documents/{id}",
params(("id" = Uuid, Path, description = "Document ID")),
responses((status = 204, description = "Document deleted")),
tag = "Documents"
)]
pub async fn delete_document( pub async fn delete_document(
Path(document_id): Path<Uuid>, Path(document_id): Path<Uuid>,
TenantScopedConn { TenantScopedConn {
@@ -1215,6 +1307,14 @@ pub async fn delete_document(
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
#[utoipa::path(
patch,
path = "/api/documents/{id}",
params(("id" = Uuid, Path, description = "Document ID")),
request_body = UpdateDocumentRequest,
responses((status = 200, description = "Updated document", body = DocumentDetailResponse)),
tag = "Documents"
)]
pub async fn update_document( pub async fn update_document(
State(state): State<AppState>, State(state): State<AppState>,
Path(document_id): Path<Uuid>, Path(document_id): Path<Uuid>,
@@ -1387,6 +1487,14 @@ pub async fn update_document(
})) }))
} }
#[utoipa::path(
post,
path = "/api/documents/{id}/restore",
params(("id" = Uuid, Path, description = "Document ID")),
request_body = RestoreDocumentRequest,
responses((status = 204, description = "Document restored")),
tag = "Documents"
)]
pub async fn restore_document( pub async fn restore_document(
Path(document_id): Path<Uuid>, Path(document_id): Path<Uuid>,
TenantScopedConn { TenantScopedConn {
@@ -1437,6 +1545,14 @@ pub async fn restore_document(
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
#[utoipa::path(
patch,
path = "/api/documents/{id}/folder",
params(("id" = Uuid, Path, description = "Document ID")),
request_body = MoveDocumentRequest,
responses((status = 204, description = "Document moved")),
tag = "Documents"
)]
pub async fn move_document( pub async fn move_document(
Path(document_id): Path<Uuid>, Path(document_id): Path<Uuid>,
TenantScopedConn { TenantScopedConn {
@@ -1465,6 +1581,13 @@ pub async fn move_document(
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
#[utoipa::path(
post,
path = "/api/documents/bulk/move",
request_body = BulkMoveRequest,
responses((status = 200, description = "Bulk move outcome", body = BulkMoveResponse)),
tag = "Documents"
)]
pub async fn bulk_move_documents( pub async fn bulk_move_documents(
TenantScopedConn { TenantScopedConn {
mut conn, mut conn,
@@ -1548,6 +1671,14 @@ pub async fn bulk_move_documents(
Ok((StatusCode::OK, body.into_json()?)) Ok((StatusCode::OK, body.into_json()?))
} }
#[utoipa::path(
post,
path = "/api/documents/{id}/correspondents",
params(("id" = Uuid, Path, description = "Document ID")),
request_body = AssignCorrespondentsRequest,
responses((status = 204, description = "Correspondents assigned")),
tag = "Documents"
)]
pub async fn assign_correspondents( pub async fn assign_correspondents(
Path(document_id): Path<Uuid>, Path(document_id): Path<Uuid>,
TenantScopedConn { TenantScopedConn {
@@ -1629,6 +1760,13 @@ pub async fn assign_correspondents(
no_content() no_content()
} }
#[utoipa::path(
post,
path = "/api/documents/bulk/correspondents",
request_body = BulkCorrespondentsRequest,
responses((status = 200, description = "Bulk correspondents outcome", body = BulkCorrespondentResponse)),
tag = "Documents"
)]
pub async fn bulk_assign_correspondents( pub async fn bulk_assign_correspondents(
TenantScopedConn { TenantScopedConn {
mut conn, mut conn,
@@ -1721,6 +1859,16 @@ pub async fn bulk_assign_correspondents(
Ok((StatusCode::OK, body.into_json()?)) Ok((StatusCode::OK, body.into_json()?))
} }
#[utoipa::path(
delete,
path = "/api/documents/{id}/correspondents/{correspondent_id}",
params(
("id" = Uuid, Path, description = "Document ID"),
("correspondent_id" = Uuid, Path, description = "Correspondent ID")
),
responses((status = 204, description = "Correspondent removed")),
tag = "Documents"
)]
pub async fn remove_correspondent( pub async fn remove_correspondent(
Path((document_id, correspondent_id)): Path<(Uuid, Uuid)>, Path((document_id, correspondent_id)): Path<(Uuid, Uuid)>,
TenantScopedConn { TenantScopedConn {
@@ -1760,6 +1908,14 @@ pub async fn remove_correspondent(
no_content() no_content()
} }
#[utoipa::path(
post,
path = "/api/documents/{id}/tags",
params(("id" = Uuid, Path, description = "Document ID")),
request_body = AssignTagsRequest,
responses((status = 204, description = "Tags assigned")),
tag = "Documents"
)]
pub async fn assign_tags( pub async fn assign_tags(
Path(document_id): Path<Uuid>, Path(document_id): Path<Uuid>,
TenantScopedConn { TenantScopedConn {
@@ -1790,6 +1946,13 @@ pub async fn assign_tags(
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
#[utoipa::path(
post,
path = "/api/documents/bulk/tags",
request_body = BulkTagRequest,
responses((status = 200, description = "Bulk tag outcome", body = BulkTagResponse)),
tag = "Documents"
)]
pub async fn bulk_update_tags( pub async fn bulk_update_tags(
TenantScopedConn { TenantScopedConn {
mut conn, mut conn,
@@ -1875,6 +2038,16 @@ pub async fn bulk_update_tags(
Ok((StatusCode::OK, response.into_json()?)) Ok((StatusCode::OK, response.into_json()?))
} }
#[utoipa::path(
delete,
path = "/api/documents/{id}/tags/{tag_id}",
params(
("id" = Uuid, Path, description = "Document ID"),
("tag_id" = Uuid, Path, description = "Tag ID")
),
responses((status = 204, description = "Tag removed")),
tag = "Documents"
)]
pub async fn remove_tag( pub async fn remove_tag(
Path((document_id, tag_id)): Path<(Uuid, Uuid)>, Path((document_id, tag_id)): Path<(Uuid, Uuid)>,
TenantScopedConn { TenantScopedConn {
@@ -2190,7 +2363,7 @@ pub(crate) fn to_document_response(
original_name: doc.original_name, original_name: doc.original_name,
content_type: doc.content_type, content_type: doc.content_type,
folder_id: doc.folder_id, folder_id: doc.folder_id,
uploaded_at: to_iso(doc.uploaded_at), created_at: to_iso(doc.created_at),
updated_at: to_iso(doc.updated_at), updated_at: to_iso(doc.updated_at),
deleted_at: doc.deleted_at.map(to_iso), deleted_at: doc.deleted_at.map(to_iso),
issued_at: doc.issued_at.map(to_iso), issued_at: doc.issued_at.map(to_iso),
@@ -2204,3 +2377,99 @@ pub(crate) fn to_document_response(
current_version, current_version,
}) })
} }
pub(crate) fn hydrate_documents(
state: &AppState,
conn: &mut PgConnection,
tenant_id: Uuid,
user_id: Uuid,
docs: Vec<Document>,
) -> AppResult<Vec<DocumentResponse>> {
if docs.is_empty() {
return Ok(Vec::new());
}
let doc_ids: Vec<Uuid> = docs.iter().map(|doc| doc.id).collect();
let tags_map = load_tags_for_documents(conn, &doc_ids)?;
let mut correspondents_map = load_correspondents_for_documents(conn, &doc_ids)?;
let primary_versions = load_primary_assets(state, tenant_id, &docs)?;
let mut responses = 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();
responses.push(to_document_response(
state,
user_id,
doc,
tags,
correspondents,
current_version,
)?);
}
Ok(responses)
}
#[derive(utoipa::OpenApi)]
#[openapi(
paths(
crate::routes::documents::list_documents,
crate::routes::documents::check_document,
crate::routes::documents::upload_document,
crate::routes::documents::get_document,
crate::routes::documents::update_document,
crate::routes::documents::delete_document,
crate::routes::documents::restore_document,
crate::routes::documents::download_with_token,
crate::routes::documents::move_document,
crate::routes::documents::assign_tags,
crate::routes::documents::remove_tag,
crate::routes::documents::bulk_move_documents,
crate::routes::documents::bulk_update_tags,
crate::routes::documents::bulk_assign_correspondents,
crate::routes::documents::assign_correspondents,
crate::routes::documents::remove_correspondent,
crate::routes::documents::reanalyze_selected_documents,
crate::routes::documents::list_document_assets,
crate::routes::documents::request_document_assets,
crate::routes::documents::get_document_asset,
crate::routes::documents::list_document_versions,
crate::routes::documents::get_document_version,
),
components(schemas(
crate::routes::documents::DocumentListQuery,
crate::routes::documents::DocumentStatusFilter,
crate::routes::documents::AssetRequestQuery,
crate::routes::documents::DocumentCheckQuery,
crate::routes::documents::DocumentCheckResponse,
crate::routes::documents::DocumentResponse,
crate::routes::documents::DocumentDetailResponse,
crate::routes::documents::DocumentMetadataUpdate,
crate::routes::documents::TagResponse,
crate::routes::documents::CorrespondentAssignmentInput,
crate::routes::documents::AssignCorrespondentsRequest,
crate::routes::documents::BulkCorrespondentAction,
crate::routes::documents::BulkCorrespondentsRequest,
crate::routes::documents::BulkCorrespondentResponse,
crate::routes::documents::BulkMoveRequest,
crate::routes::documents::BulkMoveResponse,
crate::routes::documents::BulkTagAction,
crate::routes::documents::BulkTagRequest,
crate::routes::documents::BulkTagResponse,
crate::routes::documents::AssignTagsRequest,
crate::routes::documents::MoveDocumentRequest,
crate::routes::documents::BulkReanalyzeSelectionRequest,
crate::routes::documents::BulkReanalyzeResponse,
crate::routes::documents::AssetObjectsQuery,
crate::routes::documents::UploadDocumentForm,
crate::documents::asset::DocumentVersionResponse,
crate::documents::asset::DocumentVersionDetailResponse,
crate::documents::asset::DocumentAssetResponse,
crate::documents::asset::DocumentAssetDetailResponse,
crate::documents::asset::DocumentAssetObjectResponse,
crate::documents::correspondents::DocumentCorrespondentResponse,
))
)]
pub struct DocumentsApiDoc;
+129 -62
View File
@@ -4,7 +4,7 @@ use axum::{
}; };
use diesel::{dsl::exists, prelude::*, PgConnection}; use diesel::{dsl::exists, prelude::*, PgConnection};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use utoipa::{IntoParams, ToSchema};
use uuid::Uuid; use uuid::Uuid;
use crate::models::{Document, Folder, NewFolder}; use crate::models::{Document, Folder, NewFolder};
@@ -15,43 +15,41 @@ use crate::{
error::{AppError, AppResult}, error::{AppError, AppResult},
}; };
use super::documents::{to_document_response, DocumentResponse}; use super::documents::{hydrate_documents, DocumentResponse};
use crate::documents::{ use crate::utils::{json::deserialize_patch_field, time::to_iso};
asset::load_primary_assets, correspondents::load_correspondents_for_documents,
tags::load_tags_for_documents,
};
use crate::utils::{
json::{classify_nullable, NullableValue},
time::to_iso,
};
#[derive(Deserialize)] #[derive(Deserialize, ToSchema)]
pub struct CreateFolderRequest { pub struct CreateFolderRequest {
pub name: String, pub name: String,
#[schema(nullable)]
pub parent_id: Option<Uuid>, pub parent_id: Option<Uuid>,
} }
#[derive(Deserialize)] #[derive(Deserialize, ToSchema)]
pub struct EnsureFolderPathRequest { pub struct EnsureFolderPathRequest {
#[schema(nullable)]
pub parent_id: Option<Uuid>, pub parent_id: Option<Uuid>,
pub segments: Vec<String>, pub segments: Vec<String>,
} }
#[derive(Serialize)] #[derive(Serialize, ToSchema)]
pub struct FolderResponse { pub struct FolderResponse {
pub folder: FolderInfo, pub folder: FolderInfo,
} }
#[derive(Serialize)] #[derive(Serialize, ToSchema)]
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>,
} }
#[derive(Deserialize)] #[derive(Deserialize, IntoParams, ToSchema)]
#[into_params(parameter_in = Query)]
pub struct FolderContentsQuery { pub struct FolderContentsQuery {
#[serde(default = "default_include_documents")] #[serde(default = "default_include_documents")]
#[schema(default = true)]
pub include_documents: bool, pub include_documents: bool,
} }
@@ -59,15 +57,58 @@ const fn default_include_documents() -> bool {
true true
} }
#[derive(Serialize)] #[derive(Serialize, ToSchema)]
pub struct FolderInfo { pub struct FolderInfo {
pub id: Uuid, pub id: Uuid,
pub name: String, pub name: String,
#[schema(nullable)]
pub parent_id: Option<Uuid>, pub parent_id: Option<Uuid>,
pub created_at: String, pub created_at: String,
pub updated_at: String, pub updated_at: String,
} }
#[derive(Default, Deserialize, ToSchema)]
pub struct UpdateFolderRequest {
#[serde(default, deserialize_with = "deserialize_patch_field")]
#[schema(nullable, value_type = Option<Uuid>)]
pub parent_id: Option<Option<Uuid>>,
#[serde(default, deserialize_with = "deserialize_patch_field")]
#[schema(nullable)]
pub name: Option<Option<String>>,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn update_folder_request_deserializes_null_parent() {
let req: UpdateFolderRequest =
serde_json::from_value(json!({ "parent_id": null })).unwrap();
assert!(matches!(req.parent_id, Some(None)));
}
#[test]
fn update_folder_request_deserializes_absent_parent() {
let req: UpdateFolderRequest = serde_json::from_value(json!({})).unwrap();
assert!(req.parent_id.is_none());
}
#[test]
fn update_folder_request_deserializes_null_name() {
let req: UpdateFolderRequest = serde_json::from_value(json!({ "name": null })).unwrap();
assert!(matches!(req.name, Some(None)));
}
}
#[utoipa::path(
get,
path = "/api/folders/{id}",
params(("id" = Uuid, Path, description = "Folder ID")),
responses((status = 200, description = "Folder detail", body = FolderResponse)),
tag = "Folders"
)]
pub async fn get_folder( pub async fn get_folder(
Path(folder_id): Path<Uuid>, Path(folder_id): Path<Uuid>,
TenantScopedConn { TenantScopedConn {
@@ -86,6 +127,13 @@ pub async fn get_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(
TenantScopedConn { TenantScopedConn {
mut conn, mut conn,
@@ -172,6 +220,16 @@ pub async fn ensure_folder_path(
})) }))
} }
#[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(
TenantScopedConn { TenantScopedConn {
mut conn, mut conn,
@@ -259,6 +317,13 @@ pub async fn create_folder(
} }
} }
#[utoipa::path(
get,
path = "/api/folders/{id}/contents",
params(("id" = Uuid, Path, description = "Folder ID"), 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>,
@@ -308,7 +373,7 @@ pub async fn list_folder_contents(
let docs_query = documents::table let docs_query = documents::table
.filter(documents::deleted_at.is_null()) .filter(documents::deleted_at.is_null())
.filter(documents::tenant_id.eq(tenant_id)) .filter(documents::tenant_id.eq(tenant_id))
.order(documents::uploaded_at.desc()); .order(documents::created_at.desc());
let docs: Vec<Document> = if let Some(current_folder) = folder_id { let docs: Vec<Document> = if let Some(current_folder) = folder_id {
docs_query docs_query
@@ -320,29 +385,7 @@ pub async fn list_folder_contents(
.load(&mut conn)? .load(&mut conn)?
}; };
let doc_ids: Vec<Uuid> = docs.iter().map(|doc| doc.id).collect(); hydrate_documents(&state, &mut conn, tenant_id, user_id, docs)?
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, tenant_id, &docs)?;
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_id,
doc,
tags,
correspondents,
current_version,
)?);
}
documents
} else { } else {
Vec::new() Vec::new()
}; };
@@ -354,6 +397,13 @@ pub async fn list_folder_contents(
})) }))
} }
#[utoipa::path(
delete,
path = "/api/folders/{id}",
params(("id" = Uuid, Path, description = "Folder ID")),
responses((status = 204, description = "Folder deleted")),
tag = "Folders"
)]
pub async fn delete_folder( pub async fn delete_folder(
Path(folder_id): Path<Uuid>, Path(folder_id): Path<Uuid>,
TenantScopedConn { TenantScopedConn {
@@ -408,6 +458,14 @@ pub async fn delete_folder(
Ok(StatusCode::NO_CONTENT) 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(
Path(folder_id): Path<Uuid>, Path(folder_id): Path<Uuid>,
TenantScopedConn { TenantScopedConn {
@@ -415,15 +473,8 @@ pub async fn update_folder(
tenant_id, tenant_id,
.. ..
}: TenantScopedConn, }: TenantScopedConn,
Json(body): Json<Value>, Json(payload): Json<UpdateFolderRequest>,
) -> AppResult<StatusCode> { ) -> AppResult<StatusCode> {
if !body.is_object() {
return Err(AppError::bad_request("request body must be a JSON object"));
}
let parent_class = classify_nullable(body.get("parent_id")).map_err(AppError::bad_request)?;
let name_class = classify_nullable(body.get("name")).map_err(AppError::bad_request)?;
conn.transaction::<(), AppError, _>(|conn| { conn.transaction::<(), AppError, _>(|conn| {
let folder: Folder = folders::table let folder: Folder = folders::table
.find(folder_id) .find(folder_id)
@@ -432,21 +483,15 @@ pub async fn update_folder(
let mut next_parent = folder.parent_id; let mut next_parent = folder.parent_id;
let mut parent_changed = false; let mut parent_changed = false;
match parent_class { match payload.parent_id {
NullableValue::Omitted => {} None => {}
NullableValue::Null => { Some(None) => {
if folder.parent_id.is_some() { if folder.parent_id.is_some() {
parent_changed = true; parent_changed = true;
} }
next_parent = None; next_parent = None;
} }
NullableValue::String(value) => { Some(Some(parent_id)) => {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(AppError::bad_request("parent_id must not be empty"));
}
let parent_id = Uuid::parse_str(trimmed)
.map_err(|_| AppError::bad_request("parent_id must be a valid UUID or null"))?;
if parent_id == folder_id { if parent_id == folder_id {
return Err(AppError::bad_request("folder cannot be its own parent")); return Err(AppError::bad_request("folder cannot be its own parent"));
} }
@@ -472,12 +517,12 @@ pub async fn update_folder(
let mut new_name = folder.name.clone(); let mut new_name = folder.name.clone();
let mut name_changed = false; let mut name_changed = false;
match name_class { match payload.name {
NullableValue::Omitted => {} None => {}
NullableValue::Null => { Some(None) => {
return Err(AppError::bad_request("name cannot be null")); return Err(AppError::bad_request("name cannot be null"));
} }
NullableValue::String(value) => { Some(Some(value)) => {
let trimmed = value.trim(); let trimmed = value.trim();
if trimmed.is_empty() { if trimmed.is_empty() {
return Err(AppError::bad_request("name must not be empty")); return Err(AppError::bad_request("name must not be empty"));
@@ -565,3 +610,25 @@ pub(super) fn gather_descendant_folder_ids(
Ok(ids) Ok(ids)
} }
#[derive(utoipa::OpenApi)]
#[openapi(
paths(
crate::routes::folders::create_folder,
crate::routes::folders::ensure_folder_path,
crate::routes::folders::get_folder,
crate::routes::folders::list_folder_contents,
crate::routes::folders::delete_folder,
crate::routes::folders::update_folder
),
components(schemas(
crate::routes::folders::CreateFolderRequest,
crate::routes::folders::EnsureFolderPathRequest,
crate::routes::folders::FolderResponse,
crate::routes::folders::FolderInfo,
crate::routes::folders::FolderContentsQuery,
crate::routes::folders::FolderContentsResponse,
crate::routes::folders::UpdateFolderRequest
))
)]
pub struct FoldersApiDoc;
+43 -3
View File
@@ -1,6 +1,46 @@
use axum::{http::StatusCode, response::Json}; use axum::{extract::State, http::StatusCode, response::Json};
use diesel::RunQueryDsl;
use serde_json::json; use serde_json::json;
pub async fn health_check() -> (StatusCode, Json<serde_json::Value>) { use crate::state::AppState;
(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))
} }
+48 -11
View File
@@ -2,7 +2,7 @@ use axum::http::HeaderValue;
use axum::{ use axum::{
extract::DefaultBodyLimit, extract::DefaultBodyLimit,
middleware, middleware,
response::Json, response::{Html, Json},
routing::{delete, get, patch, post}, routing::{delete, get, patch, post},
Router, Router,
}; };
@@ -148,6 +148,10 @@ pub fn create_router(state: AppState) -> Router<()> {
"/webdav-tokens", "/webdav-tokens",
get(profile::list_webdav_tokens).post(profile::create_webdav_token), get(profile::list_webdav_tokens).post(profile::create_webdav_token),
) )
.route(
"/webdav-tokens/:id/regenerate",
post(profile::regenerate_webdav_token),
)
.route("/webdav-tokens/:id", delete(profile::delete_webdav_token)) .route("/webdav-tokens/:id", delete(profile::delete_webdav_token))
.route("/passkeys", get(profile::list_passkeys)) .route("/passkeys", get(profile::list_passkeys))
.route("/passkeys/:id", delete(profile::delete_passkey)); .route("/passkeys/:id", delete(profile::delete_passkey));
@@ -164,24 +168,26 @@ pub fn create_router(state: AppState) -> Router<()> {
.nest("/api/assets", assets_routes) .nest("/api/assets", assets_routes)
.layer(middleware::from_extractor_with_state::<AuthenticatedUser, _>(protected_state)); .layer(middleware::from_extractor_with_state::<AuthenticatedUser, _>(protected_state));
let openapi_arc = Arc::new(ApiDoc::openapi()); let upload_limit = state.config.upload_body_limit_bytes;
let docs_route = Router::new().route(
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", "/api/docs/openapi.json",
get({ get({
let spec = openapi_arc.clone(); let spec = openapi_spec.clone();
move || { move || async move { Json((*spec).clone()) }
let spec = spec.clone();
async move { Json((*spec).clone()) }
}
}), }),
); );
let upload_limit = state.config.upload_body_limit_bytes;
Router::new() Router::new()
.merge(download_routes) .merge(download_routes)
.merge(protected_routes) .merge(protected_routes)
.merge(docs_route) .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)
@@ -196,3 +202,34 @@ pub fn create_router(state: AppState) -> Router<()> {
.on_failure(DefaultOnFailure::new().level(tracing::Level::ERROR)), .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>"#
)
}
+94 -5
View File
@@ -5,13 +5,14 @@ use axum::{
}; };
use chrono::{DateTime, NaiveDateTime}; use chrono::{DateTime, NaiveDateTime};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid; use uuid::Uuid;
use crate::auth::{ use crate::auth::{
passkeys::PasskeySummary, passkeys::PasskeySummary,
webdav_tokens::{ webdav_tokens::{
create_webdav_token as issue_token, list_webdav_tokens as load_tokens, create_webdav_token as issue_token, list_webdav_tokens as load_tokens,
revoke_webdav_token as revoke_token, regenerate_webdav_token as rotate_token, revoke_webdav_token as revoke_token,
}, },
TenantScopedConn, TenantScopedConn,
}; };
@@ -20,35 +21,48 @@ use crate::models::WebdavToken;
use crate::state::AppState; use crate::state::AppState;
use crate::utils::{db::no_content, time::to_iso}; use crate::utils::{db::no_content, time::to_iso};
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, ToSchema)]
pub struct WebdavTokenResponse { pub struct WebdavTokenResponse {
pub id: Uuid, pub id: Uuid,
pub tenant_id: Uuid, pub tenant_id: Uuid,
#[schema(nullable)]
pub label: Option<String>, pub label: Option<String>,
pub created_at: String, pub created_at: String,
#[schema(nullable)]
pub last_used_at: Option<String>, pub last_used_at: Option<String>,
#[schema(nullable)]
pub expires_at: Option<String>, pub expires_at: Option<String>,
#[schema(nullable)]
pub revoked_at: Option<String>, pub revoked_at: Option<String>,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, ToSchema)]
pub struct WebdavTokenCreatedResponse { pub struct WebdavTokenCreatedResponse {
pub token: String, pub token: String,
pub token_info: WebdavTokenResponse, pub token_info: WebdavTokenResponse,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, ToSchema)]
pub struct CreateWebdavTokenRequest { pub struct CreateWebdavTokenRequest {
#[schema(nullable)]
pub label: Option<String>, pub label: Option<String>,
#[schema(nullable)]
pub expires_at: Option<String>, pub expires_at: Option<String>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, ToSchema)]
pub struct RevokePasskeyQuery { pub struct RevokePasskeyQuery {
#[serde(default)] #[serde(default)]
#[schema(nullable)]
pub reason: Option<String>, pub reason: Option<String>,
} }
#[utoipa::path(
get,
path = "/api/profile/passkeys",
responses((status = 200, description = "List registered passkeys", body = [PasskeySummary])),
tag = "Profile"
)]
pub async fn list_passkeys( pub async fn list_passkeys(
State(state): State<AppState>, State(state): State<AppState>,
TenantScopedConn { TenantScopedConn {
@@ -64,6 +78,12 @@ pub async fn list_passkeys(
Ok(Json(passkeys)) Ok(Json(passkeys))
} }
#[utoipa::path(
get,
path = "/api/profile/webdav-tokens",
responses((status = 200, description = "List WebDAV tokens", body = [WebdavTokenResponse])),
tag = "Profile"
)]
pub async fn list_webdav_tokens( pub async fn list_webdav_tokens(
TenantScopedConn { TenantScopedConn {
mut conn, mut conn,
@@ -77,6 +97,13 @@ pub async fn list_webdav_tokens(
Ok(Json(responses)) Ok(Json(responses))
} }
#[utoipa::path(
post,
path = "/api/profile/webdav-tokens",
request_body = CreateWebdavTokenRequest,
responses((status = 201, description = "WebDAV token created", body = WebdavTokenCreatedResponse)),
tag = "Profile"
)]
pub async fn create_webdav_token( pub async fn create_webdav_token(
TenantScopedConn { TenantScopedConn {
mut conn, mut conn,
@@ -107,6 +134,38 @@ pub async fn create_webdav_token(
Ok((StatusCode::CREATED, Json(response))) Ok((StatusCode::CREATED, Json(response)))
} }
#[utoipa::path(
post,
path = "/api/profile/webdav-tokens/{id}/regenerate",
params(("id" = Uuid, Path, description = "WebDAV token ID")),
responses((status = 200, description = "WebDAV token regenerated", body = WebdavTokenCreatedResponse)),
tag = "Profile"
)]
pub async fn regenerate_webdav_token(
TenantScopedConn {
mut conn,
tenant_id,
user_id,
..
}: TenantScopedConn,
Path(token_id): Path<Uuid>,
) -> AppResult<Json<WebdavTokenCreatedResponse>> {
let issued = rotate_token(&mut conn, token_id, user_id, Some(tenant_id))?;
let response = WebdavTokenCreatedResponse {
token: issued.token,
token_info: webdav_token_to_response(issued.record),
};
Ok(Json(response))
}
#[utoipa::path(
delete,
path = "/api/profile/webdav-tokens/{id}",
params(("id" = Uuid, Path, description = "WebDAV token ID")),
responses((status = 204, description = "WebDAV token revoked")),
tag = "Profile"
)]
pub async fn delete_webdav_token( pub async fn delete_webdav_token(
TenantScopedConn { TenantScopedConn {
mut conn, user_id, .. mut conn, user_id, ..
@@ -117,6 +176,16 @@ pub async fn delete_webdav_token(
no_content() no_content()
} }
#[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( pub async fn delete_passkey(
State(state): State<AppState>, State(state): State<AppState>,
TenantScopedConn { TenantScopedConn {
@@ -158,3 +227,23 @@ fn parse_timestamp(value: &str) -> AppResult<NaiveDateTime> {
.map_err(|_| AppError::bad_request("invalid expires_at timestamp"))?; .map_err(|_| AppError::bad_request("invalid expires_at timestamp"))?;
Ok(dt.naive_utc()) Ok(dt.naive_utc())
} }
#[derive(utoipa::OpenApi)]
#[openapi(
paths(
crate::routes::profile::list_webdav_tokens,
crate::routes::profile::create_webdav_token,
crate::routes::profile::regenerate_webdav_token,
crate::routes::profile::delete_webdav_token,
crate::routes::profile::list_passkeys,
crate::routes::profile::delete_passkey
),
components(schemas(
crate::routes::profile::WebdavTokenResponse,
crate::routes::profile::WebdavTokenCreatedResponse,
crate::routes::profile::CreateWebdavTokenRequest,
crate::routes::profile::RevokePasskeyQuery,
crate::auth::passkeys::PasskeySummary
))
)]
pub struct ProfileApiDoc;
+115 -35
View File
@@ -1,20 +1,24 @@
use crate::utils::json::{classify_nullable, NullableValue};
use axum::{extract::Path, http::StatusCode, Json}; use axum::{extract::Path, 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::auth::TenantScopedConn; use crate::auth::TenantScopedConn;
use crate::error::{AppError, AppResult}; use crate::error::{AppError, AppResult};
use crate::models::{NewTag, Tag}; use crate::models::{NewTag, Tag};
use crate::schema::{document_tags, tags}; use crate::schema::{document_tags, tags};
use crate::utils::db::{no_content, EnsureEntity, IntoJsonResponse}; use crate::utils::{
db::{no_content, EnsureEntity, IntoJsonResponse},
json::deserialize_patch_field,
named_entity::{ensure_name_available, normalize_name},
};
#[derive(Deserialize)] #[derive(Deserialize, ToSchema)]
pub struct CreateTagRequest { pub struct CreateTagRequest {
pub label: String, pub label: String,
#[schema(nullable)]
pub color: Option<String>, pub color: Option<String>,
} }
@@ -25,14 +29,55 @@ struct UpdateTagChangeset<'a> {
color: Option<Option<&'a str>>, color: Option<Option<&'a str>>,
} }
#[derive(Serialize)] #[cfg(test)]
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 struct UpdateTagRequest {
#[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(
get,
path = "/api/tags",
responses((status = 200, description = "Tags", body = [TagCatalogEntry])),
tag = "Tags"
)]
pub async fn list_tags( pub async fn list_tags(
TenantScopedConn { TenantScopedConn {
mut conn, mut conn,
@@ -66,6 +111,13 @@ pub async fn list_tags(
response.into_json() response.into_json()
} }
#[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 { TenantScopedConn {
mut conn, mut conn,
@@ -74,13 +126,13 @@ pub async fn create_tag(
}: TenantScopedConn, }: TenantScopedConn,
Json(payload): Json<CreateTagRequest>, Json(payload): Json<CreateTagRequest>,
) -> AppResult<Json<TagCatalogEntry>> { ) -> AppResult<Json<TagCatalogEntry>> {
if payload.label.trim().is_empty() { let label = normalize_name(&payload.label, || {
return Err(AppError::bad_request("label must not be empty")); AppError::bad_request("label must not be empty")
} })?;
let new_tag = NewTag { let new_tag = NewTag {
id: Uuid::new_v4(), id: Uuid::new_v4(),
label: payload.label.trim().to_string(), label: label.clone(),
color: payload.color, color: payload.color,
tenant_id, tenant_id,
}; };
@@ -114,6 +166,14 @@ pub async fn create_tag(
.into_json() .into_json()
} }
#[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(
Path(tag_id): Path<Uuid>, Path(tag_id): Path<Uuid>,
TenantScopedConn { TenantScopedConn {
@@ -121,19 +181,16 @@ pub async fn update_tag(
tenant_id, tenant_id,
.. ..
}: TenantScopedConn, }: TenantScopedConn,
Json(body): Json<Value>, Json(payload): Json<UpdateTagRequest>,
) -> AppResult<Json<TagCatalogEntry>> { ) -> AppResult<Json<TagCatalogEntry>> {
let existing: Tag = tags::table let existing: Tag = tags::table
.find(tag_id) .find(tag_id)
.filter(tags::tenant_id.eq(tenant_id)) .filter(tags::tenant_id.eq(tenant_id))
.first(&mut conn) .first(&mut conn)
.one()?; .one()?;
let label_class = classify_nullable(body.get("label")).map_err(AppError::bad_request)?; let UpdateTagRequest { label, color } = payload;
let color_class = classify_nullable(body.get("color")).map_err(AppError::bad_request)?;
if matches!(label_class, NullableValue::Omitted) if label.is_none() && color.is_none() {
&& 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())
@@ -149,27 +206,27 @@ pub async fn update_tag(
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_class { match label {
NullableValue::Omitted => {} None => {}
NullableValue::Null => { Some(None) => {
return Err(AppError::bad_request("label cannot be null")); return Err(AppError::bad_request("label cannot be null"));
} }
NullableValue::String(value) => { Some(Some(value)) => {
let trimmed = value.trim(); let normalized =
if trimmed.is_empty() { normalize_name(&value, || AppError::bad_request("label must not be empty"))?;
return Err(AppError::bad_request("label must not be empty")); if normalized != existing.label {
} ensure_name_available(
if trimmed != existing.label { || {
let duplicate = tags::table tags::table
.filter(tags::label.eq(trimmed)) .filter(tags::label.eq(&normalized))
.filter(tags::id.ne(tag_id)) .filter(tags::id.ne(tag_id))
.filter(tags::tenant_id.eq(tenant_id)) .filter(tags::tenant_id.eq(tenant_id))
.first::<Tag>(&mut conn) .first::<Tag>(&mut conn)
.optional()?; .optional()
if duplicate.is_some() { },
return Err(AppError::bad_request("tag label already exists")); || AppError::bad_request("tag label already exists"),
} )?;
new_label = Some(trimmed.to_string()); new_label = Some(normalized);
label_changed = true; label_changed = true;
} }
} }
@@ -177,13 +234,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_class { match color {
NullableValue::Omitted => {} None => {}
NullableValue::Null => { Some(None) => {
color_change = Some(None); color_change = Some(None);
color_changed = true; color_changed = true;
} }
NullableValue::String(value) => { Some(Some(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"));
@@ -244,6 +301,13 @@ pub async fn update_tag(
.into_json() .into_json()
} }
#[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(
Path(tag_id): Path<Uuid>, Path(tag_id): Path<Uuid>,
TenantScopedConn { TenantScopedConn {
@@ -276,3 +340,19 @@ pub async fn delete_tag(
no_content() no_content()
} }
#[derive(utoipa::OpenApi)]
#[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;
+13 -9
View File
@@ -21,10 +21,10 @@ use crate::error::{AppError, AppResult};
use crate::models::{Document, DocumentVersion, Folder, User}; use crate::models::{Document, DocumentVersion, Folder, User};
use crate::schema::{ use crate::schema::{
document_versions::dsl as document_versions_dsl, documents::dsl as documents_dsl, document_versions::dsl as document_versions_dsl, documents::dsl as documents_dsl,
folders::dsl as folders_dsl, tenants::dsl as tenant_dsl, folders::dsl as folders_dsl, user_memberships::dsl as memberships_dsl, users::dsl as users_dsl,
user_memberships::dsl as memberships_dsl, users::dsl as users_dsl,
}; };
use crate::state::AppState; use crate::state::AppState;
use crate::tenants::{apply_tenant_guc, apply_user_guc, clear_user_guc};
use crate::utils::{error::StorageResultExt, http::inline_content_disposition, time::to_http_date}; use crate::utils::{error::StorageResultExt, http::inline_content_disposition, time::to_http_date};
const REALM: &str = "Papercrate WebDAV"; const REALM: &str = "Papercrate WebDAV";
@@ -273,7 +273,7 @@ fn fetch_folder_contents(
}; };
let documents: Vec<Document> = docs_query let documents: Vec<Document> = docs_query
.order(documents_dsl::uploaded_at.desc()) .order(documents_dsl::created_at.desc())
.load(&mut conn)?; .load(&mut conn)?;
let version_ids: Vec<Uuid> = documents.iter().map(|doc| doc.current_version_id).collect(); let version_ids: Vec<Uuid> = documents.iter().map(|doc| doc.current_version_id).collect();
@@ -453,16 +453,19 @@ fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavCo
} }
}; };
let tenant_row = memberships_dsl::user_memberships apply_user_guc(&mut conn, user.id)?;
.inner_join(tenant_dsl::tenants)
let membership_exists = memberships_dsl::user_memberships
.filter(memberships_dsl::user_id.eq(user.id)) .filter(memberships_dsl::user_id.eq(user.id))
.filter(memberships_dsl::tenant_id.eq(token.tenant_id)) .filter(memberships_dsl::tenant_id.eq(token.tenant_id))
.select((tenant_dsl::id, tenant_dsl::name)) .select(memberships_dsl::tenant_id)
.first::<(Uuid, String)>(&mut conn) .first::<Uuid>(&mut conn)
.optional()?; .optional()?;
let (tenant_id, _name) = match tenant_row { clear_user_guc(&mut conn)?;
Some(row) => row,
let tenant_id = match membership_exists {
Some(id) => id,
None => { None => {
tracing::warn!( tracing::warn!(
%username, %username,
@@ -473,6 +476,7 @@ fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavCo
} }
}; };
apply_tenant_guc(&mut conn, tenant_id)?;
touch_webdav_token(&mut conn, token.id)?; touch_webdav_token(&mut conn, token.id)?;
tracing::debug!( tracing::debug!(
+25 -1
View File
@@ -1,6 +1,10 @@
// @generated automatically by Diesel CLI. // @generated automatically by Diesel CLI.
pub mod sql_types { pub mod sql_types {
#[derive(diesel::query_builder::QueryId, Clone, diesel::sql_types::SqlType)]
#[diesel(postgres_type(name = "magic_token_kind"))]
pub struct MagicTokenKind;
#[derive(diesel::query_builder::QueryId, Clone, diesel::sql_types::SqlType)] #[derive(diesel::query_builder::QueryId, Clone, diesel::sql_types::SqlType)]
#[diesel(postgres_type(name = "tenant_status"))] #[diesel(postgres_type(name = "tenant_status"))]
pub struct TenantStatus; pub struct TenantStatus;
@@ -88,7 +92,7 @@ diesel::table! {
#[max_length = 100] #[max_length = 100]
content_type -> Nullable<Varchar>, content_type -> Nullable<Varchar>,
folder_id -> Nullable<Uuid>, folder_id -> Nullable<Uuid>,
uploaded_at -> Timestamptz, created_at -> Timestamptz,
updated_at -> Timestamptz, updated_at -> Timestamptz,
deleted_at -> Nullable<Timestamptz>, deleted_at -> Nullable<Timestamptz>,
metadata -> Jsonb, metadata -> Jsonb,
@@ -127,6 +131,25 @@ diesel::table! {
} }
} }
diesel::table! {
use diesel::sql_types::*;
use super::sql_types::MagicTokenKind;
magic_tokens (id) {
id -> Uuid,
user_id -> Uuid,
kind -> MagicTokenKind,
token_hash -> Varchar,
metadata -> Jsonb,
expires_at -> Timestamptz,
max_uses -> Nullable<Int4>,
used_count -> Int4,
created_at -> Timestamptz,
created_by -> Nullable<Uuid>,
last_used_at -> Nullable<Timestamptz>,
}
}
diesel::table! { diesel::table! {
refresh_tokens (id) { refresh_tokens (id) {
id -> Uuid, id -> Uuid,
@@ -275,6 +298,7 @@ diesel::allow_tables_to_appear_in_same_query!(
documents, documents,
folders, folders,
jobs, jobs,
magic_tokens,
refresh_tokens, refresh_tokens,
tags, tags,
tenants, tenants,
+6 -3
View File
@@ -12,7 +12,7 @@ use crate::{
db::PgPool, db::PgPool,
error::{AppError, AppResult}, error::{AppError, AppResult},
storage::{ObjectStorage, TenantStorage}, storage::{ObjectStorage, TenantStorage},
tenants::{apply_tenant_guc, TenantService}, tenants::{apply_tenant_guc, clear_tenant_context, clear_user_guc, TenantService},
}; };
pub type PgPooledConnection = PooledConnection<ConnectionManager<PgConnection>>; pub type PgPooledConnection = PooledConnection<ConnectionManager<PgConnection>>;
@@ -75,14 +75,17 @@ impl AppState {
debug_assert!(!tenant_id.is_nil(), "nil tenant_id passed to db_for_tenant"); debug_assert!(!tenant_id.is_nil(), "nil tenant_id passed to db_for_tenant");
let mut conn = self.db_unscoped()?; let mut conn = self.db_unscoped()?;
apply_tenant_guc(&mut conn, tenant_id)?; apply_tenant_guc(&mut conn, tenant_id)?;
clear_user_guc(&mut conn)?;
Ok(conn) Ok(conn)
} }
pub(crate) fn db_unscoped(&self) -> AppResult<PgPooledConnection> { pub(crate) fn db_unscoped(&self) -> AppResult<PgPooledConnection> {
self.pool.get().map_err(|err| { let mut conn = self.pool.get().map_err(|err| {
tracing::error!(error = ?err, "database pool error"); tracing::error!(error = ?err, "database pool error");
AppError::internal("database pool error") AppError::internal("database pool error")
}) })?;
clear_tenant_context(&mut conn)?;
Ok(conn)
} }
pub fn storage_for_tenant(&self, tenant_id: Uuid) -> AppResult<TenantStorage> { pub fn storage_for_tenant(&self, tenant_id: Uuid) -> AppResult<TenantStorage> {
+59 -1
View File
@@ -128,13 +128,71 @@ impl TenantService {
} }
pub fn apply_tenant_guc(conn: &mut PgConnection, tenant_id: Uuid) -> AppResult<()> { pub fn apply_tenant_guc(conn: &mut PgConnection, tenant_id: Uuid) -> AppResult<()> {
diesel::sql_query("SELECT set_config('papercrate.tenant_id', $1, true)") diesel::sql_query("SELECT set_config('papercrate.tenant_id', $1, false)")
.bind::<Text, _>(tenant_id.to_string()) .bind::<Text, _>(tenant_id.to_string())
.execute(conn) .execute(conn)
.map(|_| ()) .map(|_| ())
.map_err(AppError::from) .map_err(AppError::from)
} }
pub fn apply_user_guc(conn: &mut PgConnection, user_id: Uuid) -> AppResult<()> {
diesel::sql_query("SELECT set_config('papercrate.user_id', $1, false)")
.bind::<Text, _>(user_id.to_string())
.execute(conn)
.map(|_| ())
.map_err(AppError::from)
}
pub fn clear_tenant_context(conn: &mut PgConnection) -> AppResult<()> {
diesel::sql_query(
"SELECT \
set_config('papercrate.tenant_id', '', false), \
set_config('papercrate.user_id', '', false), \
set_config('papercrate.refresh_token_hash', '', false), \
set_config('papercrate.webdav_token_prefix', '', false)",
)
.execute(conn)
.map(|_| ())
.map_err(AppError::from)
}
pub fn clear_user_guc(conn: &mut PgConnection) -> AppResult<()> {
diesel::sql_query("SELECT set_config('papercrate.user_id', '', false)")
.execute(conn)
.map(|_| ())
.map_err(AppError::from)
}
pub fn apply_refresh_token_hash(conn: &mut PgConnection, hash: &str) -> AppResult<()> {
diesel::sql_query("SELECT set_config('papercrate.refresh_token_hash', $1, false)")
.bind::<Text, _>(hash)
.execute(conn)
.map(|_| ())
.map_err(AppError::from)
}
pub fn clear_refresh_token_hash(conn: &mut PgConnection) -> AppResult<()> {
diesel::sql_query("SELECT set_config('papercrate.refresh_token_hash', '', false)")
.execute(conn)
.map(|_| ())
.map_err(AppError::from)
}
pub fn apply_webdav_token_prefix(conn: &mut PgConnection, prefix: &str) -> AppResult<()> {
diesel::sql_query("SELECT set_config('papercrate.webdav_token_prefix', $1, false)")
.bind::<Text, _>(prefix)
.execute(conn)
.map(|_| ())
.map_err(AppError::from)
}
pub fn clear_webdav_token_prefix(conn: &mut PgConnection) -> AppResult<()> {
diesel::sql_query("SELECT set_config('papercrate.webdav_token_prefix', '', false)")
.execute(conn)
.map(|_| ())
.map_err(AppError::from)
}
fn normalize_storage_root(raw: Option<&str>, tenant_id: Uuid) -> String { fn normalize_storage_root(raw: Option<&str>, tenant_id: Uuid) -> String {
match raw.map(str::trim) { match raw.map(str::trim) {
Some(root) if !root.is_empty() => { Some(root) if !root.is_empty() => {
+9
View File
@@ -1,3 +1,4 @@
use serde::Deserialize;
use serde_json::Value; use serde_json::Value;
pub enum NullableValue { pub enum NullableValue {
@@ -14,3 +15,11 @@ pub fn classify_nullable(optional_value: Option<&Value>) -> Result<NullableValue
Some(other) => Err(format!("expected string or null, got {other}")), Some(other) => Err(format!("expected string or null, got {other}")),
} }
} }
pub fn deserialize_patch_field<'de, D, T>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
where
D: serde::Deserializer<'de>,
T: Deserialize<'de>,
{
Option::<T>::deserialize(deserializer).map(Some)
}
+1
View File
@@ -3,6 +3,7 @@ pub mod db;
pub mod error; pub mod error;
pub mod http; pub mod http;
pub mod json; pub mod json;
pub mod named_entity;
pub mod storage_paths; pub mod storage_paths;
pub mod time; pub mod time;
pub mod tracing; pub mod tracing;
+28
View File
@@ -0,0 +1,28 @@
use diesel::QueryResult;
use crate::error::{AppError, AppResult};
/// Trim and validate a user-supplied entity name, returning an owned String.
///
/// The `on_empty` closure is only invoked when the trimmed name is empty, giving
/// callers control over the concrete error that should be surfaced.
pub fn normalize_name(raw: &str, on_empty: impl Fn() -> AppError) -> AppResult<String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(on_empty());
}
Ok(trimmed.to_string())
}
/// Ensure that no conflicting entity exists by executing the provided query
/// closure. If a record is returned, the `on_duplicate` closure is evaluated to
/// produce the appropriate error.
pub fn ensure_name_available<T>(
query: impl FnOnce() -> QueryResult<Option<T>>,
on_duplicate: impl Fn() -> AppError,
) -> AppResult<()> {
if query()?.is_some() {
return Err(on_duplicate());
}
Ok(())
}
+12 -2
View File
@@ -91,7 +91,12 @@ fn analyze_document(
let version: DocumentVersion = document_versions::table let version: DocumentVersion = document_versions::table
.find(payload.document_version_id) .find(payload.document_version_id)
.first(&mut conn) .first(&mut conn)
.map_err(|err| format!("{err:?}"))?; .map_err(|err| {
format!(
"failed to load document_version {} for tenant {}: {err:?}",
payload.document_version_id, tenant_id
)
})?;
if version.document_id != payload.document_id { if version.document_id != payload.document_id {
return Err("document/version mismatch".into()); return Err("document/version mismatch".into());
@@ -100,7 +105,12 @@ fn analyze_document(
let document: Document = documents::table let document: Document = documents::table
.find(payload.document_id) .find(payload.document_id)
.first(&mut conn) .first(&mut conn)
.map_err(|err| format!("{err:?}"))?; .map_err(|err| {
format!(
"failed to load document {} for tenant {}: {err:?}",
payload.document_id, tenant_id
)
})?;
let tenant_id = document.tenant_id; let tenant_id = document.tenant_id;
+96
View File
@@ -0,0 +1,96 @@
use std::collections::HashMap;
use diesel::{prelude::*, PgConnection};
use uuid::Uuid;
use crate::models::{Document, DocumentAsset, DocumentAssetObject, DocumentVersion};
use crate::schema::{document_asset_objects, document_assets, document_versions, documents};
use crate::state::AppState;
pub(crate) struct DocumentVersionContext {
pub document: Document,
pub version: DocumentVersion,
pub tenant_id: Uuid,
}
pub(crate) fn load_document_version(
state: &AppState,
tenant_id: Uuid,
document_id: Uuid,
version_id: Uuid,
) -> Result<DocumentVersionContext, String> {
let mut conn = state
.db_for_tenant(tenant_id)
.map_err(|err| format!("{err:?}"))?;
let version: DocumentVersion = document_versions::table
.find(version_id)
.first(&mut conn)
.map_err(|err| format!("{err:?}"))?;
if version.document_id != document_id {
return Err("document/version mismatch".into());
}
let document: Document = documents::table
.find(document_id)
.first(&mut conn)
.map_err(|err| format!("{err:?}"))?;
Ok(DocumentVersionContext {
document,
version,
tenant_id,
})
}
pub(crate) struct LoadedAsset {
pub asset: DocumentAsset,
pub objects: Vec<DocumentAssetObject>,
}
pub(crate) fn load_version_assets(
conn: &mut PgConnection,
tenant_id: Uuid,
version_id: Uuid,
asset_types: &[&str],
) -> Result<HashMap<String, LoadedAsset>, String> {
let mut query = document_assets::table
.filter(document_assets::document_version_id.eq(version_id))
.filter(document_assets::tenant_id.eq(tenant_id))
.into_boxed();
if !asset_types.is_empty() {
let types: Vec<String> = asset_types.iter().map(|ty| (*ty).to_string()).collect();
query = query.filter(document_assets::asset_type.eq_any(types));
}
let assets: Vec<DocumentAsset> = query
.order(document_assets::created_at.asc())
.load(conn)
.map_err(|err| format!("{err:?}"))?;
let asset_ids: Vec<Uuid> = assets.iter().map(|asset| asset.id).collect();
let mut object_map: HashMap<Uuid, Vec<DocumentAssetObject>> = HashMap::new();
if !asset_ids.is_empty() {
let objects: Vec<DocumentAssetObject> = document_asset_objects::table
.filter(document_asset_objects::asset_id.eq_any(&asset_ids))
.filter(document_asset_objects::tenant_id.eq(tenant_id))
.order(document_asset_objects::ordinal.asc())
.load(conn)
.map_err(|err| format!("{err:?}"))?;
for object in objects {
object_map.entry(object.asset_id).or_default().push(object);
}
}
let mut result = HashMap::with_capacity(assets.len());
for asset in assets {
let objects = object_map.remove(&asset.id).unwrap_or_default();
result.insert(asset.asset_type.clone(), LoadedAsset { asset, objects });
}
Ok(result)
}
+13 -12
View File
@@ -89,7 +89,9 @@ impl JobHandler for IndexDocumentTextJob {
let client = Client::new(); let client = Client::new();
let state_clone = state.clone(); let state_clone = state.clone();
let context = match task::spawn_blocking(move || load_context(state_clone, &payload)).await let tenant_id = job.tenant_id;
let context =
match task::spawn_blocking(move || load_context(state_clone, tenant_id, payload)).await
{ {
Ok(Ok(ctx)) => ctx, Ok(Ok(ctx)) => ctx,
Ok(Err(err)) => { Ok(Err(err)) => {
@@ -166,12 +168,18 @@ struct IndexContext {
text_s3_key: Option<String>, text_s3_key: Option<String>,
} }
fn load_context(state: Arc<AppState>, payload: &IndexPayload) -> Result<IndexContext, String> { fn load_context(
let mut base_conn = state.db_unscoped().map_err(|err| format!("{err:?}"))?; state: Arc<AppState>,
tenant_id: Uuid,
payload: IndexPayload,
) -> Result<IndexContext, String> {
let mut conn = state
.db_for_tenant(tenant_id)
.map_err(|err| format!("{err:?}"))?;
let version: DocumentVersion = document_versions::table let version: DocumentVersion = document_versions::table
.find(payload.document_version_id) .find(payload.document_version_id)
.first(&mut base_conn) .first(&mut conn)
.map_err(|err| format!("{err:?}"))?; .map_err(|err| format!("{err:?}"))?;
if version.document_id != payload.document_id { if version.document_id != payload.document_id {
@@ -180,14 +188,7 @@ fn load_context(state: Arc<AppState>, payload: &IndexPayload) -> Result<IndexCon
let document: Document = documents::table let document: Document = documents::table
.find(payload.document_id) .find(payload.document_id)
.first(&mut base_conn) .first(&mut conn)
.map_err(|err| format!("{err:?}"))?;
let tenant_id = document.tenant_id;
drop(base_conn);
let mut conn = state
.db_for_tenant(tenant_id)
.map_err(|err| format!("{err:?}"))?; .map_err(|err| format!("{err:?}"))?;
let text_s3_key: Option<String> = document_asset_objects::table let text_s3_key: Option<String> = document_asset_objects::table
+1
View File
@@ -13,6 +13,7 @@ use crate::{
}; };
pub mod analyze; pub mod analyze;
pub mod common;
pub mod index; pub mod index;
pub mod ocr; pub mod ocr;
pub mod tenants; pub mod tenants;
+44 -57
View File
@@ -24,13 +24,16 @@ use crate::{
Document, DocumentAsset, DocumentAssetObject, DocumentVersion, NewDocumentAsset, Document, DocumentAsset, DocumentAssetObject, DocumentVersion, NewDocumentAsset,
NewDocumentAssetObject, NewDocumentAssetObject,
}, },
schema::{document_asset_objects, document_assets, document_versions, documents}, schema::{document_asset_objects, document_assets},
state::AppState, state::AppState,
storage::TenantStorage, storage::TenantStorage,
utils::storage_paths::document_asset_object_prefix, utils::storage_paths::document_asset_object_prefix,
}; };
use super::{fetch_version_object, handle_fetch_error, JobExecution, JobHandler}; use super::{
common::{load_document_version, load_version_assets},
fetch_version_object, handle_fetch_error, JobExecution, JobHandler,
};
pub const OCR_TEXT_ASSET_TYPE: &str = "ocr-text"; pub const OCR_TEXT_ASSET_TYPE: &str = "ocr-text";
const MIN_TEXT_LENGTH: usize = 50; const MIN_TEXT_LENGTH: usize = 50;
@@ -74,8 +77,11 @@ impl JobHandler for GenerateOcrTextJob {
let state_clone = state.clone(); let state_clone = state.clone();
let payload_clone = payload.clone(); let payload_clone = payload.clone();
let context = let tenant_id = job.tenant_id;
match task::spawn_blocking(move || load_ocr_context(state_clone, &payload_clone)).await let context = match task::spawn_blocking(move || {
load_ocr_context(state_clone, tenant_id, payload_clone)
})
.await
{ {
Ok(Ok(ctx)) => ctx, Ok(Ok(ctx)) => ctx,
Ok(Err(err)) => { Ok(Err(err)) => {
@@ -192,7 +198,7 @@ impl JobHandler for GenerateOcrTextJob {
.await .await
{ {
Ok(Ok(())) => { Ok(Ok(())) => {
if let Err(err) = enqueue_index_job(&state, &payload) { if let Err(err) = enqueue_index_job(&state, job.tenant_id, &payload) {
warn!(job_id = %job.id, error = %err, "failed to enqueue index job"); warn!(job_id = %job.id, error = %err, "failed to enqueue index job");
} }
JobExecution::Success JobExecution::Success
@@ -233,55 +239,40 @@ struct OcrGeneration {
source: &'static str, source: &'static str,
} }
fn load_ocr_context(state: Arc<AppState>, payload: &OcrPayload) -> Result<OcrContext, String> { fn load_ocr_context(
let mut base_conn = state.db_unscoped().map_err(|err| format!("{err:?}"))?; state: Arc<AppState>,
tenant_id: Uuid,
let version: DocumentVersion = document_versions::table payload: OcrPayload,
.find(payload.document_version_id) ) -> Result<OcrContext, String> {
.first(&mut base_conn) let base = load_document_version(
.map_err(|err| format!("{err:?}"))?; state.as_ref(),
tenant_id,
if version.document_id != payload.document_id { payload.document_id,
return Err("document/version mismatch".into()); payload.document_version_id,
} )?;
let document: Document = documents::table
.find(payload.document_id)
.first(&mut base_conn)
.map_err(|err| format!("{err:?}"))?;
let tenant_id = document.tenant_id;
drop(base_conn);
let mut conn = state let mut conn = state
.db_for_tenant(tenant_id) .db_for_tenant(base.tenant_id)
.map_err(|err| format!("{err:?}"))?; .map_err(|err| format!("{err:?}"))?;
let existing_asset: Option<DocumentAsset> = document_assets::table let mut assets = load_version_assets(
.filter(document_assets::document_version_id.eq(payload.document_version_id)) &mut conn,
.filter(document_assets::asset_type.eq(OCR_TEXT_ASSET_TYPE)) base.tenant_id,
.filter(document_assets::tenant_id.eq(tenant_id)) base.version.id,
.first(&mut conn) &[OCR_TEXT_ASSET_TYPE],
.optional() )?;
.map_err(|err| format!("{err:?}"))?;
let existing_objects: Vec<DocumentAssetObject> = if let Some(asset) = &existing_asset { let (existing_asset, existing_objects) = assets
document_asset_objects::table .remove(OCR_TEXT_ASSET_TYPE)
.filter(document_asset_objects::asset_id.eq(asset.id)) .map(|entry| (Some(entry.asset), entry.objects))
.filter(document_asset_objects::tenant_id.eq(tenant_id)) .unwrap_or((None, Vec::new()));
.order(document_asset_objects::ordinal.asc())
.load(&mut conn)
.map_err(|err| format!("{err:?}"))?
} else {
Vec::new()
};
let is_pdf = document_is_pdf(&document); let is_pdf = document_is_pdf(&base.document);
if !is_pdf { if !is_pdf {
return Ok(OcrContext { return Ok(OcrContext {
document, document: base.document,
version, version: base.version,
existing_asset: existing_asset, existing_asset,
existing_objects, existing_objects,
skip: true, skip: true,
}); });
@@ -290,8 +281,8 @@ fn load_ocr_context(state: Arc<AppState>, payload: &OcrPayload) -> Result<OcrCon
let skip = existing_asset.is_some() && !payload.force; let skip = existing_asset.is_some() && !payload.force;
Ok(OcrContext { Ok(OcrContext {
document, document: base.document,
version, version: base.version,
existing_asset, existing_asset,
existing_objects, existing_objects,
skip, skip,
@@ -498,15 +489,11 @@ fn persist_ocr_metadata(
Ok(()) Ok(())
} }
fn enqueue_index_job(state: &AppState, payload: &OcrPayload) -> Result<(), String> { fn enqueue_index_job(
let mut base_conn = state.db_unscoped().map_err(|err| format!("{err:?}"))?; state: &AppState,
let tenant_id: Uuid = documents::table tenant_id: Uuid,
.find(payload.document_id) payload: &OcrPayload,
.select(documents::tenant_id) ) -> Result<(), String> {
.first(&mut base_conn)
.map_err(|err| format!("{err:?}"))?;
drop(base_conn);
let mut conn = state let mut conn = state
.db_for_tenant(tenant_id) .db_for_tenant(tenant_id)
.map_err(|err| format!("{err:?}"))?; .map_err(|err| format!("{err:?}"))?;
+13
View File
@@ -57,6 +57,19 @@ impl JobHandler for ProvisionTenantJob {
} }
}; };
drop(conn);
let mut conn = match state.db_for_tenant(tenant.id) {
Ok(conn) => conn,
Err(err) => {
warn!(job_id = %job.id, error = ?err, "failed to scope connection for tenant provisioning");
return JobExecution::Retry {
delay: std::time::Duration::from_secs(30),
error: "tenant connection unavailable".into(),
};
}
};
if tenant.status == TenantStatus::Active { if tenant.status == TenantStatus::Active {
warn!( warn!(
job_id = %job.id, job_id = %job.id,
+52 -67
View File
@@ -18,15 +18,16 @@ use crate::{
Document, DocumentAsset, DocumentAssetObject, DocumentVersion, NewDocumentAsset, Document, DocumentAsset, DocumentAssetObject, DocumentVersion, NewDocumentAsset,
NewDocumentAssetObject, NewDocumentAssetObject,
}, },
schema::{document_asset_objects, document_assets, document_versions, documents}, schema::{document_asset_objects, document_assets, document_versions},
state::AppState, state::AppState,
storage::TenantStorage, storage::TenantStorage,
utils::storage_paths::document_asset_object_key, utils::storage_paths::document_asset_object_key,
}; };
use super::{ use super::{
analyze::determine_thumbnail_support, fetch_version_object, handle_fetch_error, JobExecution, analyze::determine_thumbnail_support,
JobHandler, common::{load_document_version, load_version_assets},
fetch_version_object, handle_fetch_error, JobExecution, JobHandler,
}; };
const THUMBNAIL_WIDTH: u32 = 512; const THUMBNAIL_WIDTH: u32 = 512;
@@ -36,7 +37,7 @@ const PREVIEW_HEIGHT: u32 = THUMBNAIL_HEIGHT * 4;
const THUMBNAIL_ASSET_TYPE: &str = "thumbnail"; const THUMBNAIL_ASSET_TYPE: &str = "thumbnail";
const PREVIEW_ASSET_TYPE: &str = "preview"; const PREVIEW_ASSET_TYPE: &str = "preview";
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, Clone)]
struct ThumbnailPayload { struct ThumbnailPayload {
document_id: Uuid, document_id: Uuid,
document_version_id: Uuid, document_version_id: Uuid,
@@ -74,8 +75,12 @@ impl JobHandler for GenerateThumbnailsJob {
}; };
let state_clone = state.clone(); let state_clone = state.clone();
let initial = let tenant_id = job.tenant_id;
match task::spawn_blocking(move || load_thumbnail_context(state_clone, &payload)).await let payload_for_context = payload.clone();
let initial = match task::spawn_blocking(move || {
load_thumbnail_context(state_clone, tenant_id, payload_for_context)
})
.await
{ {
Ok(Ok(ctx)) => ctx, Ok(Ok(ctx)) => ctx,
Ok(Err(err)) => { Ok(Err(err)) => {
@@ -122,8 +127,15 @@ impl JobHandler for GenerateThumbnailsJob {
let state_clone = state.clone(); let state_clone = state.clone();
let document_id = initial.document.id; let document_id = initial.document.id;
let version_id = initial.version.id; let version_id = initial.version.id;
let tenant_id = initial.tenant_id;
match task::spawn_blocking(move || { match task::spawn_blocking(move || {
persist_document_page_count(state_clone, document_id, version_id, page_count) persist_document_page_count(
state_clone,
tenant_id,
document_id,
version_id,
page_count,
)
}) })
.await .await
{ {
@@ -371,6 +383,7 @@ struct ThumbnailContext {
existing_preview: Option<DocumentAsset>, existing_preview: Option<DocumentAsset>,
existing_preview_objects: Vec<DocumentAssetObject>, existing_preview_objects: Vec<DocumentAssetObject>,
skip: bool, skip: bool,
tenant_id: Uuid,
} }
struct GeneratedImage { struct GeneratedImage {
@@ -404,70 +417,43 @@ struct AssetPersistence {
fn load_thumbnail_context( fn load_thumbnail_context(
state: Arc<AppState>, state: Arc<AppState>,
payload: &ThumbnailPayload, tenant_id: Uuid,
payload: ThumbnailPayload,
) -> Result<ThumbnailContext, String> { ) -> Result<ThumbnailContext, String> {
let mut conn = state.db_unscoped().map_err(|err| format!("{err:?}"))?; let base = load_document_version(
state.as_ref(),
tenant_id,
payload.document_id,
payload.document_version_id,
)?;
let version: DocumentVersion = document_versions::table let mut conn = state
.find(payload.document_version_id) .db_for_tenant(base.tenant_id)
.first(&mut conn)
.map_err(|err| format!("{err:?}"))?; .map_err(|err| format!("{err:?}"))?;
if version.document_id != payload.document_id { let mut assets = load_version_assets(
return Err("document/version mismatch".into()); &mut conn,
} base.tenant_id,
base.version.id,
&[THUMBNAIL_ASSET_TYPE, PREVIEW_ASSET_TYPE],
)?;
let document: Document = documents::table let (existing_thumbnail, existing_thumbnail_objects) = assets
.find(payload.document_id) .remove(THUMBNAIL_ASSET_TYPE)
.first(&mut conn) .map(|entry| (Some(entry.asset), entry.objects))
.map_err(|err| format!("{err:?}"))?; .unwrap_or((None, Vec::new()));
let tenant_id = document.tenant_id; let (existing_preview, existing_preview_objects) = assets
.remove(PREVIEW_ASSET_TYPE)
.map(|entry| (Some(entry.asset), entry.objects))
.unwrap_or((None, Vec::new()));
let existing_assets: Vec<DocumentAsset> = document_assets::table let (supported, _) = determine_thumbnail_support(&base.document);
.filter(document_assets::document_version_id.eq(payload.document_version_id))
.filter(document_assets::asset_type.eq_any(vec![
THUMBNAIL_ASSET_TYPE.to_string(),
PREVIEW_ASSET_TYPE.to_string(),
]))
.filter(document_assets::tenant_id.eq(tenant_id))
.load(&mut conn)
.map_err(|err| format!("{err:?}"))?;
let mut existing_thumbnail = None;
let mut existing_thumbnail_objects: Vec<DocumentAssetObject> = Vec::new();
let mut existing_preview = None;
let mut existing_preview_objects: Vec<DocumentAssetObject> = Vec::new();
for asset in existing_assets {
match asset.asset_type.as_str() {
THUMBNAIL_ASSET_TYPE => {
existing_thumbnail_objects = document_asset_objects::table
.filter(document_asset_objects::asset_id.eq(asset.id))
.filter(document_asset_objects::tenant_id.eq(tenant_id))
.order(document_asset_objects::ordinal.asc())
.load(&mut conn)
.map_err(|err| format!("{err:?}"))?;
existing_thumbnail = Some(asset);
}
PREVIEW_ASSET_TYPE => {
existing_preview_objects = document_asset_objects::table
.filter(document_asset_objects::asset_id.eq(asset.id))
.filter(document_asset_objects::tenant_id.eq(tenant_id))
.order(document_asset_objects::ordinal.asc())
.load(&mut conn)
.map_err(|err| format!("{err:?}"))?;
existing_preview = Some(asset);
}
_ => {}
}
}
let (supported, _) = determine_thumbnail_support(&document);
if !supported { if !supported {
return Err("thumbnail generation not supported for this document".into()); return Err("thumbnail generation not supported for this document".into());
} }
let expected_cardinality = expected_asset_cardinality(&document, &version); let expected_cardinality = expected_asset_cardinality(&base.document, &base.version);
let preview_cardinality = existing_preview let preview_cardinality = existing_preview
.as_ref() .as_ref()
.and_then(|asset| asset.cardinality) .and_then(|asset| asset.cardinality)
@@ -488,13 +474,14 @@ fn load_thumbnail_context(
&& !needs_regeneration; && !needs_regeneration;
Ok(ThumbnailContext { Ok(ThumbnailContext {
document, document: base.document,
version, version: base.version,
existing_thumbnail, existing_thumbnail,
existing_thumbnail_objects, existing_thumbnail_objects,
existing_preview, existing_preview,
existing_preview_objects, existing_preview_objects,
skip, skip,
tenant_id: base.tenant_id,
}) })
} }
@@ -736,15 +723,13 @@ fn persist_assets_metadata(
fn persist_document_page_count( fn persist_document_page_count(
state: Arc<AppState>, state: Arc<AppState>,
tenant_id: Uuid,
document_id: Uuid, document_id: Uuid,
document_version_id: Uuid, document_version_id: Uuid,
page_count: u32, page_count: u32,
) -> Result<(), String> { ) -> Result<(), String> {
let mut conn = state.db_unscoped().map_err(|err| format!("{err:?}"))?; let mut conn = state
let tenant_id: Uuid = documents::table .db_for_tenant(tenant_id)
.find(document_id)
.select(documents::tenant_id)
.first(&mut conn)
.map_err(|err| format!("{err:?}"))?; .map_err(|err| format!("{err:?}"))?;
let existing_metadata: Value = document_versions::table let existing_metadata: Value = document_versions::table
+23 -19
View File
@@ -2,16 +2,16 @@ mod common;
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
use axum::http::{header::SET_COOKIE, StatusCode}; use axum::http::{header::SET_COOKIE, StatusCode};
use backend::auth::passkeys::{
PasskeyLoginFinishPayload, PasskeyLoginStartPayload, PasskeyRegistrationFinishPayload,
RegistrationChallengeResponse,
};
use backend::models::{NewRefreshToken, NewUserMembership, TenantStatus, UserPasskey};
use backend::openapi::schemas::PasskeySummary;
use backend::schema::{refresh_tokens, tenants, user_memberships, users};
use chrono::{Duration as ChronoDuration, Utc}; use chrono::{Duration as ChronoDuration, Utc};
use common::{acquire_db_lock, body_to_vec, TestApp}; use common::{acquire_db_lock, body_to_vec, TestApp};
use diesel::prelude::*; use diesel::prelude::*;
use papercrate::auth::passkeys::{
PasskeyLoginFinishPayload, PasskeyLoginStartPayload, PasskeyRegistrationFinishPayload,
RegistrationChallengeResponse,
};
use papercrate::models::{NewRefreshToken, NewUserMembership, TenantStatus, UserPasskey};
use papercrate::openapi::schemas::PasskeySummary;
use papercrate::schema::{refresh_tokens, tenants, user_memberships, users};
use rand::rngs::OsRng; use rand::rngs::OsRng;
use rand::RngCore; use rand::RngCore;
use serde::Deserialize; use serde::Deserialize;
@@ -29,8 +29,10 @@ struct AuthenticatedUser {
} }
#[derive(Deserialize)] #[derive(Deserialize)]
struct ErrorResponse { struct ApiErrorResponse {
error: String, error: String,
#[serde(default)]
_code: Option<String>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -53,8 +55,10 @@ struct SignupStartResponse {
#[derive(Deserialize)] #[derive(Deserialize)]
struct TenantSelectionResponse { struct TenantSelectionResponse {
access_token: String, #[serde(rename = "access_token")]
tenants: Vec<TenantSummary>, _access_token: String,
#[serde(rename = "tenants")]
_tenants: Vec<TenantSummary>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -98,7 +102,7 @@ async fn login_rejects_unknown_user() -> Result<()> {
let response = app.post_json("/api/auth/login", &payload, None).await?; let response = app.post_json("/api/auth/login", &payload, None).await?;
assert_eq!(response.status(), StatusCode::BAD_REQUEST); assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = body_to_vec(response.into_body()).await?; let body = body_to_vec(response.into_body()).await?;
let err: ErrorResponse = serde_json::from_slice(&body)?; let err: ApiErrorResponse = serde_json::from_slice(&body)?;
assert_eq!(err.error, "password authentication is no longer supported"); assert_eq!(err.error, "password authentication is no longer supported");
app.cleanup().await?; app.cleanup().await?;
@@ -169,8 +173,8 @@ async fn passkey_register_start_creates_challenge() -> Result<()> {
let challenge_id = challenge.challenge_id; let challenge_id = challenge.challenge_id;
app.with_conn(move |conn| { app.with_conn(move |conn| {
use backend::schema::webauthn_challenges::dsl;
use diesel::dsl::{exists, select}; use diesel::dsl::{exists, select};
use papercrate::schema::webauthn_challenges::dsl;
let exists: bool = select(exists( let exists: bool = select(exists(
dsl::webauthn_challenges.filter(dsl::id.eq(challenge_id)), dsl::webauthn_challenges.filter(dsl::id.eq(challenge_id)),
@@ -322,7 +326,7 @@ async fn delete_passkey_soft_revokes() -> Result<()> {
assert_eq!(response.status(), StatusCode::NO_CONTENT); assert_eq!(response.status(), StatusCode::NO_CONTENT);
app.with_conn(move |conn| { app.with_conn(move |conn| {
use backend::schema::user_passkeys::dsl as passkey_dsl; use papercrate::schema::user_passkeys::dsl as passkey_dsl;
let record = passkey_dsl::user_passkeys let record = passkey_dsl::user_passkeys
.find(passkey_id) .find(passkey_id)
@@ -409,7 +413,7 @@ async fn login_rejects_invalid_password() -> Result<()> {
let response = app.post_json("/api/auth/login", &payload, None).await?; let response = app.post_json("/api/auth/login", &payload, None).await?;
assert_eq!(response.status(), StatusCode::BAD_REQUEST); assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = body_to_vec(response.into_body()).await?; let body = body_to_vec(response.into_body()).await?;
let err: ErrorResponse = serde_json::from_slice(&body)?; let err: ApiErrorResponse = serde_json::from_slice(&body)?;
assert_eq!(err.error, "password authentication is no longer supported"); assert_eq!(err.error, "password authentication is no longer supported");
app.cleanup().await?; app.cleanup().await?;
@@ -574,18 +578,18 @@ async fn login_with_session(
let username = username.to_string(); let username = username.to_string();
let state = app.state.clone(); let state = app.state.clone();
app.with_conn(move |conn| { app.with_conn(move |conn| {
use backend::schema::user_memberships::dsl as memberships_dsl; use papercrate::schema::user_memberships::dsl as memberships_dsl;
use backend::schema::users::dsl as users_dsl; use papercrate::schema::users::dsl as users_dsl;
let user: backend::models::User = users_dsl::users let user: papercrate::models::User = users_dsl::users
.filter(users_dsl::username.eq(&username)) .filter(users_dsl::username.eq(&username))
.first(conn)?; .first(conn)?;
let membership: backend::models::UserMembership = memberships_dsl::user_memberships let membership: papercrate::models::UserMembership = memberships_dsl::user_memberships
.filter(memberships_dsl::user_id.eq(user.id)) .filter(memberships_dsl::user_id.eq(user.id))
.first(conn)?; .first(conn)?;
let tenant: backend::models::Tenant = let tenant: papercrate::models::Tenant =
tenants::table.find(membership.tenant_id).first(conn)?; tenants::table.find(membership.tenant_id).first(conn)?;
let now = Utc::now(); let now = Utc::now();
+108 -36
View File
@@ -8,17 +8,6 @@ use async_trait::async_trait;
use axum::body::Body; use axum::body::Body;
use axum::http::{header, Method, Request}; use axum::http::{header, Method, Request};
use axum::Router; use axum::Router;
use backend::auth::jwt::JwtService;
use backend::config::AppConfig;
use backend::db::{self, PgPool};
use backend::models::{
Job, NewRefreshToken, NewUser, NewUserMembership, NewUserPasskey, Tenant, TenantStatus, User,
UserMembership,
};
use backend::routes;
use backend::schema::refresh_tokens::dsl as refresh_dsl;
use backend::state::AppState;
use backend::storage::ObjectStorage;
use chrono::{Duration as ChronoDuration, Utc}; use chrono::{Duration as ChronoDuration, Utc};
use diesel::connection::SimpleConnection; use diesel::connection::SimpleConnection;
use diesel::prelude::*; use diesel::prelude::*;
@@ -27,9 +16,20 @@ use diesel::PgConnection;
use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness}; use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
use http_body_util::BodyExt; use http_body_util::BodyExt;
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use papercrate::auth::jwt::JwtService;
use papercrate::config::AppConfig;
use papercrate::db::{self, PgPool};
use papercrate::models::{
Job, NewRefreshToken, NewUser, NewUserMembership, NewUserPasskey, Tenant, TenantStatus, User,
UserMembership,
};
use papercrate::routes;
use papercrate::schema::refresh_tokens::dsl as refresh_dsl;
use papercrate::state::AppState;
use papercrate::storage::ObjectStorage;
use rand::rngs::OsRng; use rand::rngs::OsRng;
use rand::RngCore; use rand::RngCore;
use serde::{Deserialize, Serialize}; use serde::Serialize;
use serde_json::{self, json}; use serde_json::{self, json};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use tokio::sync::Mutex; use tokio::sync::Mutex;
@@ -37,6 +37,11 @@ use tower::util::ServiceExt;
use uuid::Uuid; use uuid::Uuid;
const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations"); const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
const RESET_DATABASE_SQL: &str = "DROP SCHEMA IF EXISTS tenant CASCADE;\n\
DROP SCHEMA IF EXISTS shared CASCADE;\n\
DROP SCHEMA IF EXISTS public CASCADE;\n\
CREATE SCHEMA public;\n\
GRANT ALL ON SCHEMA public TO public;";
static DB_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(())); static DB_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
@@ -220,7 +225,7 @@ impl TestApp {
id: Uuid::new_v4(), id: Uuid::new_v4(),
username, username,
}; };
diesel::insert_into(backend::schema::users::table) diesel::insert_into(papercrate::schema::users::table)
.values(&user) .values(&user)
.execute(conn) .execute(conn)
.context("failed to insert user")?; .context("failed to insert user")?;
@@ -231,7 +236,7 @@ impl TestApp {
tenant_id, tenant_id,
}; };
diesel::insert_into(backend::schema::user_memberships::table) diesel::insert_into(papercrate::schema::user_memberships::table)
.values(&membership) .values(&membership)
.execute(conn) .execute(conn)
.context("failed to insert user membership")?; .context("failed to insert user membership")?;
@@ -258,7 +263,7 @@ impl TestApp {
nickname, nickname,
}; };
diesel::insert_into(backend::schema::user_passkeys::table) diesel::insert_into(papercrate::schema::user_passkeys::table)
.values(&passkey) .values(&passkey)
.execute(conn) .execute(conn)
.context("failed to insert passkey")?; .context("failed to insert passkey")?;
@@ -272,7 +277,7 @@ impl TestApp {
let name_value = TEST_TENANT_NAME.to_string(); let name_value = TEST_TENANT_NAME.to_string();
let quickwit_enabled = self.state.config.quickwit_endpoint.is_some(); let quickwit_enabled = self.state.config.quickwit_endpoint.is_some();
self.with_conn(move |conn| { self.with_conn(move |conn| {
use backend::schema::tenants::dsl as tenants_dsl; use papercrate::schema::tenants::dsl as tenants_dsl;
let existing = tenants_dsl::tenants let existing = tenants_dsl::tenants
.filter(tenants_dsl::name.eq(&name_value)) .filter(tenants_dsl::name.eq(&name_value))
@@ -332,9 +337,9 @@ impl TestApp {
let username = username.to_string(); let username = username.to_string();
let state = self.state.clone(); let state = self.state.clone();
self.with_conn(move |conn| { self.with_conn(move |conn| {
use backend::schema::tenants::dsl as tenants_dsl; use papercrate::schema::tenants::dsl as tenants_dsl;
use backend::schema::user_memberships::dsl as memberships_dsl; use papercrate::schema::user_memberships::dsl as memberships_dsl;
use backend::schema::users::dsl as users_dsl; use papercrate::schema::users::dsl as users_dsl;
let user: User = users_dsl::users let user: User = users_dsl::users
.filter(users_dsl::username.eq(&username)) .filter(users_dsl::username.eq(&username))
@@ -381,7 +386,7 @@ impl TestApp {
#[allow(dead_code)] #[allow(dead_code)]
pub async fn clear_jobs(&self) -> Result<()> { pub async fn clear_jobs(&self) -> Result<()> {
self.with_conn(|conn| { self.with_conn(|conn| {
use backend::schema::jobs::dsl::jobs as jobs_table; use papercrate::schema::jobs::dsl::jobs as jobs_table;
diesel::delete(jobs_table) diesel::delete(jobs_table)
.execute(conn) .execute(conn)
.context("failed to clear jobs")?; .context("failed to clear jobs")?;
@@ -394,7 +399,7 @@ impl TestApp {
pub async fn jobs_by_type(&self, ty: &str) -> Result<Vec<Job>> { pub async fn jobs_by_type(&self, ty: &str) -> Result<Vec<Job>> {
let ty = ty.to_string(); let ty = ty.to_string();
self.with_conn(move |conn| { self.with_conn(move |conn| {
use backend::schema::jobs::dsl::{job_type as job_type_col, jobs as jobs_table}; use papercrate::schema::jobs::dsl::{job_type as job_type_col, jobs as jobs_table};
let rows = jobs_table let rows = jobs_table
.filter(job_type_col.eq(&ty)) .filter(job_type_col.eq(&ty))
.load::<Job>(conn) .load::<Job>(conn)
@@ -691,12 +696,76 @@ pub async fn body_to_vec(body: Body) -> Result<Vec<u8>> {
Ok(collected.to_bytes().to_vec()) Ok(collected.to_bytes().to_vec())
} }
#[cfg(test)]
mod helper_tests {
use super::*;
#[tokio::test]
async fn create_session_and_login_token_provide_access() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let username = "helper-login";
let password = "irrelevant";
app.insert_user(username, password, "admin").await?;
let (access, refresh, refresh_id) = app.create_session(username).await?;
assert!(!access.is_empty(), "access token should not be empty");
assert!(!refresh.is_empty(), "refresh token should not be empty");
assert_ne!(
refresh_id,
Uuid::nil(),
"refresh token id should be assigned"
);
let bearer = app.login_token(username, password).await?;
assert!(!bearer.is_empty(), "login_token must yield bearer");
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn insert_passkey_and_upload_with_options_succeeds() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let username = "helper-passkey";
let password = "unused";
let user_id = app.insert_user(username, password, "admin").await?;
let passkey_id = app.insert_passkey(user_id, Some("Laptop")).await?;
assert_ne!(passkey_id, Uuid::nil());
let bearer = app.login_token(username, password).await?;
let response = app
.upload_document_with_options(
"/api/documents",
"helper.txt",
"text/plain",
b"helper-content",
None,
Some("Helper Note"),
Some("{\"category\":\"note\"}"),
&bearer,
)
.await?;
assert!(response.status().is_success());
app.cleanup().await?;
Ok(())
}
}
async fn prepare_database(pool: &PgPool) -> Result<()> { async fn prepare_database(pool: &PgPool) -> Result<()> {
let pool = pool.clone(); let pool = pool.clone();
tokio::task::spawn_blocking(move || -> Result<()> { tokio::task::spawn_blocking(move || -> Result<()> {
let mut conn = pool let mut conn = pool
.get() .get()
.map_err(|err| anyhow!("failed to acquire connection: {err}"))?; .map_err(|err| anyhow!("failed to acquire connection: {err}"))?;
conn.batch_execute(RESET_DATABASE_SQL)
.map_err(|err| anyhow!("failed to reset schema: {err}"))?;
conn.batch_execute("DROP TABLE IF EXISTS __diesel_schema_migrations;")
.map_err(|err| anyhow!("failed to drop diesel schema table: {err}"))?;
conn.run_pending_migrations(MIGRATIONS) conn.run_pending_migrations(MIGRATIONS)
.map_err(|err| anyhow!("failed to run migrations: {err}"))?; .map_err(|err| anyhow!("failed to run migrations: {err}"))?;
truncate_all(&mut conn)?; truncate_all(&mut conn)?;
@@ -709,21 +778,24 @@ async fn prepare_database(pool: &PgPool) -> Result<()> {
fn truncate_all(conn: &mut PgConnection) -> Result<()> { fn truncate_all(conn: &mut PgConnection) -> Result<()> {
conn.batch_execute( conn.batch_execute(
"TRUNCATE TABLE \ "TRUNCATE TABLE \
document_asset_objects, \ tenant.document_asset_objects, \
document_assets, \ tenant.document_assets, \
document_correspondents, \ tenant.document_correspondents, \
correspondents, \ tenant.correspondents, \
document_tags, \ tenant.document_tags, \
document_versions, \ tenant.document_versions, \
documents, \ tenant.documents, \
folders, \ tenant.folders, \
jobs, \ shared.jobs, \
refresh_tokens, \ tenant.refresh_tokens, \
tags, \ tenant.tags, \
webdav_tokens, \ tenant.webdav_tokens, \
user_memberships, \ shared.webauthn_challenges, \
users, \ shared.user_passkeys, \
tenants \ tenant.user_memberships, \
shared.users, \
shared.magic_tokens, \
shared.tenants \
RESTART IDENTITY CASCADE;", RESTART IDENTITY CASCADE;",
) )
.context("failed to truncate tables")?; .context("failed to truncate tables")?;
+2 -1
View File
@@ -14,7 +14,8 @@ struct DocumentDetail {
#[derive(Deserialize)] #[derive(Deserialize)]
struct DocumentSummary { struct DocumentSummary {
id: Uuid, id: Uuid,
title: String, #[serde(rename = "title")]
_title: String,
#[serde(default)] #[serde(default)]
correspondents: Vec<DocumentCorrespondentSummary>, correspondents: Vec<DocumentCorrespondentSummary>,
} }
+13 -21
View File
@@ -12,6 +12,13 @@ struct DocumentDetail {
document: DocumentInfo, document: DocumentInfo,
} }
#[derive(Deserialize)]
struct ApiErrorResponse {
error: String,
#[serde(default)]
code: Option<String>,
}
#[derive(Deserialize)] #[derive(Deserialize)]
struct DocumentInfo { struct DocumentInfo {
id: Uuid, id: Uuid,
@@ -25,8 +32,6 @@ struct DocumentInfo {
metadata: Value, metadata: Value,
tags: Vec<TagSummary>, tags: Vec<TagSummary>,
#[serde(default)] #[serde(default)]
correspondents: Vec<DocumentCorrespondentInfo>,
#[serde(default)]
current_version: Option<DocumentVersionPayload>, current_version: Option<DocumentVersionPayload>,
} }
@@ -81,12 +86,6 @@ struct TagSummary {
label: String, label: String,
} }
#[derive(Deserialize)]
struct DocumentCorrespondentInfo {
id: Uuid,
name: String,
}
#[derive(Deserialize)] #[derive(Deserialize)]
struct AnalyzeJobPayload { struct AnalyzeJobPayload {
document_id: Uuid, document_id: Uuid,
@@ -95,13 +94,6 @@ struct AnalyzeJobPayload {
force: bool, force: bool,
} }
#[derive(Deserialize)]
struct ErrorResponse {
error: String,
#[serde(default)]
code: Option<String>,
}
#[derive(Deserialize)] #[derive(Deserialize)]
struct FolderResponse { struct FolderResponse {
folder: FolderInfo, folder: FolderInfo,
@@ -1059,7 +1051,7 @@ async fn patch_document_updates_title_and_handles_conflict() -> Result<()> {
.await?; .await?;
assert_eq!(conflict.status(), StatusCode::CONFLICT); assert_eq!(conflict.status(), StatusCode::CONFLICT);
let conflict_body = body_to_vec(conflict.into_body()).await?; let conflict_body = body_to_vec(conflict.into_body()).await?;
let conflict_json: ErrorResponse = serde_json::from_slice(&conflict_body)?; let conflict_json: ApiErrorResponse = serde_json::from_slice(&conflict_body)?;
assert_eq!(conflict_json.code.as_deref(), Some("duplicate_filename")); assert_eq!(conflict_json.code.as_deref(), Some("duplicate_filename"));
app.cleanup().await?; app.cleanup().await?;
@@ -1241,7 +1233,7 @@ async fn patch_document_validation_errors() -> Result<()> {
.await?; .await?;
assert_eq!(empty_title.status(), StatusCode::BAD_REQUEST); assert_eq!(empty_title.status(), StatusCode::BAD_REQUEST);
let title_body = body_to_vec(empty_title.into_body()).await?; let title_body = body_to_vec(empty_title.into_body()).await?;
let title_error: ErrorResponse = serde_json::from_slice(&title_body)?; let title_error: ApiErrorResponse = serde_json::from_slice(&title_body)?;
assert_eq!(title_error.error, "title must not be empty"); assert_eq!(title_error.error, "title must not be empty");
let empty_issued = app let empty_issued = app
@@ -1253,7 +1245,7 @@ async fn patch_document_validation_errors() -> Result<()> {
.await?; .await?;
assert_eq!(empty_issued.status(), StatusCode::BAD_REQUEST); assert_eq!(empty_issued.status(), StatusCode::BAD_REQUEST);
let issued_body = body_to_vec(empty_issued.into_body()).await?; let issued_body = body_to_vec(empty_issued.into_body()).await?;
let issued_error: ErrorResponse = serde_json::from_slice(&issued_body)?; let issued_error: ApiErrorResponse = serde_json::from_slice(&issued_body)?;
assert_eq!(issued_error.error, "issued_at must not be empty"); assert_eq!(issued_error.error, "issued_at must not be empty");
let invalid_merge = app let invalid_merge = app
@@ -1269,7 +1261,7 @@ async fn patch_document_validation_errors() -> Result<()> {
.await?; .await?;
assert_eq!(invalid_merge.status(), StatusCode::BAD_REQUEST); assert_eq!(invalid_merge.status(), StatusCode::BAD_REQUEST);
let merge_body = body_to_vec(invalid_merge.into_body()).await?; let merge_body = body_to_vec(invalid_merge.into_body()).await?;
let merge_error: ErrorResponse = serde_json::from_slice(&merge_body)?; let merge_error: ApiErrorResponse = serde_json::from_slice(&merge_body)?;
assert_eq!( assert_eq!(
merge_error.error, merge_error.error,
"metadata value must be a JSON object when replace is false" "metadata value must be a JSON object when replace is false"
@@ -1305,7 +1297,7 @@ async fn patch_document_validation_errors() -> Result<()> {
.await?; .await?;
assert_eq!(merge_after_scalar.status(), StatusCode::BAD_REQUEST); assert_eq!(merge_after_scalar.status(), StatusCode::BAD_REQUEST);
let merge_after_body = body_to_vec(merge_after_scalar.into_body()).await?; let merge_after_body = body_to_vec(merge_after_scalar.into_body()).await?;
let merge_after_error: ErrorResponse = serde_json::from_slice(&merge_after_body)?; let merge_after_error: ApiErrorResponse = serde_json::from_slice(&merge_after_body)?;
assert_eq!( assert_eq!(
merge_after_error.error, merge_after_error.error,
"existing metadata is not an object; set replace=true to overwrite" "existing metadata is not an object; set replace=true to overwrite"
@@ -1320,7 +1312,7 @@ async fn patch_document_validation_errors() -> Result<()> {
.await?; .await?;
assert_eq!(malformed_timestamp.status(), StatusCode::BAD_REQUEST); assert_eq!(malformed_timestamp.status(), StatusCode::BAD_REQUEST);
let malformed_body = body_to_vec(malformed_timestamp.into_body()).await?; let malformed_body = body_to_vec(malformed_timestamp.into_body()).await?;
let malformed_error: ErrorResponse = serde_json::from_slice(&malformed_body)?; let malformed_error: ApiErrorResponse = serde_json::from_slice(&malformed_body)?;
assert!( assert!(
malformed_error malformed_error
.error .error
+4 -4
View File
@@ -2,13 +2,13 @@ mod common;
use anyhow::Result; use anyhow::Result;
use axum::http::StatusCode; use axum::http::StatusCode;
use backend::models::{NewUser, NewUserMembership, Tag, TenantStatus}; use common::{acquire_db_lock, body_to_vec, TestApp};
use backend::schema::{ use diesel::prelude::*;
use papercrate::models::{NewUser, NewUserMembership, Tag, TenantStatus};
use papercrate::schema::{
tags::dsl as tags_dsl, tenants::dsl as tenants_dsl, user_memberships::dsl as memberships_dsl, tags::dsl as tags_dsl, tenants::dsl as tenants_dsl, user_memberships::dsl as memberships_dsl,
users::dsl as users_dsl, users::dsl as users_dsl,
}; };
use common::{acquire_db_lock, body_to_vec, TestApp};
use diesel::prelude::*;
use serde::Deserialize; use serde::Deserialize;
use serde::Serialize; use serde::Serialize;
use uuid::Uuid; use uuid::Uuid;
+71 -5
View File
@@ -3,13 +3,13 @@ mod common;
use anyhow::Result; use anyhow::Result;
use axum::body::Body; use axum::body::Body;
use axum::http::{header, Method, Request, StatusCode}; use axum::http::{header, Method, Request, StatusCode};
use backend::models::WebdavToken;
use backend::routes::webdav;
use backend::schema::webdav_tokens;
use base64::engine::general_purpose::STANDARD as BASE64; use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine; use base64::Engine;
use common::{acquire_db_lock, body_to_vec, TestApp}; use common::{acquire_db_lock, body_to_vec, TestApp};
use diesel::prelude::*; use diesel::prelude::*;
use papercrate::models::WebdavToken;
use papercrate::routes::webdav;
use papercrate::schema::webdav_tokens;
use serde::Deserialize; use serde::Deserialize;
use serde_json::json; use serde_json::json;
use tower::ServiceExt; use tower::ServiceExt;
@@ -54,6 +54,20 @@ async fn webdav_token_api_crud() -> Result<()> {
assert_eq!(created.info.label.as_deref(), Some("dav")); assert_eq!(created.info.label.as_deref(), Some("dav"));
assert!(created.info.last_used_at.is_none()); assert!(created.info.last_used_at.is_none());
let regenerate_response = app
.post_json(
&format!("/api/profile/webdav-tokens/{token_id}/regenerate"),
&json!({}),
Some(&access_token),
)
.await?;
assert_eq!(regenerate_response.status(), StatusCode::OK);
let regenerate_body = body_to_vec(regenerate_response.into_body()).await?;
let regenerated: CreateTokenResponse = serde_json::from_slice(&regenerate_body)?;
assert_eq!(regenerated.info.id, token_id);
assert_ne!(regenerated.token, created.token);
assert!(regenerated.info.last_used_at.is_none());
let list_response = app let list_response = app
.get("/api/profile/webdav-tokens", Some(&access_token)) .get("/api/profile/webdav-tokens", Some(&access_token))
.await?; .await?;
@@ -106,9 +120,10 @@ async fn webdav_basic_auth_uses_tokens() -> Result<()> {
let token_id = created.info.id; let token_id = created.info.id;
let router = webdav::create_router().with_state(app.state.clone()); let router = webdav::create_router().with_state(app.state.clone());
let original_secret = created.token.clone();
let auth_header = format!( let auth_header = format!(
"Basic {}", "Basic {}",
BASE64.encode(format!("{}:{}", username, created.token)) BASE64.encode(format!("{}:{}", username, original_secret))
); );
let propfind = Method::from_bytes(b"PROPFIND")?; let propfind = Method::from_bytes(b"PROPFIND")?;
@@ -131,6 +146,57 @@ async fn webdav_basic_auth_uses_tokens() -> Result<()> {
.await?; .await?;
assert!(used.is_some()); assert!(used.is_some());
let regenerate_response = app
.post_json(
&format!("/api/profile/webdav-tokens/{token_id}/regenerate"),
&json!({}),
Some(&access_token),
)
.await?;
assert_eq!(regenerate_response.status(), StatusCode::OK);
let regenerate_body = body_to_vec(regenerate_response.into_body()).await?;
let regenerated: CreateTokenResponse = serde_json::from_slice(&regenerate_body)?;
assert_ne!(regenerated.token, original_secret);
let unused_after_regen = app
.with_conn(move |conn| {
let record = webdav_tokens::table
.find(token_id)
.first::<WebdavToken>(conn)?;
Ok::<_, anyhow::Error>(record.last_used_at)
})
.await?;
assert!(unused_after_regen.is_none());
let old_secret_request = Request::builder()
.method(propfind.clone())
.uri("/")
.header(
header::AUTHORIZATION,
format!(
"Basic {}",
BASE64.encode(format!("{}:{}", username, original_secret))
),
)
.header("depth", "0")
.body(Body::empty())?;
let old_secret_response = router.clone().oneshot(old_secret_request).await?;
assert_eq!(old_secret_response.status(), StatusCode::UNAUTHORIZED);
let new_secret_header = format!(
"Basic {}",
BASE64.encode(format!("{}:{}", username, regenerated.token))
);
let success_request = Request::builder()
.method(propfind.clone())
.uri("/")
.header(header::AUTHORIZATION, new_secret_header.clone())
.header("depth", "0")
.body(Body::empty())?;
let response = router.clone().oneshot(success_request).await?;
assert_eq!(response.status(), StatusCode::MULTI_STATUS);
let delete_response = app let delete_response = app
.delete( .delete(
&format!("/api/profile/webdav-tokens/{token_id}"), &format!("/api/profile/webdav-tokens/{token_id}"),
@@ -142,7 +208,7 @@ async fn webdav_basic_auth_uses_tokens() -> Result<()> {
let failure_request = Request::builder() let failure_request = Request::builder()
.method(propfind) .method(propfind)
.uri("/") .uri("/")
.header(header::AUTHORIZATION, auth_header) .header(header::AUTHORIZATION, new_secret_header)
.header("depth", "0") .header("depth", "0")
.body(Body::empty())?; .body(Body::empty())?;
let response = router.oneshot(failure_request).await?; let response = router.oneshot(failure_request).await?;
+1 -18
View File
@@ -49,24 +49,7 @@ services:
command: > command: >
/bin/sh -c " /bin/sh -c "
echo 'Running database migrations' && echo 'Running database migrations' &&
diesel migration run && diesel migration run
echo 'Ensuring tenant admin exists' &&
if papercrate-admin list-tenants | grep -q '^admin '; then
echo 'tenant admin already exists';
else
papercrate-admin create-tenant admin;
fi &&
ADMIN_TENANT_ID=$(papercrate-admin list-tenants | awk '/^admin / {print $2; exit}') &&
if [ -z "$ADMIN_TENANT_ID" ]; then
echo 'failed to resolve admin tenant id' >&2;
exit 1;
fi &&
echo 'Ensuring demo user credentials' &&
(papercrate-admin create-user admin adminadmin || papercrate-admin set-password admin adminadmin) &&
echo 'Ensuring demo membership' &&
papercrate-admin add-user-to-tenant admin "$ADMIN_TENANT_ID" &&
echo 'Ensuring Quickwit index for admin tenant' &&
papercrate-admin quickwit-create-index "$ADMIN_TENANT_ID"
" "
user: root user: root
restart: "no" restart: "no"
+120 -50
View File
@@ -1,30 +1,62 @@
version: "3.9"
x-app-env: &app-env
DATABASE_URL: postgres://papercrate_app_login:${APP_DATABASE_PASSWORD:-papercrate_app}@postgres:5432/papercrate
DATABASE_MAX_POOL_SIZE: ${DATABASE_MAX_POOL_SIZE:-8}
SERVER_HOST: 0.0.0.0
SERVER_PORT: 3000
WEBDAV_HOST: 0.0.0.0
WEBDAV_PORT: 3001
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET must be set}
JWT_ISSUER: ${JWT_ISSUER:-papercrate}
JWT_AUDIENCE: ${JWT_AUDIENCE:-papercrate-clients}
JWT_EXPIRY_MINUTES: ${JWT_EXPIRY_MINUTES:-60}
DOWNLOAD_TOKEN_AUDIENCE: ${DOWNLOAD_TOKEN_AUDIENCE:-papercrate-download}
DOWNLOAD_TOKEN_EXPIRY_MINUTES: ${DOWNLOAD_TOKEN_EXPIRY_MINUTES:-60}
REFRESH_TOKEN_EXPIRY_DAYS: ${REFRESH_TOKEN_EXPIRY_DAYS:-30}
REFRESH_COOKIE_SECURE: ${REFRESH_COOKIE_SECURE:-false}
REFRESH_COOKIE_DOMAIN: ${REFRESH_COOKIE_DOMAIN:-}
CORS_ALLOWED_ORIGIN: ${CORS_ALLOWED_ORIGIN:-}
AWS_ENDPOINT_URL: http://minio:9000
AWS_ACCESS_KEY_ID: ${MINIO_ROOT_USER:-papercrate}
AWS_SECRET_ACCESS_KEY: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD must be set}
AWS_REGION: ${AWS_REGION:-us-east-1}
S3_BUCKET: ${S3_BUCKET:-documents}
QUICKWIT_ENDPOINT: http://quickwit:7280
QUICKWIT_INDEX: ${QUICKWIT_INDEX:-documents}
WORKER_MAX_DOCUMENT_BYTES: ${WORKER_MAX_DOCUMENT_BYTES:-209715200}
UPLOAD_BODY_LIMIT_BYTES: ${UPLOAD_BODY_LIMIT_BYTES:-134217728}
WEBAUTHN_RP_ID: ${WEBAUTHN_RP_ID:-papercrate.local}
WEBAUTHN_ORIGIN: ${WEBAUTHN_ORIGIN:-https://papercrate.local}
WEBAUTHN_RP_NAME: ${WEBAUTHN_RP_NAME:-Papercrate}
RUST_LOG: ${RUST_LOG:-info}
services: services:
postgres: postgres:
image: postgres:16-alpine image: postgres:16-alpine
restart: unless-stopped
environment: environment:
POSTGRES_USER: papercrate POSTGRES_USER: papercrate
POSTGRES_PASSWORD: papercrate_dev POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set}
POSTGRES_DB: papercrate POSTGRES_DB: papercrate
ports:
- "5432:5432"
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
- ./backend/migrations:/docker-entrypoint-initdb.d - ./backend/postgres-init:/docker-entrypoint-initdb.d
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U papercrate"] test: ["CMD-SHELL", "pg_isready -U papercrate -d papercrate"]
interval: 5s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
ports:
- "${POSTGRES_PORT:-5432}:5432"
minio: minio:
image: minio/minio:latest image: minio/minio:latest
command: server /data --console-address ":9001" command: server /data --console-address ":9001"
restart: unless-stopped
environment: environment:
MINIO_ROOT_USER: minioadmin MINIO_ROOT_USER: ${MINIO_ROOT_USER:-papercrate}
MINIO_ROOT_PASSWORD: minioadmin MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD must be set}
ports:
- "9000:9000" # S3 API
- "9001:9001" # Console
volumes: volumes:
- minio_data:/data - minio_data:/data
healthcheck: healthcheck:
@@ -32,28 +64,35 @@ services:
interval: 30s interval: 30s
timeout: 20s timeout: 20s
retries: 3 retries: 3
ports:
- "${MINIO_API_PORT:-9000}:9000"
- "${MINIO_CONSOLE_PORT:-9001}:9001"
createbuckets: createbuckets:
image: minio/mc:latest image: minio/mc:latest
depends_on: depends_on:
minio: minio:
condition: service_healthy condition: service_healthy
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-papercrate}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD must be set}
S3_BUCKET: ${S3_BUCKET:-documents}
entrypoint: > entrypoint: >
/bin/sh -c " /bin/sh -c "
/usr/bin/mc alias set myminio http://minio:9000 minioadmin minioadmin; set -e;
/usr/bin/mc mb myminio/documents --ignore-existing; /usr/bin/mc alias set papercrate http://minio:9000 $${MINIO_ROOT_USER} $${MINIO_ROOT_PASSWORD};
/usr/bin/mc anonymous set download myminio/documents; /usr/bin/mc mb papercrate/$${S3_BUCKET} --ignore-existing;
exit 0; exit 0;
" "
restart: "no"
quickwit: quickwit:
image: quickwit/quickwit:0.8.2 image: quickwit/quickwit:0.8.2
command: ["run"] command: ["run"]
restart: unless-stopped
environment: environment:
QW_ENABLE_API_AUTH: "false" QW_ENABLE_API_AUTH: "false"
QW_DATA_DIR: /quickwit/data QW_DATA_DIR: /quickwit/data
ports:
- "7280:7280"
volumes: volumes:
- quickwit_data:/quickwit/data - quickwit_data:/quickwit/data
healthcheck: healthcheck:
@@ -61,49 +100,80 @@ services:
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
ports:
- "${QUICKWIT_PORT:-7280}:7280"
admin-bootstrap: backend:
build: build:
context: ./backend context: ./backend
image: papercrate/backend:latest
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
minio:
condition: service_healthy
createbuckets:
condition: service_completed_successfully
quickwit: quickwit:
condition: service_healthy condition: service_healthy
migrator:
condition: service_completed_successfully
environment: environment:
DATABASE_URL: postgres://papercrate:papercrate_dev@postgres:5432/papercrate <<: *app-env
DATABASE_MAX_POOL_SIZE: 2 ports:
AWS_ENDPOINT_URL: http://minio:9000 - "${API_PORT:-3000}:3000"
AWS_ACCESS_KEY_ID: minioadmin healthcheck:
AWS_SECRET_ACCESS_KEY: minioadmin test: ["CMD", "curl", "-sf", "http://localhost:3000/api/health"]
AWS_REGION: us-east-1 interval: 10s
S3_BUCKET: documents timeout: 5s
JWT_SECRET: change-me-super-secret retries: 5
QUICKWIT_ENDPOINT: http://quickwit:7280 restart: unless-stopped
entrypoint: []
command: > worker:
/bin/sh -c " image: papercrate/backend:latest
echo 'Running database migrations' && depends_on:
diesel migration run && backend:
echo 'Ensuring tenant admin exists' && condition: service_healthy
if papercrate-admin list-tenants | grep -q '^admin '; then environment:
echo 'tenant admin already exists'; <<: *app-env
else entrypoint: ["/usr/local/bin/papercrate-worker"]
papercrate-admin create-tenant admin; restart: unless-stopped
fi &&
ADMIN_TENANT_ID=$(papercrate-admin list-tenants | awk '/^admin / {print $2; exit}') && webdav:
if [ -z "$ADMIN_TENANT_ID" ]; then image: papercrate/backend:latest
echo 'failed to resolve admin tenant id' >&2; depends_on:
exit 1; backend:
fi && condition: service_healthy
echo 'Ensuring demo user credentials' && environment:
(papercrate-admin create-user admin adminadmin || papercrate-admin set-password admin adminadmin) && <<: *app-env
echo 'Ensuring demo membership' && entrypoint: ["/usr/local/bin/papercrate-webdav"]
papercrate-admin add-user-to-tenant admin "$ADMIN_TENANT_ID" && ports:
echo 'Ensuring Quickwit index for admin tenant' && - "${WEBDAV_PORT:-3001}:3001"
papercrate-admin quickwit-create-index "$ADMIN_TENANT_ID" restart: unless-stopped
"
user: root frontend:
build:
context: ./frontend
image: papercrate/frontend:latest
environment:
API_PROXY_PASS: http://backend:3000
depends_on:
backend:
condition: service_healthy
ports:
- "${FRONTEND_PORT:-8080}:80"
restart: unless-stopped
migrator:
image: papercrate/backend:latest
depends_on:
postgres:
condition: service_healthy
environment:
<<: *app-env
DATABASE_URL: postgres://papercrate:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set}@postgres:5432/papercrate
entrypoint: ["/usr/local/bin/diesel"]
command: ["migration", "run"]
restart: "no" restart: "no"
volumes: volumes:
+95
View File
@@ -0,0 +1,95 @@
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: papercrate
POSTGRES_PASSWORD: papercrate_dev
POSTGRES_DB: papercrate
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./backend/postgres-init:/docker-entrypoint-initdb.d
healthcheck:
test: ["CMD-SHELL", "pg_isready -U papercrate"]
interval: 5s
timeout: 5s
retries: 5
minio:
image: minio/minio:latest
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
ports:
- "9000:9000" # S3 API
- "9001:9001" # Console
volumes:
- minio_data:/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 20s
retries: 3
createbuckets:
image: minio/mc:latest
depends_on:
minio:
condition: service_healthy
entrypoint: >
/bin/sh -c "
/usr/bin/mc alias set myminio http://minio:9000 minioadmin minioadmin;
/usr/bin/mc mb myminio/documents --ignore-existing;
/usr/bin/mc anonymous set download myminio/documents;
exit 0;
"
quickwit:
image: quickwit/quickwit:0.8.2
command: ["run"]
environment:
QW_ENABLE_API_AUTH: "false"
QW_DATA_DIR: /quickwit/data
ports:
- "7280:7280"
volumes:
- quickwit_data:/quickwit/data
healthcheck:
test: ["CMD", "curl", "-sf", "http://127.0.0.1:7280/api/v1/version"]
interval: 10s
timeout: 5s
retries: 5
admin-bootstrap:
build:
context: ./backend
depends_on:
postgres:
condition: service_healthy
quickwit:
condition: service_healthy
environment:
DATABASE_URL: postgres://papercrate:papercrate_dev@postgres:5432/papercrate
DATABASE_MAX_POOL_SIZE: 2
AWS_ENDPOINT_URL: http://minio:9000
AWS_ACCESS_KEY_ID: minioadmin
AWS_SECRET_ACCESS_KEY: minioadmin
AWS_REGION: us-east-1
S3_BUCKET: documents
JWT_SECRET: change-me-super-secret
QUICKWIT_ENDPOINT: http://quickwit:7280
entrypoint: []
command: >
/bin/sh -c "
echo 'Running database migrations' &&
diesel migration run
"
user: root
restart: "no"
volumes:
postgres_data:
minio_data:
quickwit_data:
+92
View File
@@ -0,0 +1,92 @@
# Document Data Model
This note describes the core persistence model for documents: the metadata held in
`documents`, how versions are tracked, and the way auxiliary assets are stored.
## documents
Each row represents the logical document a user interacts with in the UI. Key
fields:
- `id (uuid)` Stable identifier used in API paths.
- `tenant_id (uuid)` Multi-tenancy boundary; all joins filter by this.
- `title (varchar)` Display name editable via PATCH.
- `filename / original_name (varchar)` Current storage filename vs. the name
captured during upload.
- `folder_id (uuid, nullable)` Parent folder, `NULL` means root.
- `metadata (jsonb)` Arbitrary structured metadata (source import details,
custom fields, etc.).
- `issued_at (timestamptz, nullable)` User-provided timestamp for when the
document was issued (invoice date, etc.).
- `current_version_id (uuid)` FK pointing at the active `document_versions`
row; updated whenever a new version is promoted.
- `deleted_at (timestamptz, nullable)` Soft-delete marker; non-NULL rows are
treated as living in the trash.
- `created_at / updated_at (timestamptz)` Audit stamps; `updated_at` reflects
metadata or version changes.
Other indexes enforce per-tenant uniqueness for `(folder, filename)` and support
common queries (folder listing, trash filtering).
## document_versions
Every binary revision lives here. Fields of interest:
- `document_id (uuid)` Back-reference to the logical document.
- `version_number (int)` Monotonic per document (1, 2, …); enforced via
`UNIQUE(document_id, version_number)`.
- `s3_key (varchar)` Object storage path for the binary (used for download).
- `size_bytes`, `checksum` Stored metadata about the binary; checksum is a
hex-encoded SHA-256 hash used for dedupe/conflicts.
- `metadata (jsonb)` Small metadata blob specific to the version (extracted
text summary, processing hints, etc.).
- `tenant_id (uuid)` Mirrors the owning documents tenant.
The row referenced by `documents.current_version_id` is treated as the latest
revision. Older versions remain queryable for download or audit.
## Assets
A document version can have zero or more derived artifacts (thumbnails, OCR
output, previews). These are modelled via:
- `document_assets`
- `document_version_id` FK to the owning version.
- `asset_type (text)` Logical type identifier (e.g. `thumbnail`, `ocr_text`).
- `mime_type (text)` Media type for consumers.
- `metadata (jsonb)` Asset-specific metadata (dimensions, page count, etc.).
- `cardinality (int, nullable)` Optional hint for multi-object assets.
- `tenant_id (uuid)` Tenant scoping.
- Uniqueness on `(document_version_id, asset_type)` ensures one logical asset
per type; multi-object cases are stored in `document_asset_objects`.
- `document_asset_objects`
- `asset_id` FK to `document_assets`.
- `ordinal (int)` 1-based position for multi-part assets.
- `s3_key (text)` Object storage key for the binary blob.
- `metadata (jsonb)` Per-object metadata if needed (e.g. page number).
Simple assets (single thumbnail) live solely in `document_assets`. Complex ones
(e.g. per-page previews) use `document_asset_objects` to point at multiple S3
objects under a single logical asset.
## Related tables
- `document_tags` and `document_correspondents` provide many-to-many
relationships for categorisation.
- `jobs` records background work (OCR, thumbnails, indexing) keyed by tenant.
- `webdav_tokens`, `refresh_tokens`, and `user_passkeys` live alongside but do
not alter the document schema directly.
## Lifecycle summary
1. Upload creates a `documents` row and an initial `document_versions` entry.
2. Workers generate derived assets, inserting rows into `document_assets`
(and possibly `document_asset_objects`).
3. When a new version is promoted, a fresh `document_versions` row is written
and `documents.current_version_id` is updated atomically.
4. Soft-deleting the document sets `deleted_at`; restore clears it and the
document reappears in listings.
This schema allows arbitrary metadata expansion while maintaining a clear
separation between logical documents, their version history, and derived assets.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

+1 -1
View File
@@ -39,7 +39,7 @@ npm run build
- Drag-and-drop moves (documents between folders) and file uploads (window-wide or onto a folder) - Drag-and-drop moves (documents between folders) and file uploads (window-wide or onto a folder)
- Search box plus tag chips filter documents across the selected folder and all descendants - Search box plus tag chips filter documents across the selected folder and all descendants
- Tag management (create/assign/remove) from the detail panel - Tag management (create/assign/remove) from the detail panel
- Login via the seeded admin account (`admin` / `adminadmin`) with stored JWT session - Login with a WebAuthn passkey created through the signup flow (no baked-in demo account)
- Inline status banner for quick feedback on API interactions - Inline status banner for quick feedback on API interactions
## Assets ## Assets
+13 -2
View File
@@ -4,16 +4,25 @@ set -euo pipefail
API_PROXY_PASS_TRIMMED="${API_PROXY_PASS:-}" API_PROXY_PASS_TRIMMED="${API_PROXY_PASS:-}"
API_PROXY_PASS_TRIMMED="${API_PROXY_PASS_TRIMMED%%/}" API_PROXY_PASS_TRIMMED="${API_PROXY_PASS_TRIMMED%%/}"
cat <<'BASE' > /etc/nginx/conf.d/default.conf MAX_BODY_SIZE_RAW="${UPLOAD_BODY_LIMIT_BYTES:-}"
if [ -n "$MAX_BODY_SIZE_RAW" ]; then
MAX_BODY_SIZE=$(printf '%sm' "$((MAX_BODY_SIZE_RAW / (1024 * 1024)))")
else
MAX_BODY_SIZE="128m"
fi
cat <<BASE > /etc/nginx/conf.d/default.conf
server { server {
listen 80; listen 80;
server_name _; server_name _;
client_max_body_size ${MAX_BODY_SIZE};
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
location / { location / {
try_files $uri /index.html; try_files \$uri /index.html;
} }
BASE BASE
@@ -21,6 +30,7 @@ if [ -n "$API_PROXY_PASS_TRIMMED" ]; then
cat <<PROXY >> /etc/nginx/conf.d/default.conf cat <<PROXY >> /etc/nginx/conf.d/default.conf
location /api/ { location /api/ {
client_max_body_size ${MAX_BODY_SIZE};
proxy_pass ${API_PROXY_PASS_TRIMMED}; proxy_pass ${API_PROXY_PASS_TRIMMED};
proxy_set_header Host \$host; proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr; proxy_set_header X-Real-IP \$remote_addr;
@@ -29,6 +39,7 @@ cat <<PROXY >> /etc/nginx/conf.d/default.conf
} }
location /download/ { location /download/ {
client_max_body_size ${MAX_BODY_SIZE};
proxy_pass ${API_PROXY_PASS_TRIMMED}; proxy_pass ${API_PROXY_PASS_TRIMMED};
proxy_set_header Host \$host; proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr; proxy_set_header X-Real-IP \$remote_addr;
+40 -40
View File
@@ -1,12 +1,12 @@
/* Desktop workspace styles */ /* Desktop workspace styles */
.skeuo-main { .desk-main {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 0; min-height: 0;
} }
.skeuo-item { .desk-item {
position: absolute; position: absolute;
display: block; display: block;
width: auto; width: auto;
@@ -18,7 +18,7 @@
will-change: transform; will-change: transform;
} }
.skeuo-shell { .desk-shell {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -27,14 +27,14 @@
position: relative; position: relative;
} }
.skeuo-canvas { .desk-canvas {
flex: 1; flex: 1;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
margin: 0; margin: 0;
} }
.skeuo-empty { .desk-empty {
height: 100%; height: 100%;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -47,32 +47,32 @@
.skeuo-item__body { .desk-item__body {
flex-grow: 1; flex-grow: 1;
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
.skeuo-item:focus-visible { .desk-item:focus-visible {
outline: 2px solid var(--accent); outline: 2px solid var(--accent);
outline-offset: 4px; outline-offset: 4px;
} }
.skeuo-item.is-dragging { .desk-item.is-dragging {
cursor: grabbing; cursor: grabbing;
transition: none; transition: none;
} }
.skeuo-item.is-tag-target .skeuo-item__card { .desk-item.is-tag-target .desk-item__card {
outline: 0.35rem dashed var(--accent); outline: 0.35rem dashed var(--accent);
outline-offset: 0.35rem; outline-offset: 0.35rem;
} }
.skeuo-item.is-tag-pending .skeuo-item__card { .desk-item.is-tag-pending .desk-item__card {
opacity: 0.6; opacity: 0.6;
} }
.skeuo-item.is-filtered-out { .desk-item.is-filtered-out {
opacity: 0.12; opacity: 0.12;
pointer-events: none; pointer-events: none;
filter: blur(15px) grayscale(100%); filter: blur(15px) grayscale(100%);
@@ -80,7 +80,7 @@
z-index: 0 !important; z-index: 0 !important;
} }
.skeuo-item__tags { .desk-item__tags {
--tag-scale: 1; --tag-scale: 1;
position: absolute; position: absolute;
top: 0; top: 0;
@@ -100,7 +100,7 @@
pointer-events: auto; pointer-events: auto;
cursor: grab; cursor: grab;
transition: transform 0.16s ease, opacity 0.2s ease, box-shadow 0.2s ease; transition: transform 0.16s ease, opacity 0.2s ease, box-shadow 0.2s ease;
box-shadow: 2px 2px 4px color-mix(in oklch, black 18%, transparent); box-shadow: 2px 2px 4px var(--shadow-medium);
} }
.tag-chip--draggable:active { .tag-chip--draggable:active {
@@ -112,14 +112,14 @@
pointer-events: none; pointer-events: none;
} }
.skeuo-item__tags .tag-chip { .desk-item__tags .tag-chip {
font-size: 0.85rem; font-size: 0.85rem;
padding: 0.18rem 0.55rem; padding: 0.18rem 0.55rem;
gap: 0.3rem; gap: 0.3rem;
} }
.skeuo-card__nav { .desk-card__nav {
position: absolute; position: absolute;
bottom: 1.8rem; bottom: 1.8rem;
left: 50%; left: 50%;
@@ -132,12 +132,12 @@
transition: opacity 0.2s ease; transition: opacity 0.2s ease;
} }
.skeuo-item__card:hover .skeuo-card__nav { .desk-item__card:hover .desk-card__nav {
opacity: 1; opacity: 1;
pointer-events: auto; pointer-events: auto;
} }
.skeuo-card__nav-button { .desk-card__nav-button {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -146,45 +146,45 @@
padding: 0.25em; padding: 0.25em;
border-radius: 50%; border-radius: 50%;
border: none; border: none;
background: oklch(0.22 0.06 260deg); background: var(--preview-nav-bg);
color: var(--on-accent); color: var(--preview-nav-fg);
cursor: pointer; cursor: pointer;
transition: background 0.15s ease, opacity 0.15s ease; transition: background 0.15s ease, opacity 0.15s ease;
} }
.skeuo-card__nav-button:hover:not([disabled]) { .desk-card__nav-button:hover:not([disabled]) {
background: oklch(0.18 0.06 260deg); background: var(--preview-nav-bg-hover);
} }
.skeuo-card__nav-button:disabled { .desk-card__nav-button:disabled {
opacity: 0.4; opacity: 0.4;
cursor: default; cursor: default;
} }
.skeuo-card__nav-button:focus-visible { .desk-card__nav-button:focus-visible {
outline: 2px solid var(--accent, #2684ff); outline: 2px solid var(--accent);
outline-offset: 2px; outline-offset: 2px;
} }
.skeuo-card__nav-button svg { .desk-card__nav-button svg {
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
.skeuo-item__tags .tag-chip--tear-pending { .desk-item__tags .tag-chip--tear-pending {
opacity: 0.35; opacity: 0.35;
} }
body.skeuo-cursor-remove, body.desk-cursor-remove,
body.skeuo-cursor-remove * { body.desk-cursor-remove * {
cursor: not-allowed !important; cursor: not-allowed !important;
} }
.skeuo-item__shadow { .desk-item__shadow {
display: none; display: none;
} }
.skeuo-item__card { .desk-item__card {
position: relative; position: relative;
border-radius: 0; border-radius: 0;
display: flex; display: flex;
@@ -192,22 +192,22 @@ body.skeuo-cursor-remove * {
justify-content: center; justify-content: center;
width: 100%; width: 100%;
height: 100%; height: 100%;
box-shadow: 0 12px 32px color-mix(in oklch, black 18%, transparent); box-shadow: 0 12px 32px var(--shadow-medium);
overflow: hidden; overflow: hidden;
} }
.skeuo-item__card img { .desk-item__card img {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: contain; object-fit: contain;
display: block; display: block;
} }
.skeuo-item__card--empty { .desk-item__card--empty {
box-shadow: 0 12px 32px var(--shadow-medium);
background: background:
radial-gradient(circle at 28% 24%, color-mix(in oklch, white 32%, transparent), transparent 60%), radial-gradient(circle at 42% 38%, color-mix(in oklch, var(--surface-subtle) 75%, var(--selection) 25%), color-mix(in oklch, var(--surface-subtle) 85%, var(--selection) 15%) 70%),
radial-gradient(circle at 72% 78%, color-mix(in oklch, black 8%, transparent), transparent 65%), linear-gradient(135deg, color-mix(in oklch, var(--surface-subtle) 88%, var(--selection-soft) 12%) 0%, color-mix(in oklch, var(--surface-subtle) 65%, var(--shadow-faint) 35%) 100%);
linear-gradient(135deg, #e6e1d6 0%, #d2cdc2 100%);
width: 100%; width: 100%;
height: 100%; height: 100%;
display: flex; display: flex;
@@ -217,14 +217,14 @@ body.skeuo-cursor-remove * {
gap: 0.5rem; gap: 0.5rem;
} }
.skeuo-item__placeholder { .desk-item__placeholder {
font-size: 0.95rem; font-size: 0.95rem;
font-weight: 500; font-weight: 500;
letter-spacing: normal; letter-spacing: normal;
color: var(--muted); color: var(--muted);
} }
.skeuo-item__empty { .desk-item__empty {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@@ -236,10 +236,10 @@ body.skeuo-cursor-remove * {
text-align: center; text-align: center;
} }
.skeuo-item__title { .desk-item__title {
font-size: 0.95rem; font-size: 0.95rem;
font-weight: 500; font-weight: 500;
color: #3b3b3b; color: var(--fg);
max-width: 90%; max-width: 90%;
overflow: hidden; overflow: hidden;
overflow-wrap: anywhere; overflow-wrap: anywhere;
+80 -73
View File
@@ -8,7 +8,9 @@ import React, {
} from 'react'; } from 'react';
import { resolveDocumentAssetUrl, createAssetView } from './asset_manager'; import { resolveDocumentAssetUrl, createAssetView } from './asset_manager';
import { useAssetNavigator } from './hooks/useAssetNavigator'; import { useAssetNavigator } from './hooks/useAssetNavigator';
import { ArrowLeftIcon, ArrowRightIcon, RefreshIcon } from './ui/icons'; import { ArrowLeftIcon, ArrowRightIcon } from './ui/icons';
import { createDocumentsTableHeaderActions } from './documents/DocumentsTable';
import createWorkspaceSurfaceConfig from './documents/workspaceHeader';
import { clamp, formatTransform } from './desktop/math'; import { clamp, formatTransform } from './desktop/math';
import { preventAll } from './desktop/events'; import { preventAll } from './desktop/events';
import useDocumentDrag from './desktop/useDocumentDrag'; import useDocumentDrag from './desktop/useDocumentDrag';
@@ -23,9 +25,6 @@ const DEFAULT_CANVAS_WIDTH = 1024;
const DEFAULT_CANVAS_HEIGHT = 680; const DEFAULT_CANVAS_HEIGHT = 680;
const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag']; const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag'];
const resolveSizeKey = (doc) =>
doc?.id || doc?.document_id || doc?.uuid || doc?.original_name || doc?.title || 'doc';
const CARD_MIN = 240; const CARD_MIN = 240;
const CARD_MAX = 340; const CARD_MAX = 340;
const TAG_REMOVE_DISTANCE = 160; const TAG_REMOVE_DISTANCE = 160;
@@ -125,7 +124,7 @@ const readTransferData = (dataTransfer, mimeTypes) => {
} }
} catch (error) { } catch (error) {
if (DEBUG_DROP) { if (DEBUG_DROP) {
console.warn('[skeuo] readTransferData failed for type', type, error); console.warn('[desk] readTransferData failed for type', type, error);
} }
} }
} }
@@ -143,7 +142,7 @@ const parseTagTransferPayload = (event) => {
try { try {
return JSON.parse(raw); return JSON.parse(raw);
} catch (error) { } catch (error) {
console.warn('[skeuo] parseTagTransferPayload failed', error); console.warn('[desk] parseTagTransferPayload failed', error);
} }
return null; return null;
}; };
@@ -210,8 +209,8 @@ const DesktopPreviewCard = ({
onNavigatorSnapshot, onNavigatorSnapshot,
]); ]);
const hasPreview = Boolean(currentUrl); const hasPreview = Boolean(currentUrl);
const cardClasses = ['skeuo-item__card']; const cardClasses = ['desk-item__card'];
if (!hasPreview) cardClasses.push('skeuo-item__card--empty'); if (!hasPreview) cardClasses.push('desk-item__card--empty');
const showNav = hasPreview && (cardinality > 1 || canGoPrev || canGoNext); const showNav = hasPreview && (cardinality > 1 || canGoPrev || canGoNext);
return ( return (
@@ -219,18 +218,18 @@ const DesktopPreviewCard = ({
{hasPreview ? ( {hasPreview ? (
<img src={currentUrl} alt={title} /> <img src={currentUrl} alt={title} />
) : ( ) : (
<div className="skeuo-item__empty"> <div className="desk-item__empty">
<div className="skeuo-item__placeholder">DOC</div> <div className="desk-item__placeholder">DOC</div>
<div className="skeuo-item__title" title={title}> <div className="desk-item__title" title={title}>
{title} {title}
</div> </div>
</div> </div>
)} )}
{showNav ? ( {showNav ? (
<div className="skeuo-card__nav"> <div className="desk-card__nav">
<button <button
type="button" type="button"
className="skeuo-card__nav-button" className="desk-card__nav-button"
onClick={(event) => { onClick={(event) => {
preventAll(event); preventAll(event);
navigator.goPrev(); navigator.goPrev();
@@ -254,7 +253,7 @@ const DesktopPreviewCard = ({
</button> </button>
<button <button
type="button" type="button"
className="skeuo-card__nav-button" className="desk-card__nav-button"
onClick={(event) => { onClick={(event) => {
preventAll(event); preventAll(event);
navigator.goNext(); navigator.goNext();
@@ -466,7 +465,7 @@ const usePreviewMetadata = (documents, getDocumentAsset, ensureAssetUrl) => {
metadata = view.getPrimaryMetadata(); metadata = view.getPrimaryMetadata();
} }
} catch (error) { } catch (error) {
console.warn('[skeuo] ensureDocumentSize metadata fetch failed', error); console.warn('[desk] ensureDocumentSize metadata fetch failed', error);
} }
} }
@@ -753,15 +752,15 @@ const DesktopWorkspace = ({
const focusTarget = () => { const focusTarget = () => {
try { try {
if (DEBUG_FOCUS) { if (DEBUG_FOCUS) {
console.log('[skeuo] focusCanvas -> attempting focus', canvas); console.log('[desk] focusCanvas -> attempting focus', canvas);
} }
canvas.focus({ preventScroll: true }); canvas.focus({ preventScroll: true });
if (DEBUG_FOCUS) { if (DEBUG_FOCUS) {
console.log('[skeuo] focusCanvas: applied focus. activeElement:', document?.activeElement); console.log('[desk] focusCanvas: applied focus. activeElement:', document?.activeElement);
} }
} catch (error) { } catch (error) {
if (DEBUG_FOCUS) { if (DEBUG_FOCUS) {
console.warn('[skeuo] focusTarget failed to focus canvas', error); console.warn('[desk] focusTarget failed to focus canvas', error);
} }
} }
}; };
@@ -772,20 +771,20 @@ const DesktopWorkspace = ({
} }
if (DEBUG_FOCUS) { if (DEBUG_FOCUS) {
console.log('[skeuo] requestCanvasFocus -> scheduling deferred focus'); console.log('[desk] requestCanvasFocus -> scheduling deferred focus');
} }
if (typeof window.requestAnimationFrame === 'function') { if (typeof window.requestAnimationFrame === 'function') {
window.requestAnimationFrame(() => { window.requestAnimationFrame(() => {
if (DEBUG_FOCUS) { if (DEBUG_FOCUS) {
console.log('[skeuo] requestCanvasFocus -> executing deferred focus (rAF)'); console.log('[desk] requestCanvasFocus -> executing deferred focus (rAF)');
} }
focusTarget(); focusTarget();
}); });
} else { } else {
setTimeout(() => { setTimeout(() => {
if (DEBUG_FOCUS) { if (DEBUG_FOCUS) {
console.log('[skeuo] requestCanvasFocus -> executing deferred focus (timeout)'); console.log('[desk] requestCanvasFocus -> executing deferred focus (timeout)');
} }
focusTarget(); focusTarget();
}, 0); }, 0);
@@ -805,9 +804,9 @@ const DesktopWorkspace = ({
} }
removalCursorActiveRef.current = active; removalCursorActiveRef.current = active;
if (active) { if (active) {
body.classList.add('skeuo-cursor-remove'); body.classList.add('desk-cursor-remove');
} else { } else {
body.classList.remove('skeuo-cursor-remove'); body.classList.remove('desk-cursor-remove');
} }
}, []); }, []);
@@ -1083,7 +1082,7 @@ const syncLayoutSnapshot = useCallback(() => {
const nodeEnv = typeof globalThis !== 'undefined' ? globalThis.process?.env?.NODE_ENV : undefined; const nodeEnv = typeof globalThis !== 'undefined' ? globalThis.process?.env?.NODE_ENV : undefined;
if (nodeEnv !== 'production') { if (nodeEnv !== 'production') {
console.log('[skeuo] canvas element', container); console.log('[desk] canvas element', container);
} }
const commitSize = () => { const commitSize = () => {
@@ -1170,7 +1169,7 @@ const syncLayoutSnapshot = useCallback(() => {
id: doc.id, id: doc.id,
width: docWidth, width: docWidth,
height: docHeight, height: docHeight,
seedKey: resolveSizeKey(doc), seedKey: doc.id,
}); });
}); });
@@ -1241,7 +1240,7 @@ const syncLayoutSnapshot = useCallback(() => {
return; return;
} }
const container = itemRefs.current.get(docId); const container = itemRefs.current.get(docId);
const imageNode = container?.querySelector?.('.skeuo-item__card img'); const imageNode = container?.querySelector?.('.desk-item__card img');
if (!container || !imageNode) { if (!container || !imageNode) {
return; return;
} }
@@ -1356,24 +1355,24 @@ const syncLayoutSnapshot = useCallback(() => {
async (event, doc) => { async (event, doc) => {
if (!doc || !isTagTransfer(event) || typeof onAssignTagToDocument !== 'function') { if (!doc || !isTagTransfer(event) || typeof onAssignTagToDocument !== 'function') {
if (DEBUG_DROP) { if (DEBUG_DROP) {
console.log('[skeuo] handleTagDropOnDoc: drop ignored', { doc, hasTransfer: isTagTransfer(event) }); console.log('[desk] handleTagDropOnDoc: drop ignored', { doc, hasTransfer: isTagTransfer(event) });
} }
return; return;
} }
preventAll(event); preventAll(event);
if (DEBUG_DROP) { if (DEBUG_DROP) {
console.log('[skeuo] handleTagDropOnDoc: drop accepted for doc', doc.id, 'event', event); console.log('[desk] handleTagDropOnDoc: drop accepted for doc', doc.id, 'event', event);
} }
setTagDropTargetId(null); setTagDropTargetId(null);
const payload = parseTagTransferPayload(event); const payload = parseTagTransferPayload(event);
if (!payload && DEBUG_DROP) { if (!payload && DEBUG_DROP) {
console.warn('[skeuo] handleTagDropOnDoc: failed to parse payload'); console.warn('[desk] handleTagDropOnDoc: failed to parse payload');
} }
if (!payload?.id) { if (!payload?.id) {
if (DEBUG_DROP) { if (DEBUG_DROP) {
console.log('[skeuo] handleTagDropOnDoc: missing tag id payload', payload); console.log('[desk] handleTagDropOnDoc: missing tag id payload', payload);
} }
requestCanvasFocus(); requestCanvasFocus();
return; return;
@@ -1382,13 +1381,13 @@ const syncLayoutSnapshot = useCallback(() => {
const tagId = payload.id; const tagId = payload.id;
const sourceDocId = payload.sourceDocId || null; const sourceDocId = payload.sourceDocId || null;
if (DEBUG_DROP) { if (DEBUG_DROP) {
console.log('[skeuo] handleTagDropOnDoc: parsed payload', { tagId, sourceDocId }); console.log('[desk] handleTagDropOnDoc: parsed payload', { tagId, sourceDocId });
} }
if (sourceDocId && sourceDocId === doc.id) { if (sourceDocId && sourceDocId === doc.id) {
markActiveTagDropHandled(tagId, sourceDocId); markActiveTagDropHandled(tagId, sourceDocId);
if (DEBUG_DROP) { if (DEBUG_DROP) {
console.log('[skeuo] handleTagDropOnDoc: drop from same doc ignored', tagId); console.log('[desk] handleTagDropOnDoc: drop from same doc ignored', tagId);
} }
requestCanvasFocus(); requestCanvasFocus();
return; return;
@@ -1400,7 +1399,7 @@ const syncLayoutSnapshot = useCallback(() => {
if (alreadyAssigned) { if (alreadyAssigned) {
markActiveTagDropHandled(tagId, sourceDocId); markActiveTagDropHandled(tagId, sourceDocId);
if (DEBUG_DROP) { if (DEBUG_DROP) {
console.log('[skeuo] handleTagDropOnDoc: tag already assigned', tagId); console.log('[desk] handleTagDropOnDoc: tag already assigned', tagId);
} }
requestCanvasFocus(); requestCanvasFocus();
return; return;
@@ -1408,7 +1407,7 @@ const syncLayoutSnapshot = useCallback(() => {
const movingBetweenDocuments = Boolean(sourceDocId && sourceDocId !== doc.id); const movingBetweenDocuments = Boolean(sourceDocId && sourceDocId !== doc.id);
if (DEBUG_DROP) { if (DEBUG_DROP) {
console.log('[skeuo] handleTagDropOnDoc: movingBetweenDocuments', movingBetweenDocuments); console.log('[desk] handleTagDropOnDoc: movingBetweenDocuments', movingBetweenDocuments);
} }
setPendingTagDocId(doc.id); setPendingTagDocId(doc.id);
@@ -1416,14 +1415,14 @@ const syncLayoutSnapshot = useCallback(() => {
await onAssignTagToDocument({ documentId: doc.id, tagId, tag: payload }); await onAssignTagToDocument({ documentId: doc.id, tagId, tag: payload });
markActiveTagDropHandled(tagId, sourceDocId); markActiveTagDropHandled(tagId, sourceDocId);
if (DEBUG_DROP) { if (DEBUG_DROP) {
console.log('[skeuo] handleTagDropOnDoc: assigned tag', tagId, 'to doc', doc.id); console.log('[desk] handleTagDropOnDoc: assigned tag', tagId, 'to doc', doc.id);
} }
if (movingBetweenDocuments && typeof onRemoveTagFromDocument === 'function') { if (movingBetweenDocuments && typeof onRemoveTagFromDocument === 'function') {
setPendingRemovalTag({ docId: sourceDocId, tagId }); setPendingRemovalTag({ docId: sourceDocId, tagId });
try { try {
await onRemoveTagFromDocument(sourceDocId, tagId); await onRemoveTagFromDocument(sourceDocId, tagId);
if (DEBUG_DROP) { if (DEBUG_DROP) {
console.log('[skeuo] handleTagDropOnDoc: removed tag from source doc', sourceDocId); console.log('[desk] handleTagDropOnDoc: removed tag from source doc', sourceDocId);
} }
} finally { } finally {
setPendingRemovalTag(null); setPendingRemovalTag(null);
@@ -1432,7 +1431,7 @@ const syncLayoutSnapshot = useCallback(() => {
} finally { } finally {
setPendingTagDocId(null); setPendingTagDocId(null);
if (DEBUG_DROP) { if (DEBUG_DROP) {
console.log('[skeuo] handleTagDropOnDoc: finalizing drop for tag', tagId); console.log('[desk] handleTagDropOnDoc: finalizing drop for tag', tagId);
} }
requestCanvasFocus(); requestCanvasFocus();
} }
@@ -1554,7 +1553,7 @@ const syncLayoutSnapshot = useCallback(() => {
event.dataTransfer.effectAllowed = 'copyMove'; event.dataTransfer.effectAllowed = 'copyMove';
} }
} catch (error) { } catch (error) {
console.warn('[skeuo] Failed to set drag effect', error); console.warn('[desk] Failed to set drag effect', error);
} }
const payload = JSON.stringify({ id: tag.id, label: tag.label, sourceDocId: doc.id }); const payload = JSON.stringify({ id: tag.id, label: tag.label, sourceDocId: doc.id });
@@ -1563,7 +1562,7 @@ const syncLayoutSnapshot = useCallback(() => {
event.dataTransfer?.setData('text/papercrate-tag', payload); event.dataTransfer?.setData('text/papercrate-tag', payload);
event.dataTransfer?.setData('text/plain', tag.label || 'Tag'); event.dataTransfer?.setData('text/plain', tag.label || 'Tag');
} catch (error) { } catch (error) {
console.warn('[skeuo] Failed to populate drag data for tag', error); console.warn('[desk] Failed to populate drag data for tag', error);
} }
const pending = pendingDocTagDragRef.current; const pending = pendingDocTagDragRef.current;
@@ -1594,7 +1593,7 @@ const syncLayoutSnapshot = useCallback(() => {
try { try {
event.dataTransfer.setDragImage(preview.clone, preview.offsetX, preview.offsetY); event.dataTransfer.setDragImage(preview.clone, preview.offsetX, preview.offsetY);
} catch (error) { } catch (error) {
console.warn('[skeuo] Failed to set drag image', error); console.warn('[desk] Failed to set drag image', error);
} }
} }
} }
@@ -1681,7 +1680,7 @@ const syncLayoutSnapshot = useCallback(() => {
}; };
const dropEffect = event?.dataTransfer?.dropEffect || 'none'; const dropEffect = event?.dataTransfer?.dropEffect || 'none';
console.log('[skeuo] dragEnd dropEffect', dropEffect, 'dropHandled', state.dropHandled); console.log('[desk] dragEnd dropEffect', dropEffect, 'dropHandled', state.dropHandled);
const shouldRemove = const shouldRemove =
!state.dropHandled && !state.dropHandled &&
dropEffect === 'none' && dropEffect === 'none' &&
@@ -1690,7 +1689,7 @@ const syncLayoutSnapshot = useCallback(() => {
(state.distance || 0) >= TAG_REMOVE_DISTANCE; (state.distance || 0) >= TAG_REMOVE_DISTANCE;
if (!shouldRemove) { if (!shouldRemove) {
console.log('[skeuo] dragEnd -> no removal. distance:', state.distance); console.log('[desk] dragEnd -> no removal. distance:', state.distance);
scheduleShowNode(); scheduleShowNode();
requestCanvasFocus(); requestCanvasFocus();
updateRemovalCursor(false); updateRemovalCursor(false);
@@ -1703,7 +1702,7 @@ const syncLayoutSnapshot = useCallback(() => {
void (async () => { void (async () => {
try { try {
await onRemoveTagFromDocument(state.sourceDocId, state.tagId); await onRemoveTagFromDocument(state.sourceDocId, state.tagId);
console.log('[skeuo] dragEnd -> removed tag due to fling'); console.log('[desk] dragEnd -> removed tag due to fling');
} catch (error) { } catch (error) {
console.error('Failed to remove tag after drag', error); console.error('Failed to remove tag after drag', error);
scheduleShowNode(); scheduleShowNode();
@@ -1857,20 +1856,20 @@ const DesktopWorkspaceView = () => {
return ( return (
<> <>
<div className="skeuo-shell"> <div className="desk-shell">
<div <div
className="skeuo-canvas" className="desk-canvas"
ref={containerRef} ref={containerRef}
onDragOver={handleCanvasDragOver} onDragOver={handleCanvasDragOver}
onDragLeave={handleCanvasDragLeave} onDragLeave={handleCanvasDragLeave}
onDrop={handleCanvasDrop} onDrop={handleCanvasDrop}
> >
{!allSizesReady ? ( {!allSizesReady ? (
<div className="skeuo-empty"> <div className="desk-empty">
<p>Loading previews</p> <p>Loading previews</p>
</div> </div>
) : items.length === 0 ? ( ) : items.length === 0 ? (
<div className="skeuo-empty"> <div className="desk-empty">
<p>No documents to show here yet. Drop files to make this space come alive.</p> <p>No documents to show here yet. Drop files to make this space come alive.</p>
</div> </div>
) : ( ) : (
@@ -1917,7 +1916,7 @@ const DesktopWorkspaceView = () => {
activeTagSet.size === 0 || docTagKeys.some((key) => activeTagSet.has(key)); activeTagSet.size === 0 || docTagKeys.some((key) => activeTagSet.has(key));
const dropActive = tagDropTargetId === doc.id; const dropActive = tagDropTargetId === doc.id;
const dropPending = pendingTagDocId === doc.id; const dropPending = pendingTagDocId === doc.id;
const itemClasses = ['skeuo-item']; const itemClasses = ['desk-item'];
if (dragging) itemClasses.push('is-dragging'); if (dragging) itemClasses.push('is-dragging');
if (dropActive) itemClasses.push('is-tag-target'); if (dropActive) itemClasses.push('is-tag-target');
if (dropPending) itemClasses.push('is-tag-pending'); if (dropPending) itemClasses.push('is-tag-pending');
@@ -1954,7 +1953,7 @@ const DesktopWorkspaceView = () => {
} }
}} }}
> >
<div className="skeuo-item__body"> <div className="desk-item__body">
<DesktopPreviewCard <DesktopPreviewCard
doc={doc} doc={doc}
title={title} title={title}
@@ -1964,7 +1963,7 @@ const DesktopWorkspaceView = () => {
shouldLoad={shouldLoad} shouldLoad={shouldLoad}
/> />
{tags.length > 0 && ( {tags.length > 0 && (
<div className="skeuo-item__tags" aria-hidden="true"> <div className="desk-item__tags" aria-hidden="true">
{tags.map((tag) => { {tags.map((tag) => {
const key = tag.id || tag.label || String(tag); const key = tag.id || tag.label || String(tag);
if ( if (
@@ -2019,42 +2018,50 @@ const DesktopWorkspaceView = () => {
export default DesktopWorkspace; export default DesktopWorkspace;
export const createDesktopSurface = ({ workspaceProps, renderSidebarToggle }) => { export const createDesktopSurface = ({
workspaceProps,
renderSidebarToggle,
parentBreadcrumb,
onNavigateParent,
}) => {
if (!workspaceProps) { if (!workspaceProps) {
return null; return null;
} }
const { currentFolderName, searchResults, onRefresh, onExit } = workspaceProps; const {
currentFolderName,
searchResults,
onRefresh,
viewMode,
onViewModeChange,
} = workspaceProps;
const title = Array.isArray(searchResults) ? 'Search results' : currentFolderName; const title = Array.isArray(searchResults) ? 'Search results' : currentFolderName;
const subtitle = Array.isArray(searchResults) const subtitle = Array.isArray(searchResults)
? `${searchResults.length} matching document${searchResults.length === 1 ? '' : 's'}` ? `${searchResults.length} matching document${searchResults.length === 1 ? '' : 's'}`
: null; : null;
const actions = createDocumentsTableHeaderActions({
viewMode: viewMode || 'desk',
onViewModeChange,
onRefresh,
});
const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null; const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null;
const leading = sidebarToggle ? <>{sidebarToggle}</> : null; const surfaceConfig = createWorkspaceSurfaceConfig({
const actions = (
<>
<button
type="button"
className="icon-button"
onClick={onRefresh}
aria-label="Refresh"
title="Refresh"
>
<RefreshIcon />
</button>
<button type="button" className="secondary" onClick={onExit}>
Back to List
</button>
</>
);
return {
key: 'workspace', key: 'workspace',
variant: 'workspace', variant: 'workspace',
header: { title, subtitle, leading, actions }, title,
subtitle,
sidebarToggle,
parentBreadcrumb,
onNavigateParent,
actions,
breadcrumbs: workspaceProps?.breadcrumbs || null,
content: <DesktopWorkspace {...workspaceProps} />, content: <DesktopWorkspace {...workspaceProps} />,
});
return {
...surfaceConfig,
supportsDetail: false, supportsDetail: false,
}; };
}; };
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
import React from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import AppLayout from './AppLayout';
import DocumentsRoute from './DocumentsRoute';
import LoginRoute from './LoginRoute';
import SettingsRoute from './SettingsRoute';
const AppRouter = () => (
<Routes>
<Route path="/account/login" element={<LoginRoute />} />
<Route element={<AppLayout />}>
<Route path="/" element={<Navigate to="/documents" replace />} />
<Route path="/documents" element={<DocumentsRoute />} />
<Route path="/documents/folder/:folderId" element={<DocumentsRoute />} />
<Route path="/documents/:documentId" element={<DocumentsRoute />} />
<Route path="/settings" element={<SettingsRoute />} />
<Route path="*" element={<Navigate to="/documents" replace />} />
</Route>
</Routes>
);
export default AppRouter;
+11
View File
@@ -0,0 +1,11 @@
import React from 'react';
import Sidebar from '../sidebar/Sidebar';
const DocumentsLayout = ({ sidebarProps, children, sidebarCollapsed }) => (
<main className={`documents-main${sidebarCollapsed ? ' documents-main--sidebar-collapsed' : ''}`}>
{!sidebarCollapsed ? <Sidebar {...sidebarProps} /> : null}
{children}
</main>
);
export default DocumentsLayout;
+164
View File
@@ -0,0 +1,164 @@
import React, { useCallback, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAppShell } from '../appShellContext';
import DocumentsLayout from './DocumentsLayout';
import { useWorkspaceSurface } from './useWorkspaceSurface';
import PanelHeader from '../ui/PanelHeader';
import BreadcrumbTrail from '../ui/BreadcrumbTrail';
const DocumentsRoute = () => {
const {
sidebarProps,
documentsTableProps,
detailPanelProps,
detailPanelOpen,
documentsViewMode,
deskWorkspaceProps,
openTagsModal,
openCorrespondentsModal,
previewWorkspaceDocument,
previewWorkspaceEntry,
closeDocumentPreview,
handleThumbnailRegeneration,
ensurePreviewData,
resolveApiPath,
ensureAssetUrl,
getDocumentAsset,
notifyApiError,
} = useAppShell();
const navigate = useNavigate();
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const collapseSidebar = useCallback(() => setSidebarCollapsed(true), []);
const expandSidebar = useCallback(() => setSidebarCollapsed(false), []);
const sidebarPropsWithActions = useMemo(
() => ({
...sidebarProps,
onManageTags: openTagsModal,
onManageCorrespondents: openCorrespondentsModal,
onCollapse: collapseSidebar,
}),
[sidebarProps, openTagsModal, openCorrespondentsModal, collapseSidebar],
);
const breadcrumbs = documentsTableProps?.breadcrumbs || null;
const parentBreadcrumb = useMemo(() => {
if (!Array.isArray(breadcrumbs) || breadcrumbs.length <= 1) {
return null;
}
return breadcrumbs[breadcrumbs.length - 2];
}, [breadcrumbs]);
const handleNavigateParent = useCallback(() => {
if (!parentBreadcrumb) {
return;
}
const target = parentBreadcrumb.id === 'root'
? '/documents'
: `/documents/folder/${parentBreadcrumb.id}`;
navigate(target);
}, [navigate, parentBreadcrumb]);
const handleHeaderBreadcrumbClick = useCallback((crumb) => {
if (!crumb || !crumb.id) {
return;
}
const target = crumb.id === 'root' ? '/documents' : `/documents/folder/${crumb.id}`;
navigate(target);
}, [navigate]);
const { surface } = useWorkspaceSurface({
sidebarCollapsed,
onExpandSidebar: expandSidebar,
documentsTableProps,
detailPanelProps,
detailPanelOpen,
viewMode: documentsViewMode,
deskWorkspaceProps,
previewWorkspaceDocument,
previewWorkspaceEntry,
ensureAssetUrl,
ensurePreviewData,
getDocumentAsset,
resolveApiPath,
notifyApiError,
handleThumbnailRegeneration,
closeDocumentPreview,
parentBreadcrumb,
onNavigateParent: handleNavigateParent,
});
if (!surface) {
return (
<DocumentsLayout
sidebarProps={sidebarPropsWithActions}
sidebarCollapsed={sidebarCollapsed}
>
<div className="main-content main-content--documents">
<div className="main-content__body main-content__body--documents" />
</div>
</DocumentsLayout>
);
}
const variant = surface.variant || 'documents';
const mainContentClass = `main-content main-content--${variant}${
surface.detail ? ' main-content--has-detail' : ''
}`;
const bodyClass = `main-content__body main-content__body--${variant}${
surface.detail ? ' main-content__body--has-detail' : ''
}`;
const header = surface.header || null;
let headerTitle = null;
if (header) {
const breadcrumbEntries = Array.isArray(header.breadcrumbs) ? header.breadcrumbs.filter(Boolean) : [];
const lastIndex = breadcrumbEntries.length - 1;
const trailEntries = breadcrumbEntries.length
? breadcrumbEntries.map((crumb, index) => ({
id: crumb.id ?? index,
label: crumb.name ?? crumb.label ?? crumb.title ?? '',
onClick: index < lastIndex ? () => handleHeaderBreadcrumbClick(crumb) : null,
}))
: [{ id: 'current-location', label: header.title }];
headerTitle = (
<h2 className="main-content__title">
<BreadcrumbTrail
entries={trailEntries}
className="main-content__breadcrumbs"
separator="/"
/>
{header.subtitle ? (
<span className="main-content__subtitle">{header.subtitle}</span>
) : null}
</h2>
);
}
return (
<DocumentsLayout
sidebarProps={sidebarPropsWithActions}
sidebarCollapsed={sidebarCollapsed}
>
<div className={mainContentClass}>
{header ? (
<PanelHeader
className="main-content__header"
leading={header.leading}
title={headerTitle}
titleTag="h2"
actions={header.actions}
/>
) : null}
<div className={bodyClass}>{surface.content}</div>
{surface.detail || null}
</div>
</DocumentsLayout>
);
};
export default DocumentsRoute;
+11
View File
@@ -0,0 +1,11 @@
import React from 'react';
const DropOverlay = ({ active, folderName }) => (
<div className={`drop-overlay${active ? ' active' : ''}`}>
<div className="drop-overlay__content">
Drop files to upload to <strong>{folderName || 'this location'}</strong>
</div>
</div>
);
export default DropOverlay;

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