Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c062cc6ba0 | ||
|
|
09492f452f | ||
|
|
b6bc5391cf | ||
|
|
ac91efc421 | ||
|
|
2fba3236ad | ||
|
|
18352caabe | ||
|
|
e9731ec691 | ||
|
|
175387f0d3 | ||
|
|
2d8a4432cc | ||
|
|
c146084f75 | ||
|
|
1fcd265eb9 | ||
|
|
1a1e9a80c3 | ||
|
|
d14d263e8f | ||
|
|
48bed9a9fe | ||
|
|
91dd3289a9 | ||
|
|
b969cae524 | ||
|
|
aaf9fd1bce | ||
|
|
b254c651a7 | ||
|
|
3af656b09f | ||
|
|
321b0d84ae | ||
|
|
6b4a4af5f1 | ||
|
|
faf8fec9c7 | ||
|
|
0a87f33d89 | ||
|
|
eacab2ced6 | ||
|
|
f796932cd8 | ||
|
|
17db827e3a | ||
|
|
722c2f9a5c | ||
|
|
fa43bc749e | ||
|
|
5b5916ff92 | ||
|
|
5ca7dc9678 | ||
|
|
39d1d80383 | ||
|
|
813ce24aeb | ||
|
|
3941ec61d3 | ||
|
|
4a3cb63263 | ||
|
|
5386724e0d | ||
|
|
85f3d90329 | ||
|
|
bb34a47fa8 | ||
|
|
7fae0e44ac | ||
|
|
80d21ed0a1 | ||
|
|
67fd787a14 |
@@ -5,6 +5,7 @@ on:
|
||||
branches:
|
||||
- main
|
||||
- staging
|
||||
- dev
|
||||
tags:
|
||||
- '*'
|
||||
|
||||
@@ -27,13 +28,34 @@ jobs:
|
||||
with:
|
||||
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
|
||||
with:
|
||||
registry: ${{ vars.REGISTRY_URL }}
|
||||
username: ${{ vars.REGISTRY_USER }}
|
||||
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
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
@@ -49,5 +71,7 @@ jobs:
|
||||
push: true
|
||||
provenance: false
|
||||
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 }}
|
||||
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 }}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -1,73 +1,42 @@
|
||||
# Papercrate
|
||||
|
||||
## Local Development
|
||||

|
||||
|
||||
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
|
||||
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
|
||||
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 (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.
|
||||
For development workflows (local stack, integration tests, migrations, and
|
||||
configuration details) see [DEVELOPMENT.md](./DEVELOPMENT.md).
|
||||
|
||||
Generated
+176
-52
@@ -41,6 +41,56 @@ dependencies = [
|
||||
"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]]
|
||||
name = "anyhow"
|
||||
version = "1.0.100"
|
||||
@@ -632,58 +682,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "backtrace"
|
||||
version = "0.3.76"
|
||||
@@ -886,6 +884,46 @@ dependencies = [
|
||||
"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]]
|
||||
name = "cmake"
|
||||
version = "0.1.54"
|
||||
@@ -895,6 +933,12 @@ dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
|
||||
|
||||
[[package]]
|
||||
name = "console_error_panic_hook"
|
||||
version = "0.1.7"
|
||||
@@ -1961,6 +2005,12 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.13.0"
|
||||
@@ -2280,6 +2330,12 @@ version = "1.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.74"
|
||||
@@ -2350,6 +2406,59 @@ dependencies = [
|
||||
"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]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
@@ -3781,6 +3890,12 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "utf8parse"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "utoipa"
|
||||
version = "4.2.3"
|
||||
@@ -4143,6 +4258,15 @@ dependencies = [
|
||||
"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]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
|
||||
+21
-1
@@ -1,5 +1,5 @@
|
||||
[package]
|
||||
name = "backend"
|
||||
name = "papercrate"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
@@ -48,6 +48,7 @@ futures-util = "0.3"
|
||||
url = "2.5"
|
||||
once_cell = "1.19"
|
||||
utoipa = { version = "4.2", default-features = false, features = ["chrono", "uuid", "preserve_order"] }
|
||||
clap = { version = "4.5", features = ["derive"] }
|
||||
|
||||
# Error handling
|
||||
thiserror = "1.0"
|
||||
@@ -68,3 +69,22 @@ once_cell = "1.19"
|
||||
hyper = "1.2"
|
||||
http-body-util = "0.1"
|
||||
webauthn-rs-core = "0.5"
|
||||
|
||||
[[bin]]
|
||||
name = "backend"
|
||||
path = "src/main.rs"
|
||||
[[bin]]
|
||||
name = "worker"
|
||||
path = "src/bin/worker.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "webdav"
|
||||
path = "src/bin/webdav.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "admin"
|
||||
path = "src/bin/admin.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "openapi-dump"
|
||||
path = "src/bin/openapi_dump.rs"
|
||||
|
||||
+5
-1
@@ -1,8 +1,10 @@
|
||||
DROP TRIGGER IF EXISTS trg_jobs_updated_at ON jobs;
|
||||
DROP FUNCTION IF EXISTS touch_jobs_updated_at();
|
||||
|
||||
DROP TABLE IF EXISTS webauthn_challenges;
|
||||
DROP TABLE IF EXISTS user_passkeys;
|
||||
DROP TABLE IF EXISTS webdav_tokens;
|
||||
ALTER TABLE documents DROP CONSTRAINT IF EXISTS documents_current_version_fk;
|
||||
|
||||
DROP TABLE IF EXISTS document_asset_objects;
|
||||
DROP TABLE IF EXISTS document_assets;
|
||||
DROP TABLE IF EXISTS document_versions;
|
||||
@@ -18,4 +20,6 @@ DROP TABLE IF EXISTS user_memberships;
|
||||
DROP TABLE IF EXISTS users;
|
||||
DROP TABLE IF EXISTS tenants;
|
||||
|
||||
DROP TYPE IF EXISTS tenant_status;
|
||||
|
||||
DROP EXTENSION IF EXISTS "pgcrypto";
|
||||
+96
-45
@@ -1,14 +1,17 @@
|
||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||
|
||||
CREATE TYPE tenant_status AS ENUM ('creating', 'active', 'suspended', 'deleting', 'error');
|
||||
|
||||
CREATE TABLE tenants (
|
||||
id UUID PRIMARY KEY,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
storage_root TEXT,
|
||||
quickwit_index TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
config JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
status tenant_status NOT NULL,
|
||||
created_by UUID
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX tenants_storage_root_unique
|
||||
@@ -22,7 +25,6 @@ CREATE UNIQUE INDEX tenants_quickwit_index_unique
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY,
|
||||
username VARCHAR(100) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
@@ -31,14 +33,13 @@ CREATE TABLE user_memberships (
|
||||
id UUID PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (user_id, tenant_id)
|
||||
);
|
||||
|
||||
CREATE INDEX user_memberships_tenant_id_idx ON user_memberships (tenant_id);
|
||||
CREATE INDEX user_memberships_user_id_idx ON user_memberships (user_id);
|
||||
CREATE INDEX user_memberships_tenant_id_idx ON user_memberships(tenant_id);
|
||||
CREATE INDEX user_memberships_user_id_idx ON user_memberships(user_id);
|
||||
|
||||
CREATE TABLE folders (
|
||||
id UUID PRIMARY KEY,
|
||||
@@ -51,8 +52,12 @@ CREATE TABLE folders (
|
||||
|
||||
CREATE INDEX idx_folders_parent ON folders(parent_id);
|
||||
CREATE INDEX folders_tenant_id_idx ON folders(tenant_id);
|
||||
CREATE UNIQUE INDEX folders_parent_name_unique_idx
|
||||
ON folders (COALESCE(parent_id, '00000000-0000-0000-0000-000000000000'::uuid), name);
|
||||
CREATE UNIQUE INDEX folders_tenant_parent_name_unique_idx
|
||||
ON folders (
|
||||
tenant_id,
|
||||
COALESCE(parent_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
name
|
||||
);
|
||||
|
||||
CREATE TABLE documents (
|
||||
id UUID PRIMARY KEY,
|
||||
@@ -70,11 +75,10 @@ CREATE TABLE documents (
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_documents_folder ON documents (folder_id);
|
||||
CREATE INDEX idx_documents_deleted_at ON documents (deleted_at);
|
||||
CREATE INDEX documents_tenant_id_idx ON documents (tenant_id);
|
||||
CREATE INDEX idx_documents_current_version_id ON documents (current_version_id);
|
||||
|
||||
CREATE INDEX idx_documents_folder ON documents(folder_id);
|
||||
CREATE INDEX idx_documents_deleted_at ON documents(deleted_at);
|
||||
CREATE INDEX documents_tenant_id_idx ON documents(tenant_id);
|
||||
CREATE INDEX idx_documents_current_version_id ON documents(current_version_id);
|
||||
CREATE INDEX idx_documents_folder_title
|
||||
ON documents (
|
||||
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
@@ -82,8 +86,9 @@ CREATE INDEX idx_documents_folder_title
|
||||
)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE UNIQUE INDEX 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
|
||||
)
|
||||
@@ -97,14 +102,13 @@ CREATE TABLE document_versions (
|
||||
size_bytes BIGINT NOT NULL,
|
||||
checksum VARCHAR(64) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
operations_summary JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
||||
CONSTRAINT document_versions_unique_version UNIQUE (document_id, version_number)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_document_versions_document ON document_versions (document_id);
|
||||
CREATE INDEX document_versions_tenant_id_idx ON document_versions (tenant_id);
|
||||
CREATE INDEX idx_document_versions_document ON document_versions(document_id);
|
||||
CREATE INDEX document_versions_tenant_id_idx ON document_versions(tenant_id);
|
||||
|
||||
ALTER TABLE documents
|
||||
ADD CONSTRAINT documents_current_version_fk
|
||||
@@ -114,13 +118,14 @@ ALTER TABLE documents
|
||||
|
||||
CREATE TABLE tags (
|
||||
id UUID PRIMARY KEY,
|
||||
label VARCHAR(100) NOT NULL UNIQUE,
|
||||
label VARCHAR(100) NOT NULL,
|
||||
color VARCHAR(7),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id)
|
||||
);
|
||||
|
||||
CREATE INDEX tags_tenant_id_idx ON tags (tenant_id);
|
||||
CREATE UNIQUE INDEX tags_tenant_label_unique ON tags(tenant_id, label);
|
||||
CREATE INDEX tags_tenant_id_idx ON tags(tenant_id);
|
||||
|
||||
CREATE TABLE document_tags (
|
||||
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
@@ -131,8 +136,8 @@ CREATE TABLE document_tags (
|
||||
PRIMARY KEY (document_id, tag_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_document_tags_tag ON document_tags (tag_id);
|
||||
CREATE INDEX document_tags_tenant_id_idx ON document_tags (tenant_id);
|
||||
CREATE INDEX idx_document_tags_tag ON document_tags(tag_id);
|
||||
CREATE INDEX document_tags_tenant_id_idx ON document_tags(tenant_id);
|
||||
|
||||
CREATE TABLE correspondents (
|
||||
id UUID PRIMARY KEY,
|
||||
@@ -140,27 +145,25 @@ CREATE TABLE correspondents (
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
||||
CONSTRAINT correspondents_name_unique UNIQUE (name)
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id)
|
||||
);
|
||||
|
||||
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 (
|
||||
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
correspondent_id UUID NOT NULL REFERENCES correspondents(id) ON DELETE CASCADE,
|
||||
role VARCHAR(32) NOT NULL,
|
||||
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
assigned_by UUID REFERENCES users(id),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
||||
PRIMARY KEY (document_id, correspondent_id, role),
|
||||
CONSTRAINT document_correspondents_role_check CHECK (role IN ('sender', 'receiver', 'other'))
|
||||
PRIMARY KEY (document_id, correspondent_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_document_correspondents_document ON document_correspondents (document_id);
|
||||
CREATE INDEX idx_document_correspondents_correspondent ON document_correspondents (correspondent_id);
|
||||
CREATE INDEX idx_document_correspondents_role ON document_correspondents (role);
|
||||
CREATE INDEX document_correspondents_tenant_id_idx ON document_correspondents (tenant_id);
|
||||
CREATE INDEX idx_document_correspondents_document ON document_correspondents(document_id);
|
||||
CREATE INDEX idx_document_correspondents_correspondent ON document_correspondents(correspondent_id);
|
||||
CREATE INDEX document_correspondents_tenant_id_idx ON document_correspondents(tenant_id);
|
||||
|
||||
CREATE TABLE document_assets (
|
||||
id UUID PRIMARY KEY,
|
||||
@@ -175,9 +178,9 @@ CREATE TABLE document_assets (
|
||||
CONSTRAINT document_assets_cardinality_positive CHECK (cardinality IS NULL OR cardinality >= 1)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_document_assets_version ON document_assets (document_version_id);
|
||||
CREATE INDEX idx_document_assets_type ON document_assets (asset_type);
|
||||
CREATE INDEX document_assets_tenant_id_idx ON document_assets (tenant_id);
|
||||
CREATE INDEX idx_document_assets_version ON document_assets(document_version_id);
|
||||
CREATE INDEX idx_document_assets_type ON document_assets(asset_type);
|
||||
CREATE INDEX document_assets_tenant_id_idx ON document_assets(tenant_id);
|
||||
|
||||
CREATE TABLE document_asset_objects (
|
||||
id UUID PRIMARY KEY,
|
||||
@@ -191,10 +194,8 @@ CREATE TABLE document_asset_objects (
|
||||
);
|
||||
|
||||
CREATE INDEX idx_document_asset_objects_asset_ordinal
|
||||
ON document_asset_objects (asset_id, ordinal);
|
||||
|
||||
CREATE INDEX document_asset_objects_tenant_id_idx
|
||||
ON document_asset_objects (tenant_id);
|
||||
ON document_asset_objects(asset_id, ordinal);
|
||||
CREATE INDEX document_asset_objects_tenant_id_idx ON document_asset_objects(tenant_id);
|
||||
|
||||
CREATE TABLE jobs (
|
||||
id UUID PRIMARY KEY,
|
||||
@@ -210,9 +211,9 @@ CREATE TABLE jobs (
|
||||
CONSTRAINT jobs_status_check CHECK (status IN ('queued', 'processing', 'succeeded', 'failed'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_jobs_status_run_after ON jobs (status, run_after);
|
||||
CREATE INDEX idx_jobs_job_type ON jobs (job_type);
|
||||
CREATE INDEX jobs_tenant_id_idx ON jobs (tenant_id);
|
||||
CREATE INDEX idx_jobs_status_run_after ON jobs(status, run_after);
|
||||
CREATE INDEX idx_jobs_job_type ON jobs(job_type);
|
||||
CREATE INDEX jobs_tenant_id_idx ON jobs(tenant_id);
|
||||
|
||||
CREATE OR REPLACE FUNCTION touch_jobs_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
@@ -239,6 +240,56 @@ CREATE TABLE refresh_tokens (
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens (user_id);
|
||||
CREATE INDEX idx_refresh_tokens_token_hash ON refresh_tokens (token_hash);
|
||||
CREATE INDEX refresh_tokens_tenant_id_idx ON refresh_tokens (tenant_id);
|
||||
CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id);
|
||||
CREATE INDEX idx_refresh_tokens_token_hash ON refresh_tokens(token_hash);
|
||||
CREATE INDEX refresh_tokens_tenant_id_idx ON refresh_tokens(tenant_id);
|
||||
|
||||
CREATE TABLE webdav_tokens (
|
||||
id UUID PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
token_prefix TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL,
|
||||
label TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_used_at TIMESTAMPTZ,
|
||||
expires_at TIMESTAMPTZ,
|
||||
revoked_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX webdav_tokens_token_prefix_key ON webdav_tokens(token_prefix);
|
||||
CREATE INDEX webdav_tokens_user_tenant_idx ON webdav_tokens(user_id, tenant_id);
|
||||
|
||||
CREATE TABLE user_passkeys (
|
||||
id UUID PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
credential_id BYTEA NOT NULL UNIQUE,
|
||||
public_key BYTEA NOT NULL,
|
||||
credential JSONB NOT NULL,
|
||||
sign_count BIGINT NOT NULL,
|
||||
transports TEXT[] NOT NULL DEFAULT '{}'::text[],
|
||||
aaguid UUID,
|
||||
nickname TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_used_at TIMESTAMPTZ,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
revoked_by UUID,
|
||||
revoked_reason TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX user_passkeys_user_id_idx ON user_passkeys(user_id);
|
||||
|
||||
CREATE TABLE webauthn_challenges (
|
||||
id UUID PRIMARY KEY,
|
||||
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||
purpose TEXT NOT NULL,
|
||||
challenge BYTEA NOT NULL,
|
||||
state BYTEA NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
CONSTRAINT webauthn_challenges_purpose_check CHECK (purpose IN ('registration', 'authentication'))
|
||||
);
|
||||
|
||||
CREATE INDEX webauthn_challenges_user_id_idx ON webauthn_challenges(user_id);
|
||||
CREATE INDEX webauthn_challenges_expires_at_idx ON webauthn_challenges(expires_at);
|
||||
@@ -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;
|
||||
@@ -7,6 +7,7 @@ use axum::{async_trait, extract::FromRequestParts, http::request::Parts};
|
||||
use axum_extra::headers::{authorization::Bearer, Authorization};
|
||||
use axum_extra::TypedHeader;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::{
|
||||
error::AppError,
|
||||
@@ -14,7 +15,7 @@ use crate::{
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct AuthenticatedUser {
|
||||
pub user_id: uuid::Uuid,
|
||||
pub username: String,
|
||||
|
||||
+82
-160
@@ -56,6 +56,7 @@ impl PreparedPasskey {
|
||||
pub struct RegistrationChallengeResponse {
|
||||
pub challenge_id: Uuid,
|
||||
#[serde(flatten)]
|
||||
#[schema(value_type = Object)]
|
||||
pub challenge: CreationChallengeResponse,
|
||||
}
|
||||
|
||||
@@ -64,6 +65,7 @@ pub struct RegistrationChallengeResponse {
|
||||
pub struct AuthenticationChallengeResponse {
|
||||
pub challenge_id: Uuid,
|
||||
#[serde(flatten)]
|
||||
#[schema(value_type = Object)]
|
||||
pub challenge: RequestChallengeResponse,
|
||||
}
|
||||
|
||||
@@ -116,13 +118,55 @@ impl PasskeyService {
|
||||
.execute(conn);
|
||||
}
|
||||
|
||||
fn begin_registration(
|
||||
&self,
|
||||
conn: &mut PgConnection,
|
||||
user_id: Uuid,
|
||||
username: &str,
|
||||
challenge_user_id: Option<Uuid>,
|
||||
exclude: Option<Vec<CredentialID>>,
|
||||
) -> AppResult<RegistrationChallengeResponse> {
|
||||
self.prune_expired(conn);
|
||||
|
||||
let (challenge, state) = self
|
||||
.webauthn
|
||||
.start_passkey_registration(user_id, username, username, exclude)
|
||||
.map_err(|err| {
|
||||
tracing::error!(error = %err, "failed to start passkey registration");
|
||||
AppError::internal("failed to start passkey registration")
|
||||
})?;
|
||||
|
||||
let challenge_id = Uuid::new_v4();
|
||||
let expires_at = (Utc::now() + self.challenge_ttl).naive_utc();
|
||||
let challenge_bytes: Vec<u8> = challenge.public_key.challenge.clone().into();
|
||||
let state_bytes = serde_json::to_vec(&state)
|
||||
.context("failed to encode passkey registration state")
|
||||
.map_err(AppError::internal)?;
|
||||
|
||||
let record = NewWebauthnChallenge {
|
||||
id: challenge_id,
|
||||
user_id: challenge_user_id,
|
||||
purpose: PURPOSE_REGISTRATION.to_string(),
|
||||
challenge: challenge_bytes,
|
||||
state: state_bytes,
|
||||
expires_at,
|
||||
};
|
||||
|
||||
diesel::insert_into(challenge_dsl::webauthn_challenges)
|
||||
.values(&record)
|
||||
.execute(conn)?;
|
||||
|
||||
Ok(RegistrationChallengeResponse {
|
||||
challenge_id,
|
||||
challenge,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn start_registration(
|
||||
&self,
|
||||
conn: &mut PgConnection,
|
||||
user: &User,
|
||||
) -> AppResult<RegistrationChallengeResponse> {
|
||||
self.prune_expired(conn);
|
||||
|
||||
let existing: Vec<UserPasskey> = passkey_dsl::user_passkeys
|
||||
.filter(passkey_dsl::user_id.eq(user.id))
|
||||
.filter(passkey_dsl::revoked_at.is_null())
|
||||
@@ -139,38 +183,7 @@ impl PasskeyService {
|
||||
)
|
||||
};
|
||||
|
||||
let (challenge, state) = self
|
||||
.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,
|
||||
})
|
||||
self.begin_registration(conn, user.id, &user.username, Some(user.id), exclude)
|
||||
}
|
||||
|
||||
pub fn start_signup_registration(
|
||||
@@ -179,50 +192,16 @@ impl PasskeyService {
|
||||
user_id: Uuid,
|
||||
username: &str,
|
||||
) -> AppResult<RegistrationChallengeResponse> {
|
||||
self.prune_expired(conn);
|
||||
|
||||
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,
|
||||
})
|
||||
self.begin_registration(conn, user_id, username, None, None)
|
||||
}
|
||||
|
||||
pub fn finish_registration(
|
||||
fn complete_registration(
|
||||
&self,
|
||||
conn: &mut PgConnection,
|
||||
user: &User,
|
||||
challenge_id: Uuid,
|
||||
credential: RegisterPublicKeyCredential,
|
||||
nickname: Option<String>,
|
||||
) -> AppResult<UserPasskey> {
|
||||
credential: &RegisterPublicKeyCredential,
|
||||
expected_user: Option<Uuid>,
|
||||
) -> AppResult<PreparedPasskey> {
|
||||
let record: WebauthnChallenge = challenge_dsl::webauthn_challenges
|
||||
.find(challenge_id)
|
||||
.first(conn)
|
||||
@@ -238,8 +217,14 @@ impl PasskeyService {
|
||||
return Err(AppError::bad_request("challenge is not for registration"));
|
||||
}
|
||||
|
||||
if record.user_id != Some(user.id) {
|
||||
return Err(AppError::unauthorized());
|
||||
if let Some(expected) = expected_user {
|
||||
if record.user_id != Some(expected) {
|
||||
return Err(AppError::unauthorized());
|
||||
}
|
||||
} else if record.user_id.is_some() {
|
||||
return Err(AppError::bad_request(
|
||||
"unexpected user context for signup registration",
|
||||
));
|
||||
}
|
||||
|
||||
if record.expires_at < Utc::now().naive_utc() {
|
||||
@@ -253,7 +238,7 @@ impl PasskeyService {
|
||||
|
||||
let passkey = self
|
||||
.webauthn
|
||||
.finish_passkey_registration(&credential, &state)
|
||||
.finish_passkey_registration(credential, &state)
|
||||
.map_err(|err| {
|
||||
tracing::warn!(error = %err, "passkey registration validation failed");
|
||||
AppError::bad_request("invalid passkey attestation")
|
||||
@@ -294,24 +279,36 @@ impl PasskeyService {
|
||||
.context("failed to serialise passkey")
|
||||
.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(),
|
||||
user_id: user.id,
|
||||
credential_id: credential_id_vec,
|
||||
public_key: public_key_bytes,
|
||||
credential: credential_json,
|
||||
sign_count: credential_struct.counter as i64,
|
||||
transports,
|
||||
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)
|
||||
.values(&new_passkey)
|
||||
.execute(conn)?;
|
||||
|
||||
diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?;
|
||||
|
||||
let created: UserPasskey = passkey_dsl::user_passkeys
|
||||
.find(new_passkey.id)
|
||||
.select(UserPasskey::as_select())
|
||||
@@ -408,84 +405,7 @@ impl PasskeyService {
|
||||
challenge_id: Uuid,
|
||||
credential: &RegisterPublicKeyCredential,
|
||||
) -> AppResult<PreparedPasskey> {
|
||||
let record: WebauthnChallenge = challenge_dsl::webauthn_challenges
|
||||
.find(challenge_id)
|
||||
.first(conn)
|
||||
.map_err(|err| {
|
||||
if matches!(err, diesel::result::Error::NotFound) {
|
||||
AppError::bad_request("challenge not found")
|
||||
} else {
|
||||
AppError::from(err)
|
||||
}
|
||||
})?;
|
||||
|
||||
if record.purpose != PURPOSE_REGISTRATION {
|
||||
return Err(AppError::bad_request("challenge is not for registration"));
|
||||
}
|
||||
|
||||
if 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,
|
||||
})
|
||||
self.complete_registration(conn, challenge_id, credential, None)
|
||||
}
|
||||
|
||||
pub fn revoke_passkey(
|
||||
@@ -651,6 +571,7 @@ impl From<UserPasskey> for PasskeySummary {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PasskeyRegistrationFinishPayload {
|
||||
pub challenge_id: Uuid,
|
||||
#[schema(value_type = Object)]
|
||||
pub credential: RegisterPublicKeyCredential,
|
||||
#[serde(default)]
|
||||
pub nickname: Option<String>,
|
||||
@@ -666,5 +587,6 @@ pub struct PasskeyLoginStartPayload {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PasskeyLoginFinishPayload {
|
||||
pub challenge_id: Uuid,
|
||||
#[schema(value_type = Object)]
|
||||
pub credential: PublicKeyCredential,
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ use crate::{
|
||||
models::{NewWebdavToken, WebdavToken},
|
||||
schema::webdav_tokens,
|
||||
state::PgPooledConnection,
|
||||
tenants::{apply_webdav_token_prefix, clear_webdav_token_prefix},
|
||||
};
|
||||
|
||||
const TOKEN_PREFIX_LENGTH: usize = 12;
|
||||
@@ -73,6 +74,51 @@ pub fn list_webdav_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(
|
||||
conn: &mut PgPooledConnection,
|
||||
user_id: Uuid,
|
||||
@@ -84,6 +130,7 @@ pub fn find_active_token_by_secret(
|
||||
}
|
||||
|
||||
let prefix = &secret[..TOKEN_PREFIX_LENGTH];
|
||||
apply_webdav_token_prefix(conn, prefix)?;
|
||||
let mut query = webdav_tokens::table
|
||||
.filter(webdav_tokens::user_id.eq(user_id))
|
||||
.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));
|
||||
}
|
||||
|
||||
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 {
|
||||
if verify_token_secret(secret, &token.token_hash)? {
|
||||
|
||||
+130
-72
@@ -1,29 +1,45 @@
|
||||
use std::env;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use chrono::{Duration as ChronoDuration, Utc};
|
||||
use clap::{Parser, Subcommand, ValueEnum};
|
||||
use diesel::{dsl::exists, prelude::*, select};
|
||||
use rand::{rngs::OsRng, RngCore};
|
||||
use reqwest::{Client, Method, StatusCode};
|
||||
use sha2::{Digest, Sha256};
|
||||
use uuid::Uuid;
|
||||
|
||||
use backend::{
|
||||
use papercrate::{
|
||||
config::AppConfig,
|
||||
db::{self, PgPool},
|
||||
documents::search::ensure_quickwit_index,
|
||||
jobs::{enqueue_job, JOB_ANALYZE_DOCUMENT},
|
||||
models::{
|
||||
DocumentAsset, DocumentAssetObject, NewUser, NewUserMembership, Tenant, TenantStatus, User,
|
||||
DocumentAsset, DocumentAssetObject, MagicToken, MagicTokenKind, NewUser, NewUserMembership,
|
||||
Tenant, TenantStatus, User,
|
||||
},
|
||||
s3,
|
||||
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},
|
||||
tenants::TenantService,
|
||||
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 {
|
||||
CreateUser {
|
||||
username: String,
|
||||
@@ -34,7 +50,9 @@ enum Command {
|
||||
},
|
||||
CreateTenant {
|
||||
name: String,
|
||||
#[arg(long = "storage-root")]
|
||||
storage_root: Option<String>,
|
||||
#[arg(long = "quickwit-index")]
|
||||
quickwit_index: Option<String>,
|
||||
},
|
||||
DeleteTenant {
|
||||
@@ -52,71 +70,43 @@ enum Command {
|
||||
tenant_id: Uuid,
|
||||
},
|
||||
ListTenants,
|
||||
DeleteAssets(Uuid),
|
||||
QuickwitCreate(Uuid),
|
||||
QuickwitDelete(Uuid),
|
||||
DeleteAssets {
|
||||
tenant_id: Uuid,
|
||||
},
|
||||
QuickwitCreate {
|
||||
tenant_id: Uuid,
|
||||
},
|
||||
QuickwitDelete {
|
||||
tenant_id: Uuid,
|
||||
},
|
||||
MagicToken {
|
||||
username: String,
|
||||
#[arg(long = "ttl-minutes", default_value_t = 10)]
|
||||
ttl_minutes: i64,
|
||||
#[arg(
|
||||
long = "max-uses",
|
||||
value_name = "MAX_USES",
|
||||
help = "Maximum number of uses before the token is rejected (default: unlimited)"
|
||||
)]
|
||||
max_uses: Option<i32>,
|
||||
#[arg(long = "kind", value_enum, default_value_t = MagicTokenKindArg::EmailLogin)]
|
||||
kind: MagicTokenKindArg,
|
||||
},
|
||||
}
|
||||
|
||||
impl Command {
|
||||
fn usage() -> &'static str {
|
||||
"Usage: admin\n\
|
||||
create-user <username>\n\
|
||||
list-users\n\
|
||||
delete-user <username>\n\
|
||||
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>"
|
||||
}
|
||||
#[derive(Copy, Clone, Debug, ValueEnum)]
|
||||
enum MagicTokenKindArg {
|
||||
#[value(name = "email_login")]
|
||||
EmailLogin,
|
||||
#[value(name = "demo_login")]
|
||||
DemoLogin,
|
||||
}
|
||||
|
||||
fn parse_tenant_id(arg: Option<String>) -> Result<Uuid> {
|
||||
let raw = arg.ok_or_else(|| anyhow!("tenant id required"))?;
|
||||
Uuid::parse_str(&raw).map_err(|_| anyhow!("invalid tenant id: {}", raw))
|
||||
}
|
||||
|
||||
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())),
|
||||
impl From<MagicTokenKindArg> for MagicTokenKind {
|
||||
fn from(value: MagicTokenKindArg) -> Self {
|
||||
match value {
|
||||
MagicTokenKindArg::EmailLogin => MagicTokenKind::EmailLogin,
|
||||
MagicTokenKindArg::DemoLogin => MagicTokenKind::DemoLogin,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -124,11 +114,11 @@ impl Command {
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
init_tracing("info");
|
||||
let command = Command::parse()?;
|
||||
let cli = Cli::parse();
|
||||
let config = AppConfig::load_and_log("admin")?;
|
||||
let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?;
|
||||
|
||||
match command {
|
||||
match cli.command {
|
||||
Command::CreateUser { username } => create_user(&pool, &username)?,
|
||||
Command::ListUsers => list_users(&pool)?,
|
||||
Command::DeleteUser { username } => delete_user(&pool, &username)?,
|
||||
@@ -148,15 +138,23 @@ async fn main() -> Result<()> {
|
||||
} => remove_user_from_tenant(&pool, &username, tenant_id)?,
|
||||
Command::ReanalyzeDocuments { tenant_id } => reanalyze_documents(&pool, tenant_id)?,
|
||||
Command::ListTenants => list_tenants(&pool)?,
|
||||
Command::DeleteAssets(tenant_id) => {
|
||||
Command::DeleteAssets { tenant_id } => {
|
||||
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?
|
||||
}
|
||||
Command::QuickwitDelete(tenant_id) => {
|
||||
Command::QuickwitDelete { tenant_id } => {
|
||||
quickwit_index(&config, &pool, tenant_id, Method::DELETE).await?
|
||||
}
|
||||
Command::MagicToken {
|
||||
username,
|
||||
ttl_minutes,
|
||||
max_uses,
|
||||
kind,
|
||||
} => {
|
||||
create_magic_token(&pool, &username, ttl_minutes, max_uses, kind.into())?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -268,6 +266,66 @@ fn create_tenant(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_magic_token(
|
||||
pool: &PgPool,
|
||||
username: &str,
|
||||
ttl_minutes: i64,
|
||||
max_uses: Option<i32>,
|
||||
kind: MagicTokenKind,
|
||||
) -> Result<()> {
|
||||
let mut conn = pool.get().context("failed to get database connection")?;
|
||||
|
||||
let user: User = users::table
|
||||
.filter(users::username.eq(username))
|
||||
.first(&mut conn)
|
||||
.with_context(|| format!("user '{}' not found", username))?;
|
||||
|
||||
let raw_token = generate_random_token();
|
||||
let token_hash = hash_token(&raw_token);
|
||||
let expires_at = Utc::now() + ChronoDuration::minutes(ttl_minutes);
|
||||
|
||||
let new_token = MagicToken {
|
||||
id: Uuid::new_v4(),
|
||||
user_id: user.id,
|
||||
kind,
|
||||
token_hash,
|
||||
metadata: serde_json::json!({}),
|
||||
expires_at: expires_at.naive_utc(),
|
||||
max_uses,
|
||||
used_count: 0,
|
||||
created_at: Utc::now().naive_utc(),
|
||||
created_by: None,
|
||||
last_used_at: None,
|
||||
};
|
||||
|
||||
diesel::insert_into(magic_tokens::table)
|
||||
.values(&new_token)
|
||||
.execute(&mut conn)?;
|
||||
|
||||
println!(
|
||||
"Magic token created for '{}' (kind: {}, expires_at: {}, max_uses: {})",
|
||||
username,
|
||||
kind.as_str(),
|
||||
expires_at,
|
||||
max_uses.map_or("∞".to_string(), |v| v.to_string())
|
||||
);
|
||||
println!("Token: {}", raw_token);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generate_random_token() -> String {
|
||||
let mut bytes = [0u8; 32];
|
||||
OsRng.fill_bytes(&mut bytes);
|
||||
hex::encode(bytes)
|
||||
}
|
||||
|
||||
fn hash_token(token: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(token.as_bytes());
|
||||
hex::encode(hasher.finalize())
|
||||
}
|
||||
|
||||
fn delete_tenant(pool: &PgPool, tenant_id: Uuid) -> Result<()> {
|
||||
let mut conn = pool.get().context("failed to get database connection")?;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use backend::openapi::ApiDoc;
|
||||
use papercrate::openapi::ApiDoc;
|
||||
use utoipa::OpenApi;
|
||||
|
||||
fn main() {
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::net::SocketAddr;
|
||||
use tokio::net::TcpListener;
|
||||
use tower::make::Shared;
|
||||
|
||||
use backend::{routes::webdav, utils::bootstrap::init_component};
|
||||
use papercrate::{routes::webdav, utils::bootstrap::init_component};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::time::Duration;
|
||||
|
||||
use tokio::signal;
|
||||
|
||||
use backend::{default_handlers, utils::bootstrap::init_component, Worker};
|
||||
use papercrate::{default_handlers, utils::bootstrap::init_component, Worker};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
|
||||
+30
-1
@@ -1,12 +1,40 @@
|
||||
use std::time::Duration;
|
||||
|
||||
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 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> {
|
||||
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()
|
||||
.max_size(pool_size)
|
||||
.connection_timeout(Duration::from_secs(10))
|
||||
.connection_customizer(Box::new(SchemaCustomizer))
|
||||
.build(manager)?;
|
||||
Ok(pool)
|
||||
}
|
||||
|
||||
@@ -18,8 +18,10 @@ pub struct DocumentAssetResponse {
|
||||
pub id: Uuid,
|
||||
pub asset_type: String,
|
||||
pub mime_type: String,
|
||||
#[schema(value_type = Object)]
|
||||
pub metadata: Value,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[schema(nullable)]
|
||||
pub cardinality: Option<i32>,
|
||||
}
|
||||
|
||||
@@ -27,10 +29,13 @@ pub struct DocumentAssetResponse {
|
||||
pub struct DocumentAssetObjectResponse {
|
||||
pub id: Uuid,
|
||||
pub ordinal: i32,
|
||||
#[schema(value_type = Object)]
|
||||
pub metadata: Value,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[schema(nullable)]
|
||||
pub url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[schema(nullable)]
|
||||
pub expires_at: Option<i64>,
|
||||
}
|
||||
|
||||
@@ -39,9 +44,11 @@ pub struct DocumentAssetDetailResponse {
|
||||
pub id: Uuid,
|
||||
pub asset_type: String,
|
||||
pub mime_type: String,
|
||||
#[schema(value_type = Object)]
|
||||
pub metadata: Value,
|
||||
pub created_at: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[schema(nullable)]
|
||||
pub cardinality: Option<i32>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub objects: Vec<DocumentAssetObjectResponse>,
|
||||
@@ -54,6 +61,7 @@ pub struct DocumentVersionResponse {
|
||||
pub size_bytes: i64,
|
||||
pub checksum: String,
|
||||
pub created_at: String,
|
||||
#[schema(value_type = Object)]
|
||||
pub metadata: Value,
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ use crate::utils::time::to_iso;
|
||||
pub struct DocumentCorrespondentResponse {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
#[schema(value_type = Object)]
|
||||
pub metadata: Value,
|
||||
pub assigned_at: String,
|
||||
}
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ use std::net::SocketAddr;
|
||||
use tokio::net::TcpListener;
|
||||
use tower::make::Shared;
|
||||
|
||||
use backend::{routes, utils::bootstrap::init_component};
|
||||
use papercrate::{routes, utils::bootstrap::init_component};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
|
||||
+78
-2
@@ -10,7 +10,9 @@ use std::io::Write;
|
||||
use std::str;
|
||||
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::*;
|
||||
|
||||
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
||||
@@ -43,6 +45,64 @@ pub enum TenantStatus {
|
||||
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 {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
@@ -240,7 +300,7 @@ pub struct Document {
|
||||
pub original_name: String,
|
||||
pub content_type: Option<String>,
|
||||
pub folder_id: Option<Uuid>,
|
||||
pub uploaded_at: NaiveDateTime,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
pub deleted_at: Option<NaiveDateTime>,
|
||||
pub metadata: serde_json::Value,
|
||||
@@ -265,6 +325,22 @@ pub struct NewDocument {
|
||||
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)]
|
||||
#[diesel(table_name = document_versions)]
|
||||
#[diesel(belongs_to(Document))]
|
||||
|
||||
+91
-1185
File diff suppressed because it is too large
Load Diff
+326
-45
@@ -13,6 +13,7 @@ use diesel::{pg::PgConnection, prelude::*};
|
||||
use rand::{rngs::OsRng, RngCore};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use utoipa::{OpenApi, ToSchema};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
@@ -24,12 +25,18 @@ use crate::{
|
||||
AuthenticatedUser,
|
||||
},
|
||||
error::{AppError, AppResult},
|
||||
models::{NewRefreshToken, NewUser, RefreshToken, Tenant, TenantStatus, User, UserMembership},
|
||||
models::{
|
||||
MagicToken, MagicTokenKind, NewRefreshToken, NewUser, RefreshToken, TenantStatus, User,
|
||||
},
|
||||
schema::{
|
||||
refresh_tokens, tenants::dsl as tenant_dsl, user_memberships::dsl as memberships_dsl,
|
||||
user_passkeys::dsl as passkey_dsl, users::dsl,
|
||||
magic_tokens::dsl as magic_dsl, refresh_tokens, tenants::dsl as tenant_dsl,
|
||||
user_memberships::dsl as memberships_dsl, user_passkeys::dsl as passkey_dsl, users::dsl,
|
||||
},
|
||||
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;
|
||||
@@ -37,15 +44,22 @@ use webauthn_rs::prelude::RegisterPublicKeyCredential;
|
||||
|
||||
const REFRESH_COOKIE_NAME: &str = "refresh_token";
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct LoginRequest {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
#[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>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[derive(Deserialize, Serialize, ToSchema)]
|
||||
pub struct LoginResponse {
|
||||
pub access_token: String,
|
||||
pub token_type: String,
|
||||
@@ -53,52 +67,151 @@ pub struct LoginResponse {
|
||||
pub tenant: TenantSnippet,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct TenantSnippet {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct TenantSelectionResponse {
|
||||
pub access_token: String,
|
||||
pub tenants: Vec<TenantSnippet>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct TenantListResponse {
|
||||
pub tenants: Vec<TenantSnippet>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct TenantSelectionRequest {
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct SignupStartRequest {
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct SignupStartResponse {
|
||||
pub signup_token: String,
|
||||
pub challenge: RegistrationChallengeResponse,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct SignupFinishRequest {
|
||||
pub signup_token: String,
|
||||
#[schema(value_type = Object)]
|
||||
pub credential: RegisterPublicKeyCredential,
|
||||
#[schema(nullable)]
|
||||
pub nickname: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn login(_state: State<AppState>, _payload: Json<LoginRequest>) -> AppResult<Response> {
|
||||
Err(AppError::bad_request(
|
||||
"password authentication is no longer supported",
|
||||
))
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
#[serde(untagged)]
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<SignupFinishRequest>,
|
||||
@@ -193,6 +317,15 @@ pub async fn signup_finish(
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
jar: Option<TypedHeader<Cookie>>,
|
||||
@@ -207,6 +340,7 @@ pub async fn refresh(
|
||||
let now = Utc::now();
|
||||
let now_naive = now.naive_utc();
|
||||
|
||||
apply_refresh_token_hash(&mut conn, &hashed)?;
|
||||
let token = match refresh_dsl::refresh_tokens
|
||||
.filter(refresh_dsl::token_hash.eq(&hashed))
|
||||
.filter(refresh_dsl::revoked_at.is_null())
|
||||
@@ -218,6 +352,10 @@ pub async fn refresh(
|
||||
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)))
|
||||
.set((
|
||||
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)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/auth/select-tenant",
|
||||
request_body = TenantSelectionRequest,
|
||||
responses((status = 200, description = "Tenant selected", body = LoginResponse)),
|
||||
tag = "Auth"
|
||||
)]
|
||||
pub async fn select_tenant(
|
||||
State(state): State<AppState>,
|
||||
TypedHeader(Authorization(bearer)): TypedHeader<Authorization<Bearer>>,
|
||||
@@ -261,15 +406,17 @@ pub async fn select_tenant(
|
||||
};
|
||||
|
||||
let mut conn = state.db_unscoped()?;
|
||||
apply_user_guc(&mut conn, user_id)?;
|
||||
|
||||
let membership_exists = memberships_dsl::user_memberships
|
||||
.filter(memberships_dsl::user_id.eq(user_id))
|
||||
.filter(memberships_dsl::tenant_id.eq(payload.tenant_id))
|
||||
.inner_join(tenant_dsl::tenants)
|
||||
.select(memberships_dsl::id)
|
||||
.select(memberships_dsl::tenant_id)
|
||||
.first::<Uuid>(&mut conn)
|
||||
.optional()?;
|
||||
|
||||
clear_user_guc(&mut conn)?;
|
||||
|
||||
if membership_exists.is_none() {
|
||||
return Err(AppError::unauthorized());
|
||||
}
|
||||
@@ -282,12 +429,18 @@ pub async fn select_tenant(
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
jar: Option<TypedHeader<Cookie>>,
|
||||
) -> 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 mut rows_affected = 0;
|
||||
|
||||
@@ -327,10 +480,22 @@ pub async fn logout(
|
||||
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> {
|
||||
Json(user)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/auth/tenants",
|
||||
responses((status = 200, description = "Available tenants", body = TenantListResponse)),
|
||||
tag = "Auth"
|
||||
)]
|
||||
pub async fn list_tenants(
|
||||
State(state): State<AppState>,
|
||||
auth: Option<TypedHeader<Authorization<Bearer>>>,
|
||||
@@ -350,19 +515,39 @@ pub async fn list_tenants(
|
||||
};
|
||||
|
||||
let mut conn = state.db_unscoped()?;
|
||||
apply_user_guc(&mut conn, user_id)?;
|
||||
|
||||
let tenants = memberships_dsl::user_memberships
|
||||
.inner_join(tenant_dsl::tenants)
|
||||
let tenant_ids: Vec<Uuid> = memberships_dsl::user_memberships
|
||||
.filter(memberships_dsl::user_id.eq(user_id))
|
||||
.select((tenant_dsl::id, tenant_dsl::name))
|
||||
.load::<(Uuid, String)>(&mut conn)?
|
||||
.into_iter()
|
||||
.map(|(id, name)| TenantSnippet { id, name })
|
||||
.collect();
|
||||
.select(memberships_dsl::tenant_id)
|
||||
.load(&mut conn)?;
|
||||
|
||||
clear_user_guc(&mut conn)?;
|
||||
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 }))
|
||||
}
|
||||
|
||||
#[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(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
@@ -378,6 +563,13 @@ pub async fn passkey_register_start(
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
@@ -408,6 +600,13 @@ pub async fn passkey_register_finish(
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<PasskeyLoginStartPayload>,
|
||||
@@ -431,6 +630,16 @@ pub async fn passkey_login_start(
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<PasskeyLoginFinishPayload>,
|
||||
@@ -457,26 +666,27 @@ fn complete_login(
|
||||
user: &User,
|
||||
preferred_tenant_id: Option<Uuid>,
|
||||
) -> AppResult<Response> {
|
||||
let memberships: Vec<(UserMembership, Tenant)> = memberships_dsl::user_memberships
|
||||
.inner_join(tenant_dsl::tenants)
|
||||
apply_user_guc(conn, user.id)?;
|
||||
let tenant_ids: Vec<Uuid> = memberships_dsl::user_memberships
|
||||
.filter(memberships_dsl::user_id.eq(user.id))
|
||||
.select(memberships_dsl::tenant_id)
|
||||
.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());
|
||||
}
|
||||
|
||||
if let Some(preferred_id) = preferred_tenant_id {
|
||||
if let Some((_, tenant)) = memberships
|
||||
.iter()
|
||||
.find(|(_, tenant)| tenant.id == preferred_id)
|
||||
{
|
||||
return issue_session(state, conn, user, tenant.id);
|
||||
if tenant_ids.iter().any(|id| *id == preferred_id) {
|
||||
return issue_session(state, conn, user, preferred_id);
|
||||
}
|
||||
}
|
||||
|
||||
if memberships.len() == 1 {
|
||||
return issue_session(state, conn, user, memberships[0].1.id);
|
||||
if tenant_ids.len() == 1 {
|
||||
return issue_session(state, conn, user, tenant_ids[0]);
|
||||
}
|
||||
|
||||
let selection_token = state
|
||||
@@ -484,13 +694,19 @@ fn complete_login(
|
||||
.generate_tenant_selector_token(user.id)
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
let tenants = memberships
|
||||
.into_iter()
|
||||
.map(|(_, tenant)| TenantSnippet {
|
||||
id: tenant.id,
|
||||
name: tenant.name,
|
||||
})
|
||||
.collect();
|
||||
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(TenantSelectionResponse {
|
||||
access_token: selection_token,
|
||||
@@ -499,12 +715,73 @@ fn complete_login(
|
||||
.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(
|
||||
state: &AppState,
|
||||
conn: &mut PgConnection,
|
||||
user: &User,
|
||||
tenant_id: Uuid,
|
||||
) -> AppResult<Response> {
|
||||
apply_tenant_guc(conn, tenant_id)?;
|
||||
clear_user_guc(conn)?;
|
||||
clear_refresh_token_hash(conn)?;
|
||||
|
||||
let now = Utc::now();
|
||||
let access_token = state
|
||||
.jwt
|
||||
@@ -559,6 +836,10 @@ fn hash_refresh_token(token: &str) -> String {
|
||||
hex::encode(hasher.finalize())
|
||||
}
|
||||
|
||||
fn hash_magic_token(token: &str) -> String {
|
||||
hash_refresh_token(token)
|
||||
}
|
||||
|
||||
fn generate_refresh_token() -> String {
|
||||
let mut bytes = [0u8; 32];
|
||||
OsRng.fill_bytes(&mut bytes);
|
||||
|
||||
@@ -5,6 +5,7 @@ use chrono::Utc;
|
||||
use diesel::{dsl::count_star, prelude::*, result::DatabaseErrorKind, PgConnection};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
@@ -14,35 +15,40 @@ use crate::{
|
||||
schema::{correspondents, document_correspondents},
|
||||
utils::{
|
||||
db::{no_content, EnsureEntity, IntoJsonResponse},
|
||||
named_entity::{ensure_name_available, normalize_name},
|
||||
time::to_iso,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct CorrespondentUsage {
|
||||
pub total: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct CorrespondentSummary {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
#[schema(value_type = Object)]
|
||||
pub metadata: Value,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub usage: CorrespondentUsage,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct CreateCorrespondentRequest {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
#[schema(nullable, value_type = Object)]
|
||||
pub metadata: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct UpdateCorrespondentRequest {
|
||||
#[schema(nullable)]
|
||||
pub name: Option<String>,
|
||||
#[schema(nullable, value_type = Object)]
|
||||
pub metadata: Option<Value>,
|
||||
}
|
||||
|
||||
@@ -53,6 +59,12 @@ struct CorrespondentChangeset<'a> {
|
||||
metadata: Option<&'a Value>,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/correspondents",
|
||||
responses((status = 200, description = "Correspondents", body = [CorrespondentSummary])),
|
||||
tag = "Correspondents"
|
||||
)]
|
||||
pub async fn list_correspondents(
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
@@ -85,6 +97,13 @@ pub async fn list_correspondents(
|
||||
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(
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
@@ -93,16 +112,15 @@ pub async fn create_correspondent(
|
||||
}: TenantScopedConn,
|
||||
Json(payload): Json<CreateCorrespondentRequest>,
|
||||
) -> AppResult<Json<CorrespondentSummary>> {
|
||||
let name = payload.name.trim();
|
||||
if name.is_empty() {
|
||||
return Err(AppError::bad_request("name must not be empty"));
|
||||
}
|
||||
let name = normalize_name(&payload.name, || {
|
||||
AppError::bad_request("name must not be empty")
|
||||
})?;
|
||||
|
||||
let metadata_value = normalize_metadata(payload.metadata);
|
||||
let new_id = Uuid::new_v4();
|
||||
let new_correspondent = NewCorrespondent {
|
||||
id: new_id,
|
||||
name: name.to_string(),
|
||||
name: name.clone(),
|
||||
metadata: metadata_value,
|
||||
tenant_id,
|
||||
};
|
||||
@@ -127,6 +145,14 @@ pub async fn create_correspondent(
|
||||
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(
|
||||
Path(correspondent_id): Path<Uuid>,
|
||||
TenantScopedConn {
|
||||
@@ -144,21 +170,22 @@ pub async fn update_correspondent(
|
||||
|
||||
let mut new_name: Option<String> = None;
|
||||
if let Some(ref candidate) = payload.name {
|
||||
let trimmed = candidate.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(AppError::bad_request("name must not be empty"));
|
||||
}
|
||||
if trimmed != existing.name {
|
||||
let duplicate = correspondents::table
|
||||
.filter(correspondents::name.eq(trimmed))
|
||||
.filter(correspondents::id.ne(correspondent_id))
|
||||
.filter(correspondents::tenant_id.eq(tenant_id))
|
||||
.first::<Correspondent>(&mut conn)
|
||||
.optional()?;
|
||||
if duplicate.is_some() {
|
||||
return Err(AppError::bad_request("correspondent name already exists"));
|
||||
}
|
||||
new_name = Some(trimmed.to_string());
|
||||
let normalized = normalize_name(candidate, || {
|
||||
AppError::bad_request("name must not be empty")
|
||||
})?;
|
||||
if normalized != existing.name {
|
||||
ensure_name_available(
|
||||
|| {
|
||||
correspondents::table
|
||||
.filter(correspondents::name.eq(&normalized))
|
||||
.filter(correspondents::id.ne(correspondent_id))
|
||||
.filter(correspondents::tenant_id.eq(tenant_id))
|
||||
.first::<Correspondent>(&mut conn)
|
||||
.optional()
|
||||
},
|
||||
|| AppError::bad_request("correspondent name already exists"),
|
||||
)?;
|
||||
new_name = Some(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,6 +228,13 @@ pub async fn update_correspondent(
|
||||
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(
|
||||
Path(correspondent_id): Path<Uuid>,
|
||||
TenantScopedConn {
|
||||
@@ -264,3 +298,20 @@ fn load_usage_for_correspondent(
|
||||
|
||||
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
@@ -8,7 +8,7 @@ use axum::http::StatusCode;
|
||||
use axum::response::IntoResponse;
|
||||
use chrono::{DateTime, NaiveDateTime, Utc};
|
||||
use diesel::dsl::exists;
|
||||
use diesel::{prelude::*, result::DatabaseErrorKind, select};
|
||||
use diesel::{prelude::*, result::DatabaseErrorKind, select, OptionalExtension};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
@@ -59,13 +59,16 @@ const PRESIGNED_URL_EXPIRY_SECONDS: u64 = 300;
|
||||
#[derive(Deserialize, IntoParams, ToSchema)]
|
||||
#[into_params(parameter_in = Query)]
|
||||
pub struct DocumentListQuery {
|
||||
#[schema(nullable)]
|
||||
pub folder_id: Option<Uuid>,
|
||||
#[serde(default)]
|
||||
#[schema(nullable)]
|
||||
pub include_descendants: Option<bool>,
|
||||
pub query: Option<String>,
|
||||
pub tags: Option<String>,
|
||||
pub correspondents: Option<String>,
|
||||
#[serde(default = "default_document_status_filter")]
|
||||
#[schema(default = "active")]
|
||||
pub status: DocumentStatusFilter,
|
||||
}
|
||||
|
||||
@@ -85,6 +88,7 @@ pub enum DocumentStatusFilter {
|
||||
#[into_params(parameter_in = Query)]
|
||||
pub struct AssetRequestQuery {
|
||||
#[serde(default)]
|
||||
#[schema(default = false)]
|
||||
pub force: bool,
|
||||
}
|
||||
|
||||
@@ -98,23 +102,30 @@ pub struct DocumentCheckQuery {
|
||||
pub struct DocumentCheckResponse {
|
||||
pub exists: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[schema(nullable)]
|
||||
pub document_id: Option<Uuid>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[schema(nullable)]
|
||||
pub title: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[schema(nullable)]
|
||||
pub filename: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[schema(nullable)]
|
||||
pub version_id: Option<Uuid>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[schema(nullable)]
|
||||
pub version_number: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub uploaded_at: Option<String>,
|
||||
#[schema(nullable)]
|
||||
pub created_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct TagResponse {
|
||||
pub id: Uuid,
|
||||
pub label: String,
|
||||
#[schema(nullable)]
|
||||
pub color: Option<String>,
|
||||
}
|
||||
|
||||
@@ -134,17 +145,23 @@ pub struct DocumentResponse {
|
||||
pub filename: String,
|
||||
pub title: String,
|
||||
pub original_name: String,
|
||||
#[schema(nullable)]
|
||||
pub content_type: Option<String>,
|
||||
#[schema(nullable)]
|
||||
pub folder_id: Option<Uuid>,
|
||||
pub uploaded_at: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
#[schema(nullable)]
|
||||
pub deleted_at: Option<String>,
|
||||
#[schema(nullable)]
|
||||
pub issued_at: Option<String>,
|
||||
#[schema(value_type = Object)]
|
||||
pub metadata: Value,
|
||||
pub tags: Vec<TagResponse>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub correspondents: Vec<DocumentCorrespondentResponse>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[schema(nullable)]
|
||||
pub current_version: Option<DocumentVersionDetailResponse>,
|
||||
}
|
||||
#[derive(Serialize, ToSchema)]
|
||||
@@ -160,11 +177,13 @@ pub struct BulkReanalyzeResponse {
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct BulkMoveRequest {
|
||||
pub document_ids: Vec<Uuid>,
|
||||
#[schema(nullable)]
|
||||
pub folder_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct DocumentMetadataUpdate {
|
||||
#[schema(value_type = Object)]
|
||||
pub value: Value,
|
||||
#[serde(default)]
|
||||
#[schema(default = false)]
|
||||
@@ -179,7 +198,7 @@ pub struct UpdateDocumentRequest {
|
||||
#[schema(nullable, value_type = Option<String>)]
|
||||
pub issued_at: Option<Value>,
|
||||
#[serde(default)]
|
||||
#[schema(nullable)]
|
||||
#[schema(nullable, value_type = Object)]
|
||||
pub metadata: Option<DocumentMetadataUpdate>,
|
||||
}
|
||||
|
||||
@@ -223,6 +242,7 @@ pub struct CorrespondentAssignmentInput {
|
||||
pub struct AssignCorrespondentsRequest {
|
||||
pub assignments: Vec<CorrespondentAssignmentInput>,
|
||||
#[serde(default)]
|
||||
#[schema(default = false)]
|
||||
pub replace: bool,
|
||||
}
|
||||
|
||||
@@ -259,6 +279,7 @@ pub struct BulkCorrespondentsRequest {
|
||||
pub struct BulkReanalyzeSelectionRequest {
|
||||
pub document_ids: Vec<Uuid>,
|
||||
#[serde(default = "default_true")]
|
||||
#[schema(default = true)]
|
||||
pub force: bool,
|
||||
}
|
||||
|
||||
@@ -291,7 +312,7 @@ pub struct UploadDocumentForm {
|
||||
pub file: String,
|
||||
#[schema(nullable)]
|
||||
pub folder_id: Option<Uuid>,
|
||||
#[schema(nullable)]
|
||||
#[schema(nullable, value_type = Object)]
|
||||
pub metadata: Option<Value>,
|
||||
#[schema(nullable)]
|
||||
pub title: Option<String>,
|
||||
@@ -330,6 +351,13 @@ pub struct AssetObjectsQuery {
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<DocumentListQuery>,
|
||||
@@ -378,7 +406,6 @@ pub async fn list_documents(
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_owned());
|
||||
|
||||
let include_descendants = include_descendants.unwrap_or(true);
|
||||
|
||||
match (folder_id, include_descendants) {
|
||||
@@ -554,41 +581,29 @@ pub async fn list_documents(
|
||||
|
||||
if !by_id.is_empty() {
|
||||
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
|
||||
} else {
|
||||
docs_query
|
||||
.order(documents::uploaded_at.desc())
|
||||
.order(documents::created_at.desc())
|
||||
.load(&mut conn)?
|
||||
};
|
||||
|
||||
let doc_ids: Vec<Uuid> = docs.iter().map(|doc| doc.id).collect();
|
||||
let tags_map = load_tags_for_documents(&mut conn, &doc_ids)?;
|
||||
let mut correspondents_map = load_correspondents_for_documents(&mut conn, &doc_ids)?;
|
||||
drop(conn);
|
||||
|
||||
let primary_versions = load_primary_assets(&state, 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,
|
||||
)?);
|
||||
}
|
||||
let response = hydrate_documents(&state, &mut conn, tenant_id, user_id, docs)?;
|
||||
|
||||
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(
|
||||
Query(query): Query<DocumentCheckQuery>,
|
||||
TenantScopedConn {
|
||||
@@ -627,7 +642,7 @@ pub async fn check_document(
|
||||
filename: Some(document.filename.clone()),
|
||||
version_id: Some(version.id),
|
||||
version_number: Some(version.version_number),
|
||||
uploaded_at: Some(to_iso(document.uploaded_at)),
|
||||
created_at: Some(to_iso(document.created_at)),
|
||||
}))
|
||||
} else {
|
||||
Ok(Json(DocumentCheckResponse {
|
||||
@@ -637,11 +652,18 @@ pub async fn check_document(
|
||||
filename: None,
|
||||
version_id: 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(
|
||||
State(state): State<AppState>,
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
TenantScopedConn {
|
||||
@@ -889,6 +922,13 @@ pub async fn upload_document(
|
||||
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(
|
||||
Path(document_id): Path<Uuid>,
|
||||
Query(query): Query<AssetRequestQuery>,
|
||||
@@ -925,6 +965,13 @@ pub async fn request_document_assets(
|
||||
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(
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
@@ -976,6 +1023,13 @@ pub async fn reanalyze_selected_documents(
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
Path(document_id): Path<Uuid>,
|
||||
@@ -1000,6 +1054,13 @@ pub async fn list_document_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(
|
||||
State(state): State<AppState>,
|
||||
Path(asset_id): Path<Uuid>,
|
||||
@@ -1074,6 +1135,13 @@ pub async fn get_document_asset(
|
||||
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(
|
||||
Path(document_id): Path<Uuid>,
|
||||
TenantScopedConn {
|
||||
@@ -1103,6 +1171,16 @@ pub async fn list_document_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(
|
||||
State(state): State<AppState>,
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
Path(token): Path<String>,
|
||||
@@ -1193,6 +1278,13 @@ pub async fn download_with_token(
|
||||
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(
|
||||
Path(document_id): Path<Uuid>,
|
||||
TenantScopedConn {
|
||||
@@ -1215,6 +1307,14 @@ pub async fn delete_document(
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
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(
|
||||
Path(document_id): Path<Uuid>,
|
||||
TenantScopedConn {
|
||||
@@ -1437,6 +1545,14 @@ pub async fn restore_document(
|
||||
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(
|
||||
Path(document_id): Path<Uuid>,
|
||||
TenantScopedConn {
|
||||
@@ -1465,6 +1581,13 @@ pub async fn move_document(
|
||||
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(
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
@@ -1548,6 +1671,14 @@ pub async fn bulk_move_documents(
|
||||
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(
|
||||
Path(document_id): Path<Uuid>,
|
||||
TenantScopedConn {
|
||||
@@ -1629,6 +1760,13 @@ pub async fn assign_correspondents(
|
||||
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(
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
@@ -1721,6 +1859,16 @@ pub async fn bulk_assign_correspondents(
|
||||
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(
|
||||
Path((document_id, correspondent_id)): Path<(Uuid, Uuid)>,
|
||||
TenantScopedConn {
|
||||
@@ -1760,6 +1908,14 @@ pub async fn remove_correspondent(
|
||||
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(
|
||||
Path(document_id): Path<Uuid>,
|
||||
TenantScopedConn {
|
||||
@@ -1790,6 +1946,13 @@ pub async fn assign_tags(
|
||||
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(
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
@@ -1875,6 +2038,16 @@ pub async fn bulk_update_tags(
|
||||
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(
|
||||
Path((document_id, tag_id)): Path<(Uuid, Uuid)>,
|
||||
TenantScopedConn {
|
||||
@@ -2190,7 +2363,7 @@ pub(crate) fn to_document_response(
|
||||
original_name: doc.original_name,
|
||||
content_type: doc.content_type,
|
||||
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),
|
||||
deleted_at: doc.deleted_at.map(to_iso),
|
||||
issued_at: doc.issued_at.map(to_iso),
|
||||
@@ -2204,3 +2377,99 @@ pub(crate) fn to_document_response(
|
||||
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
@@ -4,7 +4,7 @@ use axum::{
|
||||
};
|
||||
use diesel::{dsl::exists, prelude::*, PgConnection};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::models::{Document, Folder, NewFolder};
|
||||
@@ -15,43 +15,41 @@ use crate::{
|
||||
error::{AppError, AppResult},
|
||||
};
|
||||
|
||||
use super::documents::{to_document_response, DocumentResponse};
|
||||
use crate::documents::{
|
||||
asset::load_primary_assets, correspondents::load_correspondents_for_documents,
|
||||
tags::load_tags_for_documents,
|
||||
};
|
||||
use crate::utils::{
|
||||
json::{classify_nullable, NullableValue},
|
||||
time::to_iso,
|
||||
};
|
||||
use super::documents::{hydrate_documents, DocumentResponse};
|
||||
use crate::utils::{json::deserialize_patch_field, time::to_iso};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct CreateFolderRequest {
|
||||
pub name: String,
|
||||
#[schema(nullable)]
|
||||
pub parent_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct EnsureFolderPathRequest {
|
||||
#[schema(nullable)]
|
||||
pub parent_id: Option<Uuid>,
|
||||
pub segments: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct FolderResponse {
|
||||
pub folder: FolderInfo,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct FolderContentsResponse {
|
||||
#[schema(nullable)]
|
||||
pub folder: Option<FolderInfo>,
|
||||
pub subfolders: Vec<FolderInfo>,
|
||||
pub documents: Vec<DocumentResponse>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Deserialize, IntoParams, ToSchema)]
|
||||
#[into_params(parameter_in = Query)]
|
||||
pub struct FolderContentsQuery {
|
||||
#[serde(default = "default_include_documents")]
|
||||
#[schema(default = true)]
|
||||
pub include_documents: bool,
|
||||
}
|
||||
|
||||
@@ -59,15 +57,58 @@ const fn default_include_documents() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct FolderInfo {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
#[schema(nullable)]
|
||||
pub parent_id: Option<Uuid>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(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(
|
||||
Path(folder_id): Path<Uuid>,
|
||||
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(
|
||||
TenantScopedConn {
|
||||
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(
|
||||
TenantScopedConn {
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
Path(folder_identifier): Path<String>,
|
||||
@@ -308,7 +373,7 @@ pub async fn list_folder_contents(
|
||||
let docs_query = documents::table
|
||||
.filter(documents::deleted_at.is_null())
|
||||
.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 {
|
||||
docs_query
|
||||
@@ -320,29 +385,7 @@ pub async fn list_folder_contents(
|
||||
.load(&mut conn)?
|
||||
};
|
||||
|
||||
let doc_ids: Vec<Uuid> = docs.iter().map(|doc| doc.id).collect();
|
||||
let tags_map = load_tags_for_documents(&mut conn, &doc_ids)?;
|
||||
let mut correspondents_map = load_correspondents_for_documents(&mut conn, &doc_ids)?;
|
||||
drop(conn);
|
||||
|
||||
let primary_versions = load_primary_assets(&state, 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
|
||||
hydrate_documents(&state, &mut conn, tenant_id, user_id, docs)?
|
||||
} else {
|
||||
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(
|
||||
Path(folder_id): Path<Uuid>,
|
||||
TenantScopedConn {
|
||||
@@ -408,6 +458,14 @@ pub async fn delete_folder(
|
||||
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(
|
||||
Path(folder_id): Path<Uuid>,
|
||||
TenantScopedConn {
|
||||
@@ -415,15 +473,8 @@ pub async fn update_folder(
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
Json(body): Json<Value>,
|
||||
Json(payload): Json<UpdateFolderRequest>,
|
||||
) -> AppResult<StatusCode> {
|
||||
if !body.is_object() {
|
||||
return Err(AppError::bad_request("request body must be a JSON object"));
|
||||
}
|
||||
|
||||
let parent_class = classify_nullable(body.get("parent_id")).map_err(AppError::bad_request)?;
|
||||
let name_class = classify_nullable(body.get("name")).map_err(AppError::bad_request)?;
|
||||
|
||||
conn.transaction::<(), AppError, _>(|conn| {
|
||||
let folder: Folder = folders::table
|
||||
.find(folder_id)
|
||||
@@ -432,21 +483,15 @@ pub async fn update_folder(
|
||||
|
||||
let mut next_parent = folder.parent_id;
|
||||
let mut parent_changed = false;
|
||||
match parent_class {
|
||||
NullableValue::Omitted => {}
|
||||
NullableValue::Null => {
|
||||
match payload.parent_id {
|
||||
None => {}
|
||||
Some(None) => {
|
||||
if folder.parent_id.is_some() {
|
||||
parent_changed = true;
|
||||
}
|
||||
next_parent = None;
|
||||
}
|
||||
NullableValue::String(value) => {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(AppError::bad_request("parent_id must not be empty"));
|
||||
}
|
||||
let parent_id = Uuid::parse_str(trimmed)
|
||||
.map_err(|_| AppError::bad_request("parent_id must be a valid UUID or null"))?;
|
||||
Some(Some(parent_id)) => {
|
||||
if parent_id == folder_id {
|
||||
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 name_changed = false;
|
||||
match name_class {
|
||||
NullableValue::Omitted => {}
|
||||
NullableValue::Null => {
|
||||
match payload.name {
|
||||
None => {}
|
||||
Some(None) => {
|
||||
return Err(AppError::bad_request("name cannot be null"));
|
||||
}
|
||||
NullableValue::String(value) => {
|
||||
Some(Some(value)) => {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(AppError::bad_request("name must not be empty"));
|
||||
@@ -565,3 +610,25 @@ pub(super) fn gather_descendant_folder_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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
pub async fn health_check() -> (StatusCode, Json<serde_json::Value>) {
|
||||
(StatusCode::OK, Json(json!({ "status": "ok" })))
|
||||
use crate::state::AppState;
|
||||
|
||||
#[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))
|
||||
}
|
||||
|
||||
+51
-14
@@ -2,7 +2,7 @@ use axum::http::HeaderValue;
|
||||
use axum::{
|
||||
extract::DefaultBodyLimit,
|
||||
middleware,
|
||||
response::Json,
|
||||
response::{Html, Json},
|
||||
routing::{delete, get, patch, post},
|
||||
Router,
|
||||
};
|
||||
@@ -148,6 +148,10 @@ pub fn create_router(state: AppState) -> Router<()> {
|
||||
"/webdav-tokens",
|
||||
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("/passkeys", get(profile::list_passkeys))
|
||||
.route("/passkeys/:id", delete(profile::delete_passkey));
|
||||
@@ -164,24 +168,26 @@ pub fn create_router(state: AppState) -> Router<()> {
|
||||
.nest("/api/assets", assets_routes)
|
||||
.layer(middleware::from_extractor_with_state::<AuthenticatedUser, _>(protected_state));
|
||||
|
||||
let openapi_arc = Arc::new(ApiDoc::openapi());
|
||||
let docs_route = Router::new().route(
|
||||
"/api/docs/openapi.json",
|
||||
get({
|
||||
let spec = openapi_arc.clone();
|
||||
move || {
|
||||
let spec = spec.clone();
|
||||
async move { Json((*spec).clone()) }
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
let upload_limit = state.config.upload_body_limit_bytes;
|
||||
|
||||
let openapi_spec = Arc::new(ApiDoc::openapi());
|
||||
let docs_router = Router::new()
|
||||
.route(
|
||||
"/api/docs",
|
||||
get(move || async { Html(render_swagger_ui("/api/docs/openapi.json")) }),
|
||||
)
|
||||
.route(
|
||||
"/api/docs/openapi.json",
|
||||
get({
|
||||
let spec = openapi_spec.clone();
|
||||
move || async move { Json((*spec).clone()) }
|
||||
}),
|
||||
);
|
||||
|
||||
Router::new()
|
||||
.merge(download_routes)
|
||||
.merge(protected_routes)
|
||||
.merge(docs_route)
|
||||
.merge(docs_router)
|
||||
.nest("/api/auth", auth_routes)
|
||||
.route("/api/health", get(health::health_check))
|
||||
.with_state(state)
|
||||
@@ -196,3 +202,34 @@ pub fn create_router(state: AppState) -> Router<()> {
|
||||
.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>"#
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,13 +5,14 @@ use axum::{
|
||||
};
|
||||
use chrono::{DateTime, NaiveDateTime};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::{
|
||||
passkeys::PasskeySummary,
|
||||
webdav_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,
|
||||
};
|
||||
@@ -20,35 +21,48 @@ use crate::models::WebdavToken;
|
||||
use crate::state::AppState;
|
||||
use crate::utils::{db::no_content, time::to_iso};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct WebdavTokenResponse {
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
#[schema(nullable)]
|
||||
pub label: Option<String>,
|
||||
pub created_at: String,
|
||||
#[schema(nullable)]
|
||||
pub last_used_at: Option<String>,
|
||||
#[schema(nullable)]
|
||||
pub expires_at: Option<String>,
|
||||
#[schema(nullable)]
|
||||
pub revoked_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct WebdavTokenCreatedResponse {
|
||||
pub token: String,
|
||||
pub token_info: WebdavTokenResponse,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct CreateWebdavTokenRequest {
|
||||
#[schema(nullable)]
|
||||
pub label: Option<String>,
|
||||
#[schema(nullable)]
|
||||
pub expires_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct RevokePasskeyQuery {
|
||||
#[serde(default)]
|
||||
#[schema(nullable)]
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
TenantScopedConn {
|
||||
@@ -64,6 +78,12 @@ pub async fn list_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(
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
@@ -77,6 +97,13 @@ pub async fn list_webdav_tokens(
|
||||
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(
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
@@ -107,6 +134,38 @@ pub async fn create_webdav_token(
|
||||
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(
|
||||
TenantScopedConn {
|
||||
mut conn, user_id, ..
|
||||
@@ -117,6 +176,16 @@ pub async fn delete_webdav_token(
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
TenantScopedConn {
|
||||
@@ -158,3 +227,23 @@ fn parse_timestamp(value: &str) -> AppResult<NaiveDateTime> {
|
||||
.map_err(|_| AppError::bad_request("invalid expires_at timestamp"))?;
|
||||
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;
|
||||
|
||||
+118
-38
@@ -1,20 +1,24 @@
|
||||
use crate::utils::json::{classify_nullable, NullableValue};
|
||||
use axum::{extract::Path, http::StatusCode, Json};
|
||||
use diesel::{dsl::count_star, prelude::*};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::TenantScopedConn;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::models::{NewTag, Tag};
|
||||
use crate::schema::{document_tags, tags};
|
||||
use crate::utils::db::{no_content, EnsureEntity, IntoJsonResponse};
|
||||
use crate::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 label: String,
|
||||
#[schema(nullable)]
|
||||
pub color: Option<String>,
|
||||
}
|
||||
|
||||
@@ -25,14 +29,55 @@ struct UpdateTagChangeset<'a> {
|
||||
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 id: Uuid,
|
||||
pub label: String,
|
||||
#[schema(nullable)]
|
||||
pub color: Option<String>,
|
||||
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(
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
@@ -66,6 +111,13 @@ pub async fn list_tags(
|
||||
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(
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
@@ -74,13 +126,13 @@ pub async fn create_tag(
|
||||
}: TenantScopedConn,
|
||||
Json(payload): Json<CreateTagRequest>,
|
||||
) -> AppResult<Json<TagCatalogEntry>> {
|
||||
if payload.label.trim().is_empty() {
|
||||
return Err(AppError::bad_request("label must not be empty"));
|
||||
}
|
||||
let label = normalize_name(&payload.label, || {
|
||||
AppError::bad_request("label must not be empty")
|
||||
})?;
|
||||
|
||||
let new_tag = NewTag {
|
||||
id: Uuid::new_v4(),
|
||||
label: payload.label.trim().to_string(),
|
||||
label: label.clone(),
|
||||
color: payload.color,
|
||||
tenant_id,
|
||||
};
|
||||
@@ -114,6 +166,14 @@ pub async fn create_tag(
|
||||
.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(
|
||||
Path(tag_id): Path<Uuid>,
|
||||
TenantScopedConn {
|
||||
@@ -121,19 +181,16 @@ pub async fn update_tag(
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
Json(body): Json<Value>,
|
||||
Json(payload): Json<UpdateTagRequest>,
|
||||
) -> AppResult<Json<TagCatalogEntry>> {
|
||||
let existing: Tag = tags::table
|
||||
.find(tag_id)
|
||||
.filter(tags::tenant_id.eq(tenant_id))
|
||||
.first(&mut conn)
|
||||
.one()?;
|
||||
let label_class = classify_nullable(body.get("label")).map_err(AppError::bad_request)?;
|
||||
let color_class = classify_nullable(body.get("color")).map_err(AppError::bad_request)?;
|
||||
let UpdateTagRequest { label, color } = payload;
|
||||
|
||||
if matches!(label_class, NullableValue::Omitted)
|
||||
&& matches!(color_class, NullableValue::Omitted)
|
||||
{
|
||||
if label.is_none() && color.is_none() {
|
||||
let usage_count: i64 = document_tags::table
|
||||
.filter(document_tags::tag_id.eq(tag_id))
|
||||
.select(count_star())
|
||||
@@ -149,27 +206,27 @@ pub async fn update_tag(
|
||||
|
||||
let mut new_label: Option<String> = None;
|
||||
let mut label_changed = false;
|
||||
match label_class {
|
||||
NullableValue::Omitted => {}
|
||||
NullableValue::Null => {
|
||||
match label {
|
||||
None => {}
|
||||
Some(None) => {
|
||||
return Err(AppError::bad_request("label cannot be null"));
|
||||
}
|
||||
NullableValue::String(value) => {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(AppError::bad_request("label must not be empty"));
|
||||
}
|
||||
if trimmed != existing.label {
|
||||
let duplicate = tags::table
|
||||
.filter(tags::label.eq(trimmed))
|
||||
.filter(tags::id.ne(tag_id))
|
||||
.filter(tags::tenant_id.eq(tenant_id))
|
||||
.first::<Tag>(&mut conn)
|
||||
.optional()?;
|
||||
if duplicate.is_some() {
|
||||
return Err(AppError::bad_request("tag label already exists"));
|
||||
}
|
||||
new_label = Some(trimmed.to_string());
|
||||
Some(Some(value)) => {
|
||||
let normalized =
|
||||
normalize_name(&value, || AppError::bad_request("label must not be empty"))?;
|
||||
if normalized != existing.label {
|
||||
ensure_name_available(
|
||||
|| {
|
||||
tags::table
|
||||
.filter(tags::label.eq(&normalized))
|
||||
.filter(tags::id.ne(tag_id))
|
||||
.filter(tags::tenant_id.eq(tenant_id))
|
||||
.first::<Tag>(&mut conn)
|
||||
.optional()
|
||||
},
|
||||
|| AppError::bad_request("tag label already exists"),
|
||||
)?;
|
||||
new_label = Some(normalized);
|
||||
label_changed = true;
|
||||
}
|
||||
}
|
||||
@@ -177,13 +234,13 @@ pub async fn update_tag(
|
||||
|
||||
let mut color_change: Option<Option<String>> = None;
|
||||
let mut color_changed = false;
|
||||
match color_class {
|
||||
NullableValue::Omitted => {}
|
||||
NullableValue::Null => {
|
||||
match color {
|
||||
None => {}
|
||||
Some(None) => {
|
||||
color_change = Some(None);
|
||||
color_changed = true;
|
||||
}
|
||||
NullableValue::String(value) => {
|
||||
Some(Some(value)) => {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(AppError::bad_request("color must not be empty"));
|
||||
@@ -244,6 +301,13 @@ pub async fn update_tag(
|
||||
.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(
|
||||
Path(tag_id): Path<Uuid>,
|
||||
TenantScopedConn {
|
||||
@@ -276,3 +340,19 @@ pub async fn delete_tag(
|
||||
|
||||
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;
|
||||
|
||||
@@ -21,10 +21,10 @@ use crate::error::{AppError, AppResult};
|
||||
use crate::models::{Document, DocumentVersion, Folder, User};
|
||||
use crate::schema::{
|
||||
document_versions::dsl as document_versions_dsl, documents::dsl as documents_dsl,
|
||||
folders::dsl as folders_dsl, tenants::dsl as tenant_dsl,
|
||||
user_memberships::dsl as memberships_dsl, users::dsl as users_dsl,
|
||||
folders::dsl as folders_dsl, user_memberships::dsl as memberships_dsl, users::dsl as users_dsl,
|
||||
};
|
||||
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};
|
||||
|
||||
const REALM: &str = "Papercrate WebDAV";
|
||||
@@ -273,7 +273,7 @@ fn fetch_folder_contents(
|
||||
};
|
||||
|
||||
let documents: Vec<Document> = docs_query
|
||||
.order(documents_dsl::uploaded_at.desc())
|
||||
.order(documents_dsl::created_at.desc())
|
||||
.load(&mut conn)?;
|
||||
|
||||
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
|
||||
.inner_join(tenant_dsl::tenants)
|
||||
apply_user_guc(&mut conn, user.id)?;
|
||||
|
||||
let membership_exists = memberships_dsl::user_memberships
|
||||
.filter(memberships_dsl::user_id.eq(user.id))
|
||||
.filter(memberships_dsl::tenant_id.eq(token.tenant_id))
|
||||
.select((tenant_dsl::id, tenant_dsl::name))
|
||||
.first::<(Uuid, String)>(&mut conn)
|
||||
.select(memberships_dsl::tenant_id)
|
||||
.first::<Uuid>(&mut conn)
|
||||
.optional()?;
|
||||
|
||||
let (tenant_id, _name) = match tenant_row {
|
||||
Some(row) => row,
|
||||
clear_user_guc(&mut conn)?;
|
||||
|
||||
let tenant_id = match membership_exists {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
tracing::warn!(
|
||||
%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)?;
|
||||
|
||||
tracing::debug!(
|
||||
|
||||
+25
-1
@@ -1,6 +1,10 @@
|
||||
// @generated automatically by Diesel CLI.
|
||||
|
||||
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)]
|
||||
#[diesel(postgres_type(name = "tenant_status"))]
|
||||
pub struct TenantStatus;
|
||||
@@ -88,7 +92,7 @@ diesel::table! {
|
||||
#[max_length = 100]
|
||||
content_type -> Nullable<Varchar>,
|
||||
folder_id -> Nullable<Uuid>,
|
||||
uploaded_at -> Timestamptz,
|
||||
created_at -> Timestamptz,
|
||||
updated_at -> Timestamptz,
|
||||
deleted_at -> Nullable<Timestamptz>,
|
||||
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! {
|
||||
refresh_tokens (id) {
|
||||
id -> Uuid,
|
||||
@@ -275,6 +298,7 @@ diesel::allow_tables_to_appear_in_same_query!(
|
||||
documents,
|
||||
folders,
|
||||
jobs,
|
||||
magic_tokens,
|
||||
refresh_tokens,
|
||||
tags,
|
||||
tenants,
|
||||
|
||||
@@ -12,7 +12,7 @@ use crate::{
|
||||
db::PgPool,
|
||||
error::{AppError, AppResult},
|
||||
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>>;
|
||||
@@ -75,14 +75,17 @@ impl AppState {
|
||||
debug_assert!(!tenant_id.is_nil(), "nil tenant_id passed to db_for_tenant");
|
||||
let mut conn = self.db_unscoped()?;
|
||||
apply_tenant_guc(&mut conn, tenant_id)?;
|
||||
clear_user_guc(&mut conn)?;
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
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");
|
||||
AppError::internal("database pool error")
|
||||
})
|
||||
})?;
|
||||
clear_tenant_context(&mut conn)?;
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
pub fn storage_for_tenant(&self, tenant_id: Uuid) -> AppResult<TenantStorage> {
|
||||
|
||||
+59
-1
@@ -128,13 +128,71 @@ impl TenantService {
|
||||
}
|
||||
|
||||
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())
|
||||
.execute(conn)
|
||||
.map(|_| ())
|
||||
.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 {
|
||||
match raw.map(str::trim) {
|
||||
Some(root) if !root.is_empty() => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
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}")),
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ pub mod db;
|
||||
pub mod error;
|
||||
pub mod http;
|
||||
pub mod json;
|
||||
pub mod named_entity;
|
||||
pub mod storage_paths;
|
||||
pub mod time;
|
||||
pub mod tracing;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -91,7 +91,12 @@ fn analyze_document(
|
||||
let version: DocumentVersion = document_versions::table
|
||||
.find(payload.document_version_id)
|
||||
.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 {
|
||||
return Err("document/version mismatch".into());
|
||||
@@ -100,7 +105,12 @@ fn analyze_document(
|
||||
let document: Document = documents::table
|
||||
.find(payload.document_id)
|
||||
.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;
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -89,24 +89,26 @@ impl JobHandler for IndexDocumentTextJob {
|
||||
let client = Client::new();
|
||||
|
||||
let state_clone = state.clone();
|
||||
let context = match task::spawn_blocking(move || load_context(state_clone, &payload)).await
|
||||
{
|
||||
Ok(Ok(ctx)) => ctx,
|
||||
Ok(Err(err)) => {
|
||||
warn!(job_id = %job.id, error = %err, "index job will retry");
|
||||
return JobExecution::Retry {
|
||||
delay: Duration::from_secs(30),
|
||||
error: err,
|
||||
};
|
||||
}
|
||||
Err(join_err) => {
|
||||
error!(job_id = %job.id, error = %join_err, "index task panicked");
|
||||
return JobExecution::Retry {
|
||||
delay: Duration::from_secs(60),
|
||||
error: format!("worker panicked: {join_err}"),
|
||||
};
|
||||
}
|
||||
};
|
||||
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(Err(err)) => {
|
||||
warn!(job_id = %job.id, error = %err, "index job will retry");
|
||||
return JobExecution::Retry {
|
||||
delay: Duration::from_secs(30),
|
||||
error: err,
|
||||
};
|
||||
}
|
||||
Err(join_err) => {
|
||||
error!(job_id = %job.id, error = %join_err, "index task panicked");
|
||||
return JobExecution::Retry {
|
||||
delay: Duration::from_secs(60),
|
||||
error: format!("worker panicked: {join_err}"),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
if context.text_s3_key.is_none() {
|
||||
warn!(job_id = %job.id, "missing OCR text asset; failing indexing job");
|
||||
@@ -166,12 +168,18 @@ struct IndexContext {
|
||||
text_s3_key: Option<String>,
|
||||
}
|
||||
|
||||
fn load_context(state: Arc<AppState>, payload: &IndexPayload) -> Result<IndexContext, String> {
|
||||
let mut base_conn = state.db_unscoped().map_err(|err| format!("{err:?}"))?;
|
||||
fn load_context(
|
||||
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
|
||||
.find(payload.document_version_id)
|
||||
.first(&mut base_conn)
|
||||
.first(&mut conn)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
|
||||
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
|
||||
.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
|
||||
.db_for_tenant(tenant_id)
|
||||
.first(&mut conn)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
|
||||
let text_s3_key: Option<String> = document_asset_objects::table
|
||||
|
||||
@@ -13,6 +13,7 @@ use crate::{
|
||||
};
|
||||
|
||||
pub mod analyze;
|
||||
pub mod common;
|
||||
pub mod index;
|
||||
pub mod ocr;
|
||||
pub mod tenants;
|
||||
|
||||
+61
-74
@@ -24,13 +24,16 @@ use crate::{
|
||||
Document, DocumentAsset, DocumentAssetObject, DocumentVersion, NewDocumentAsset,
|
||||
NewDocumentAssetObject,
|
||||
},
|
||||
schema::{document_asset_objects, document_assets, document_versions, documents},
|
||||
schema::{document_asset_objects, document_assets},
|
||||
state::AppState,
|
||||
storage::TenantStorage,
|
||||
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";
|
||||
const MIN_TEXT_LENGTH: usize = 50;
|
||||
@@ -74,25 +77,28 @@ impl JobHandler for GenerateOcrTextJob {
|
||||
|
||||
let state_clone = state.clone();
|
||||
let payload_clone = payload.clone();
|
||||
let context =
|
||||
match task::spawn_blocking(move || load_ocr_context(state_clone, &payload_clone)).await
|
||||
{
|
||||
Ok(Ok(ctx)) => ctx,
|
||||
Ok(Err(err)) => {
|
||||
warn!(job_id = %job.id, error = %err, "ocr job will retry");
|
||||
return JobExecution::Retry {
|
||||
delay: Duration::from_secs(30),
|
||||
error: err,
|
||||
};
|
||||
}
|
||||
Err(join_err) => {
|
||||
error!(job_id = %job.id, error = %join_err, "ocr task panicked");
|
||||
return JobExecution::Retry {
|
||||
delay: Duration::from_secs(60),
|
||||
error: format!("worker panicked: {join_err}"),
|
||||
};
|
||||
}
|
||||
};
|
||||
let tenant_id = job.tenant_id;
|
||||
let context = match task::spawn_blocking(move || {
|
||||
load_ocr_context(state_clone, tenant_id, payload_clone)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(Ok(ctx)) => ctx,
|
||||
Ok(Err(err)) => {
|
||||
warn!(job_id = %job.id, error = %err, "ocr job will retry");
|
||||
return JobExecution::Retry {
|
||||
delay: Duration::from_secs(30),
|
||||
error: err,
|
||||
};
|
||||
}
|
||||
Err(join_err) => {
|
||||
error!(job_id = %job.id, error = %join_err, "ocr task panicked");
|
||||
return JobExecution::Retry {
|
||||
delay: Duration::from_secs(60),
|
||||
error: format!("worker panicked: {join_err}"),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
if context.skip {
|
||||
info!(job_id = %job.id, "ocr already present; skipping");
|
||||
@@ -192,7 +198,7 @@ impl JobHandler for GenerateOcrTextJob {
|
||||
.await
|
||||
{
|
||||
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");
|
||||
}
|
||||
JobExecution::Success
|
||||
@@ -233,55 +239,40 @@ struct OcrGeneration {
|
||||
source: &'static str,
|
||||
}
|
||||
|
||||
fn load_ocr_context(state: Arc<AppState>, payload: &OcrPayload) -> Result<OcrContext, String> {
|
||||
let mut base_conn = state.db_unscoped().map_err(|err| format!("{err:?}"))?;
|
||||
|
||||
let version: DocumentVersion = document_versions::table
|
||||
.find(payload.document_version_id)
|
||||
.first(&mut base_conn)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
|
||||
if version.document_id != payload.document_id {
|
||||
return Err("document/version mismatch".into());
|
||||
}
|
||||
|
||||
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);
|
||||
fn load_ocr_context(
|
||||
state: Arc<AppState>,
|
||||
tenant_id: Uuid,
|
||||
payload: OcrPayload,
|
||||
) -> Result<OcrContext, String> {
|
||||
let base = load_document_version(
|
||||
state.as_ref(),
|
||||
tenant_id,
|
||||
payload.document_id,
|
||||
payload.document_version_id,
|
||||
)?;
|
||||
|
||||
let mut conn = state
|
||||
.db_for_tenant(tenant_id)
|
||||
.db_for_tenant(base.tenant_id)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
|
||||
let existing_asset: Option<DocumentAsset> = document_assets::table
|
||||
.filter(document_assets::document_version_id.eq(payload.document_version_id))
|
||||
.filter(document_assets::asset_type.eq(OCR_TEXT_ASSET_TYPE))
|
||||
.filter(document_assets::tenant_id.eq(tenant_id))
|
||||
.first(&mut conn)
|
||||
.optional()
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
let mut assets = load_version_assets(
|
||||
&mut conn,
|
||||
base.tenant_id,
|
||||
base.version.id,
|
||||
&[OCR_TEXT_ASSET_TYPE],
|
||||
)?;
|
||||
|
||||
let existing_objects: Vec<DocumentAssetObject> = if let Some(asset) = &existing_asset {
|
||||
document_asset_objects::table
|
||||
.filter(document_asset_objects::asset_id.eq(asset.id))
|
||||
.filter(document_asset_objects::tenant_id.eq(tenant_id))
|
||||
.order(document_asset_objects::ordinal.asc())
|
||||
.load(&mut conn)
|
||||
.map_err(|err| format!("{err:?}"))?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let (existing_asset, existing_objects) = assets
|
||||
.remove(OCR_TEXT_ASSET_TYPE)
|
||||
.map(|entry| (Some(entry.asset), entry.objects))
|
||||
.unwrap_or((None, Vec::new()));
|
||||
|
||||
let is_pdf = document_is_pdf(&document);
|
||||
let is_pdf = document_is_pdf(&base.document);
|
||||
if !is_pdf {
|
||||
return Ok(OcrContext {
|
||||
document,
|
||||
version,
|
||||
existing_asset: existing_asset,
|
||||
document: base.document,
|
||||
version: base.version,
|
||||
existing_asset,
|
||||
existing_objects,
|
||||
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;
|
||||
|
||||
Ok(OcrContext {
|
||||
document,
|
||||
version,
|
||||
document: base.document,
|
||||
version: base.version,
|
||||
existing_asset,
|
||||
existing_objects,
|
||||
skip,
|
||||
@@ -498,15 +489,11 @@ fn persist_ocr_metadata(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn enqueue_index_job(state: &AppState, payload: &OcrPayload) -> Result<(), String> {
|
||||
let mut base_conn = state.db_unscoped().map_err(|err| format!("{err:?}"))?;
|
||||
let tenant_id: Uuid = documents::table
|
||||
.find(payload.document_id)
|
||||
.select(documents::tenant_id)
|
||||
.first(&mut base_conn)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
drop(base_conn);
|
||||
|
||||
fn enqueue_index_job(
|
||||
state: &AppState,
|
||||
tenant_id: Uuid,
|
||||
payload: &OcrPayload,
|
||||
) -> Result<(), String> {
|
||||
let mut conn = state
|
||||
.db_for_tenant(tenant_id)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
|
||||
@@ -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 {
|
||||
warn!(
|
||||
job_id = %job.id,
|
||||
|
||||
@@ -18,15 +18,16 @@ use crate::{
|
||||
Document, DocumentAsset, DocumentAssetObject, DocumentVersion, NewDocumentAsset,
|
||||
NewDocumentAssetObject,
|
||||
},
|
||||
schema::{document_asset_objects, document_assets, document_versions, documents},
|
||||
schema::{document_asset_objects, document_assets, document_versions},
|
||||
state::AppState,
|
||||
storage::TenantStorage,
|
||||
utils::storage_paths::document_asset_object_key,
|
||||
};
|
||||
|
||||
use super::{
|
||||
analyze::determine_thumbnail_support, fetch_version_object, handle_fetch_error, JobExecution,
|
||||
JobHandler,
|
||||
analyze::determine_thumbnail_support,
|
||||
common::{load_document_version, load_version_assets},
|
||||
fetch_version_object, handle_fetch_error, JobExecution, JobHandler,
|
||||
};
|
||||
|
||||
const THUMBNAIL_WIDTH: u32 = 512;
|
||||
@@ -36,7 +37,7 @@ const PREVIEW_HEIGHT: u32 = THUMBNAIL_HEIGHT * 4;
|
||||
const THUMBNAIL_ASSET_TYPE: &str = "thumbnail";
|
||||
const PREVIEW_ASSET_TYPE: &str = "preview";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
struct ThumbnailPayload {
|
||||
document_id: Uuid,
|
||||
document_version_id: Uuid,
|
||||
@@ -74,25 +75,29 @@ impl JobHandler for GenerateThumbnailsJob {
|
||||
};
|
||||
|
||||
let state_clone = state.clone();
|
||||
let initial =
|
||||
match task::spawn_blocking(move || load_thumbnail_context(state_clone, &payload)).await
|
||||
{
|
||||
Ok(Ok(ctx)) => ctx,
|
||||
Ok(Err(err)) => {
|
||||
warn!(job_id = %job.id, error = %err, "thumbnail job will retry");
|
||||
return JobExecution::Retry {
|
||||
delay: Duration::from_secs(30),
|
||||
error: err,
|
||||
};
|
||||
}
|
||||
Err(join_err) => {
|
||||
error!(job_id = %job.id, error = %join_err, "thumbnail task panicked");
|
||||
return JobExecution::Retry {
|
||||
delay: Duration::from_secs(60),
|
||||
error: format!("worker panicked: {join_err}"),
|
||||
};
|
||||
}
|
||||
};
|
||||
let tenant_id = job.tenant_id;
|
||||
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(Err(err)) => {
|
||||
warn!(job_id = %job.id, error = %err, "thumbnail job will retry");
|
||||
return JobExecution::Retry {
|
||||
delay: Duration::from_secs(30),
|
||||
error: err,
|
||||
};
|
||||
}
|
||||
Err(join_err) => {
|
||||
error!(job_id = %job.id, error = %join_err, "thumbnail task panicked");
|
||||
return JobExecution::Retry {
|
||||
delay: Duration::from_secs(60),
|
||||
error: format!("worker panicked: {join_err}"),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
if initial.skip {
|
||||
info!(job_id = %job.id, "thumbnails already exist; skipping");
|
||||
@@ -122,8 +127,15 @@ impl JobHandler for GenerateThumbnailsJob {
|
||||
let state_clone = state.clone();
|
||||
let document_id = initial.document.id;
|
||||
let version_id = initial.version.id;
|
||||
let tenant_id = initial.tenant_id;
|
||||
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
|
||||
{
|
||||
@@ -371,6 +383,7 @@ struct ThumbnailContext {
|
||||
existing_preview: Option<DocumentAsset>,
|
||||
existing_preview_objects: Vec<DocumentAssetObject>,
|
||||
skip: bool,
|
||||
tenant_id: Uuid,
|
||||
}
|
||||
|
||||
struct GeneratedImage {
|
||||
@@ -404,70 +417,43 @@ struct AssetPersistence {
|
||||
|
||||
fn load_thumbnail_context(
|
||||
state: Arc<AppState>,
|
||||
payload: &ThumbnailPayload,
|
||||
tenant_id: Uuid,
|
||||
payload: ThumbnailPayload,
|
||||
) -> 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
|
||||
.find(payload.document_version_id)
|
||||
.first(&mut conn)
|
||||
let mut conn = state
|
||||
.db_for_tenant(base.tenant_id)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
|
||||
if version.document_id != payload.document_id {
|
||||
return Err("document/version mismatch".into());
|
||||
}
|
||||
let mut assets = load_version_assets(
|
||||
&mut conn,
|
||||
base.tenant_id,
|
||||
base.version.id,
|
||||
&[THUMBNAIL_ASSET_TYPE, PREVIEW_ASSET_TYPE],
|
||||
)?;
|
||||
|
||||
let document: Document = documents::table
|
||||
.find(payload.document_id)
|
||||
.first(&mut conn)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
let (existing_thumbnail, existing_thumbnail_objects) = assets
|
||||
.remove(THUMBNAIL_ASSET_TYPE)
|
||||
.map(|entry| (Some(entry.asset), entry.objects))
|
||||
.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
|
||||
.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);
|
||||
let (supported, _) = determine_thumbnail_support(&base.document);
|
||||
if !supported {
|
||||
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
|
||||
.as_ref()
|
||||
.and_then(|asset| asset.cardinality)
|
||||
@@ -488,13 +474,14 @@ fn load_thumbnail_context(
|
||||
&& !needs_regeneration;
|
||||
|
||||
Ok(ThumbnailContext {
|
||||
document,
|
||||
version,
|
||||
document: base.document,
|
||||
version: base.version,
|
||||
existing_thumbnail,
|
||||
existing_thumbnail_objects,
|
||||
existing_preview,
|
||||
existing_preview_objects,
|
||||
skip,
|
||||
tenant_id: base.tenant_id,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -736,15 +723,13 @@ fn persist_assets_metadata(
|
||||
|
||||
fn persist_document_page_count(
|
||||
state: Arc<AppState>,
|
||||
tenant_id: Uuid,
|
||||
document_id: Uuid,
|
||||
document_version_id: Uuid,
|
||||
page_count: u32,
|
||||
) -> Result<(), String> {
|
||||
let mut conn = state.db_unscoped().map_err(|err| format!("{err:?}"))?;
|
||||
let tenant_id: Uuid = documents::table
|
||||
.find(document_id)
|
||||
.select(documents::tenant_id)
|
||||
.first(&mut conn)
|
||||
let mut conn = state
|
||||
.db_for_tenant(tenant_id)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
|
||||
let existing_metadata: Value = document_versions::table
|
||||
|
||||
+23
-19
@@ -2,16 +2,16 @@ mod common;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
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 common::{acquire_db_lock, body_to_vec, TestApp};
|
||||
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::RngCore;
|
||||
use serde::Deserialize;
|
||||
@@ -29,8 +29,10 @@ struct AuthenticatedUser {
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ErrorResponse {
|
||||
struct ApiErrorResponse {
|
||||
error: String,
|
||||
#[serde(default)]
|
||||
_code: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -53,8 +55,10 @@ struct SignupStartResponse {
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TenantSelectionResponse {
|
||||
access_token: String,
|
||||
tenants: Vec<TenantSummary>,
|
||||
#[serde(rename = "access_token")]
|
||||
_access_token: String,
|
||||
#[serde(rename = "tenants")]
|
||||
_tenants: Vec<TenantSummary>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -98,7 +102,7 @@ async fn login_rejects_unknown_user() -> Result<()> {
|
||||
let response = app.post_json("/api/auth/login", &payload, None).await?;
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
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");
|
||||
|
||||
app.cleanup().await?;
|
||||
@@ -169,8 +173,8 @@ async fn passkey_register_start_creates_challenge() -> Result<()> {
|
||||
let challenge_id = challenge.challenge_id;
|
||||
|
||||
app.with_conn(move |conn| {
|
||||
use backend::schema::webauthn_challenges::dsl;
|
||||
use diesel::dsl::{exists, select};
|
||||
use papercrate::schema::webauthn_challenges::dsl;
|
||||
|
||||
let exists: bool = select(exists(
|
||||
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);
|
||||
|
||||
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
|
||||
.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?;
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
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");
|
||||
|
||||
app.cleanup().await?;
|
||||
@@ -574,18 +578,18 @@ async fn login_with_session(
|
||||
let username = username.to_string();
|
||||
let state = app.state.clone();
|
||||
app.with_conn(move |conn| {
|
||||
use backend::schema::user_memberships::dsl as memberships_dsl;
|
||||
use backend::schema::users::dsl as users_dsl;
|
||||
use papercrate::schema::user_memberships::dsl as memberships_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))
|
||||
.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))
|
||||
.first(conn)?;
|
||||
|
||||
let tenant: backend::models::Tenant =
|
||||
let tenant: papercrate::models::Tenant =
|
||||
tenants::table.find(membership.tenant_id).first(conn)?;
|
||||
|
||||
let now = Utc::now();
|
||||
|
||||
+108
-36
@@ -8,17 +8,6 @@ use async_trait::async_trait;
|
||||
use axum::body::Body;
|
||||
use axum::http::{header, Method, Request};
|
||||
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 diesel::connection::SimpleConnection;
|
||||
use diesel::prelude::*;
|
||||
@@ -27,9 +16,20 @@ use diesel::PgConnection;
|
||||
use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
|
||||
use http_body_util::BodyExt;
|
||||
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::RngCore;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::Serialize;
|
||||
use serde_json::{self, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
use tokio::sync::Mutex;
|
||||
@@ -37,6 +37,11 @@ use tower::util::ServiceExt;
|
||||
use uuid::Uuid;
|
||||
|
||||
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(()));
|
||||
|
||||
@@ -220,7 +225,7 @@ impl TestApp {
|
||||
id: Uuid::new_v4(),
|
||||
username,
|
||||
};
|
||||
diesel::insert_into(backend::schema::users::table)
|
||||
diesel::insert_into(papercrate::schema::users::table)
|
||||
.values(&user)
|
||||
.execute(conn)
|
||||
.context("failed to insert user")?;
|
||||
@@ -231,7 +236,7 @@ impl TestApp {
|
||||
tenant_id,
|
||||
};
|
||||
|
||||
diesel::insert_into(backend::schema::user_memberships::table)
|
||||
diesel::insert_into(papercrate::schema::user_memberships::table)
|
||||
.values(&membership)
|
||||
.execute(conn)
|
||||
.context("failed to insert user membership")?;
|
||||
@@ -258,7 +263,7 @@ impl TestApp {
|
||||
nickname,
|
||||
};
|
||||
|
||||
diesel::insert_into(backend::schema::user_passkeys::table)
|
||||
diesel::insert_into(papercrate::schema::user_passkeys::table)
|
||||
.values(&passkey)
|
||||
.execute(conn)
|
||||
.context("failed to insert passkey")?;
|
||||
@@ -272,7 +277,7 @@ impl TestApp {
|
||||
let name_value = TEST_TENANT_NAME.to_string();
|
||||
let quickwit_enabled = self.state.config.quickwit_endpoint.is_some();
|
||||
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
|
||||
.filter(tenants_dsl::name.eq(&name_value))
|
||||
@@ -332,9 +337,9 @@ impl TestApp {
|
||||
let username = username.to_string();
|
||||
let state = self.state.clone();
|
||||
self.with_conn(move |conn| {
|
||||
use backend::schema::tenants::dsl as tenants_dsl;
|
||||
use backend::schema::user_memberships::dsl as memberships_dsl;
|
||||
use backend::schema::users::dsl as users_dsl;
|
||||
use papercrate::schema::tenants::dsl as tenants_dsl;
|
||||
use papercrate::schema::user_memberships::dsl as memberships_dsl;
|
||||
use papercrate::schema::users::dsl as users_dsl;
|
||||
|
||||
let user: User = users_dsl::users
|
||||
.filter(users_dsl::username.eq(&username))
|
||||
@@ -381,7 +386,7 @@ impl TestApp {
|
||||
#[allow(dead_code)]
|
||||
pub async fn clear_jobs(&self) -> Result<()> {
|
||||
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)
|
||||
.execute(conn)
|
||||
.context("failed to clear jobs")?;
|
||||
@@ -394,7 +399,7 @@ impl TestApp {
|
||||
pub async fn jobs_by_type(&self, ty: &str) -> Result<Vec<Job>> {
|
||||
let ty = ty.to_string();
|
||||
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
|
||||
.filter(job_type_col.eq(&ty))
|
||||
.load::<Job>(conn)
|
||||
@@ -691,12 +696,76 @@ pub async fn body_to_vec(body: Body) -> Result<Vec<u8>> {
|
||||
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<()> {
|
||||
let pool = pool.clone();
|
||||
tokio::task::spawn_blocking(move || -> Result<()> {
|
||||
let mut conn = pool
|
||||
.get()
|
||||
.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)
|
||||
.map_err(|err| anyhow!("failed to run migrations: {err}"))?;
|
||||
truncate_all(&mut conn)?;
|
||||
@@ -709,21 +778,24 @@ async fn prepare_database(pool: &PgPool) -> Result<()> {
|
||||
fn truncate_all(conn: &mut PgConnection) -> Result<()> {
|
||||
conn.batch_execute(
|
||||
"TRUNCATE TABLE \
|
||||
document_asset_objects, \
|
||||
document_assets, \
|
||||
document_correspondents, \
|
||||
correspondents, \
|
||||
document_tags, \
|
||||
document_versions, \
|
||||
documents, \
|
||||
folders, \
|
||||
jobs, \
|
||||
refresh_tokens, \
|
||||
tags, \
|
||||
webdav_tokens, \
|
||||
user_memberships, \
|
||||
users, \
|
||||
tenants \
|
||||
tenant.document_asset_objects, \
|
||||
tenant.document_assets, \
|
||||
tenant.document_correspondents, \
|
||||
tenant.correspondents, \
|
||||
tenant.document_tags, \
|
||||
tenant.document_versions, \
|
||||
tenant.documents, \
|
||||
tenant.folders, \
|
||||
shared.jobs, \
|
||||
tenant.refresh_tokens, \
|
||||
tenant.tags, \
|
||||
tenant.webdav_tokens, \
|
||||
shared.webauthn_challenges, \
|
||||
shared.user_passkeys, \
|
||||
tenant.user_memberships, \
|
||||
shared.users, \
|
||||
shared.magic_tokens, \
|
||||
shared.tenants \
|
||||
RESTART IDENTITY CASCADE;",
|
||||
)
|
||||
.context("failed to truncate tables")?;
|
||||
|
||||
@@ -14,7 +14,8 @@ struct DocumentDetail {
|
||||
#[derive(Deserialize)]
|
||||
struct DocumentSummary {
|
||||
id: Uuid,
|
||||
title: String,
|
||||
#[serde(rename = "title")]
|
||||
_title: String,
|
||||
#[serde(default)]
|
||||
correspondents: Vec<DocumentCorrespondentSummary>,
|
||||
}
|
||||
|
||||
@@ -12,6 +12,13 @@ struct DocumentDetail {
|
||||
document: DocumentInfo,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ApiErrorResponse {
|
||||
error: String,
|
||||
#[serde(default)]
|
||||
code: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DocumentInfo {
|
||||
id: Uuid,
|
||||
@@ -25,8 +32,6 @@ struct DocumentInfo {
|
||||
metadata: Value,
|
||||
tags: Vec<TagSummary>,
|
||||
#[serde(default)]
|
||||
correspondents: Vec<DocumentCorrespondentInfo>,
|
||||
#[serde(default)]
|
||||
current_version: Option<DocumentVersionPayload>,
|
||||
}
|
||||
|
||||
@@ -81,12 +86,6 @@ struct TagSummary {
|
||||
label: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DocumentCorrespondentInfo {
|
||||
id: Uuid,
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AnalyzeJobPayload {
|
||||
document_id: Uuid,
|
||||
@@ -95,13 +94,6 @@ struct AnalyzeJobPayload {
|
||||
force: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ErrorResponse {
|
||||
error: String,
|
||||
#[serde(default)]
|
||||
code: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct FolderResponse {
|
||||
folder: FolderInfo,
|
||||
@@ -1059,7 +1051,7 @@ async fn patch_document_updates_title_and_handles_conflict() -> Result<()> {
|
||||
.await?;
|
||||
assert_eq!(conflict.status(), StatusCode::CONFLICT);
|
||||
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"));
|
||||
|
||||
app.cleanup().await?;
|
||||
@@ -1241,7 +1233,7 @@ async fn patch_document_validation_errors() -> Result<()> {
|
||||
.await?;
|
||||
assert_eq!(empty_title.status(), StatusCode::BAD_REQUEST);
|
||||
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");
|
||||
|
||||
let empty_issued = app
|
||||
@@ -1253,7 +1245,7 @@ async fn patch_document_validation_errors() -> Result<()> {
|
||||
.await?;
|
||||
assert_eq!(empty_issued.status(), StatusCode::BAD_REQUEST);
|
||||
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");
|
||||
|
||||
let invalid_merge = app
|
||||
@@ -1269,7 +1261,7 @@ async fn patch_document_validation_errors() -> Result<()> {
|
||||
.await?;
|
||||
assert_eq!(invalid_merge.status(), StatusCode::BAD_REQUEST);
|
||||
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!(
|
||||
merge_error.error,
|
||||
"metadata value must be a JSON object when replace is false"
|
||||
@@ -1305,7 +1297,7 @@ async fn patch_document_validation_errors() -> Result<()> {
|
||||
.await?;
|
||||
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_error: ErrorResponse = serde_json::from_slice(&merge_after_body)?;
|
||||
let merge_after_error: ApiErrorResponse = serde_json::from_slice(&merge_after_body)?;
|
||||
assert_eq!(
|
||||
merge_after_error.error,
|
||||
"existing metadata is not an object; set replace=true to overwrite"
|
||||
@@ -1320,7 +1312,7 @@ async fn patch_document_validation_errors() -> Result<()> {
|
||||
.await?;
|
||||
assert_eq!(malformed_timestamp.status(), StatusCode::BAD_REQUEST);
|
||||
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!(
|
||||
malformed_error
|
||||
.error
|
||||
|
||||
@@ -2,13 +2,13 @@ mod common;
|
||||
|
||||
use anyhow::Result;
|
||||
use axum::http::StatusCode;
|
||||
use backend::models::{NewUser, NewUserMembership, Tag, TenantStatus};
|
||||
use backend::schema::{
|
||||
use common::{acquire_db_lock, body_to_vec, TestApp};
|
||||
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,
|
||||
users::dsl as users_dsl,
|
||||
};
|
||||
use common::{acquire_db_lock, body_to_vec, TestApp};
|
||||
use diesel::prelude::*;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -3,13 +3,13 @@ mod common;
|
||||
use anyhow::Result;
|
||||
use axum::body::Body;
|
||||
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;
|
||||
use common::{acquire_db_lock, body_to_vec, TestApp};
|
||||
use diesel::prelude::*;
|
||||
use papercrate::models::WebdavToken;
|
||||
use papercrate::routes::webdav;
|
||||
use papercrate::schema::webdav_tokens;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use tower::ServiceExt;
|
||||
@@ -54,6 +54,20 @@ async fn webdav_token_api_crud() -> Result<()> {
|
||||
assert_eq!(created.info.label.as_deref(), Some("dav"));
|
||||
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(®enerate_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
|
||||
.get("/api/profile/webdav-tokens", Some(&access_token))
|
||||
.await?;
|
||||
@@ -106,9 +120,10 @@ async fn webdav_basic_auth_uses_tokens() -> Result<()> {
|
||||
let token_id = created.info.id;
|
||||
|
||||
let router = webdav::create_router().with_state(app.state.clone());
|
||||
let original_secret = created.token.clone();
|
||||
let auth_header = format!(
|
||||
"Basic {}",
|
||||
BASE64.encode(format!("{}:{}", username, created.token))
|
||||
BASE64.encode(format!("{}:{}", username, original_secret))
|
||||
);
|
||||
|
||||
let propfind = Method::from_bytes(b"PROPFIND")?;
|
||||
@@ -131,6 +146,57 @@ async fn webdav_basic_auth_uses_tokens() -> Result<()> {
|
||||
.await?;
|
||||
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(®enerate_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
|
||||
.delete(
|
||||
&format!("/api/profile/webdav-tokens/{token_id}"),
|
||||
@@ -142,7 +208,7 @@ async fn webdav_basic_auth_uses_tokens() -> Result<()> {
|
||||
let failure_request = Request::builder()
|
||||
.method(propfind)
|
||||
.uri("/")
|
||||
.header(header::AUTHORIZATION, auth_header)
|
||||
.header(header::AUTHORIZATION, new_secret_header)
|
||||
.header("depth", "0")
|
||||
.body(Body::empty())?;
|
||||
let response = router.oneshot(failure_request).await?;
|
||||
|
||||
+1
-18
@@ -49,24 +49,7 @@ services:
|
||||
command: >
|
||||
/bin/sh -c "
|
||||
echo 'Running database migrations' &&
|
||||
diesel migration run &&
|
||||
echo 'Ensuring tenant admin exists' &&
|
||||
if papercrate-admin list-tenants | grep -q '^admin '; then
|
||||
echo 'tenant admin already exists';
|
||||
else
|
||||
papercrate-admin create-tenant admin;
|
||||
fi &&
|
||||
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"
|
||||
diesel migration run
|
||||
"
|
||||
user: root
|
||||
restart: "no"
|
||||
|
||||
+121
-51
@@ -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:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: papercrate
|
||||
POSTGRES_PASSWORD: papercrate_dev
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set}
|
||||
POSTGRES_DB: papercrate
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./backend/migrations:/docker-entrypoint-initdb.d
|
||||
- ./backend/postgres-init:/docker-entrypoint-initdb.d
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U papercrate"]
|
||||
interval: 5s
|
||||
test: ["CMD-SHELL", "pg_isready -U papercrate -d papercrate"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
ports:
|
||||
- "${POSTGRES_PORT:-5432}:5432"
|
||||
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
command: server /data --console-address ":9001"
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MINIO_ROOT_USER: minioadmin
|
||||
MINIO_ROOT_PASSWORD: minioadmin
|
||||
ports:
|
||||
- "9000:9000" # S3 API
|
||||
- "9001:9001" # Console
|
||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-papercrate}
|
||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD must be set}
|
||||
volumes:
|
||||
- minio_data:/data
|
||||
healthcheck:
|
||||
@@ -32,28 +64,35 @@ services:
|
||||
interval: 30s
|
||||
timeout: 20s
|
||||
retries: 3
|
||||
ports:
|
||||
- "${MINIO_API_PORT:-9000}:9000"
|
||||
- "${MINIO_CONSOLE_PORT:-9001}:9001"
|
||||
|
||||
createbuckets:
|
||||
image: minio/mc:latest
|
||||
depends_on:
|
||||
minio:
|
||||
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: >
|
||||
/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;
|
||||
set -e;
|
||||
/usr/bin/mc alias set papercrate http://minio:9000 $${MINIO_ROOT_USER} $${MINIO_ROOT_PASSWORD};
|
||||
/usr/bin/mc mb papercrate/$${S3_BUCKET} --ignore-existing;
|
||||
exit 0;
|
||||
"
|
||||
restart: "no"
|
||||
|
||||
quickwit:
|
||||
image: quickwit/quickwit:0.8.2
|
||||
command: ["run"]
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
QW_ENABLE_API_AUTH: "false"
|
||||
QW_DATA_DIR: /quickwit/data
|
||||
ports:
|
||||
- "7280:7280"
|
||||
volumes:
|
||||
- quickwit_data:/quickwit/data
|
||||
healthcheck:
|
||||
@@ -61,49 +100,80 @@ services:
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
ports:
|
||||
- "${QUICKWIT_PORT:-7280}:7280"
|
||||
|
||||
admin-bootstrap:
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
image: papercrate/backend:latest
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
minio:
|
||||
condition: service_healthy
|
||||
createbuckets:
|
||||
condition: service_completed_successfully
|
||||
quickwit:
|
||||
condition: service_healthy
|
||||
migrator:
|
||||
condition: service_completed_successfully
|
||||
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 &&
|
||||
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
|
||||
<<: *app-env
|
||||
ports:
|
||||
- "${API_PORT:-3000}:3000"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://localhost:3000/api/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
worker:
|
||||
image: papercrate/backend:latest
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
<<: *app-env
|
||||
entrypoint: ["/usr/local/bin/papercrate-worker"]
|
||||
restart: unless-stopped
|
||||
|
||||
webdav:
|
||||
image: papercrate/backend:latest
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
<<: *app-env
|
||||
entrypoint: ["/usr/local/bin/papercrate-webdav"]
|
||||
ports:
|
||||
- "${WEBDAV_PORT:-3001}:3001"
|
||||
restart: unless-stopped
|
||||
|
||||
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"
|
||||
|
||||
volumes:
|
||||
|
||||
@@ -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:
|
||||
@@ -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 document’s 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
@@ -39,7 +39,7 @@ npm run build
|
||||
- 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
|
||||
- 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
|
||||
|
||||
## Assets
|
||||
|
||||
@@ -4,16 +4,25 @@ set -euo pipefail
|
||||
API_PROXY_PASS_TRIMMED="${API_PROXY_PASS:-}"
|
||||
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 {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
client_max_body_size ${MAX_BODY_SIZE};
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri /index.html;
|
||||
try_files \$uri /index.html;
|
||||
}
|
||||
BASE
|
||||
|
||||
@@ -21,6 +30,7 @@ if [ -n "$API_PROXY_PASS_TRIMMED" ]; then
|
||||
cat <<PROXY >> /etc/nginx/conf.d/default.conf
|
||||
|
||||
location /api/ {
|
||||
client_max_body_size ${MAX_BODY_SIZE};
|
||||
proxy_pass ${API_PROXY_PASS_TRIMMED};
|
||||
proxy_set_header Host \$host;
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
@@ -29,6 +39,7 @@ cat <<PROXY >> /etc/nginx/conf.d/default.conf
|
||||
}
|
||||
|
||||
location /download/ {
|
||||
client_max_body_size ${MAX_BODY_SIZE};
|
||||
proxy_pass ${API_PROXY_PASS_TRIMMED};
|
||||
proxy_set_header Host \$host;
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
/* Desktop workspace styles */
|
||||
.skeuo-main {
|
||||
.desk-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.skeuo-item {
|
||||
.desk-item {
|
||||
position: absolute;
|
||||
display: block;
|
||||
width: auto;
|
||||
@@ -18,7 +18,7 @@
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.skeuo-shell {
|
||||
.desk-shell {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -27,14 +27,14 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.skeuo-canvas {
|
||||
.desk-canvas {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.skeuo-empty {
|
||||
.desk-empty {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -47,32 +47,32 @@
|
||||
|
||||
|
||||
|
||||
.skeuo-item__body {
|
||||
.desk-item__body {
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.skeuo-item:focus-visible {
|
||||
.desk-item:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
.skeuo-item.is-dragging {
|
||||
.desk-item.is-dragging {
|
||||
cursor: grabbing;
|
||||
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-offset: 0.35rem;
|
||||
}
|
||||
|
||||
.skeuo-item.is-tag-pending .skeuo-item__card {
|
||||
.desk-item.is-tag-pending .desk-item__card {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.skeuo-item.is-filtered-out {
|
||||
.desk-item.is-filtered-out {
|
||||
opacity: 0.12;
|
||||
pointer-events: none;
|
||||
filter: blur(15px) grayscale(100%);
|
||||
@@ -80,7 +80,7 @@
|
||||
z-index: 0 !important;
|
||||
}
|
||||
|
||||
.skeuo-item__tags {
|
||||
.desk-item__tags {
|
||||
--tag-scale: 1;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -100,7 +100,7 @@
|
||||
pointer-events: auto;
|
||||
cursor: grab;
|
||||
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 {
|
||||
@@ -112,14 +112,14 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.skeuo-item__tags .tag-chip {
|
||||
.desk-item__tags .tag-chip {
|
||||
font-size: 0.85rem;
|
||||
padding: 0.18rem 0.55rem;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
|
||||
.skeuo-card__nav {
|
||||
.desk-card__nav {
|
||||
position: absolute;
|
||||
bottom: 1.8rem;
|
||||
left: 50%;
|
||||
@@ -132,12 +132,12 @@
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.skeuo-item__card:hover .skeuo-card__nav {
|
||||
.desk-item__card:hover .desk-card__nav {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.skeuo-card__nav-button {
|
||||
.desk-card__nav-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -146,45 +146,45 @@
|
||||
padding: 0.25em;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: oklch(0.22 0.06 260deg);
|
||||
color: var(--on-accent);
|
||||
background: var(--preview-nav-bg);
|
||||
color: var(--preview-nav-fg);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.skeuo-card__nav-button:hover:not([disabled]) {
|
||||
background: oklch(0.18 0.06 260deg);
|
||||
.desk-card__nav-button:hover:not([disabled]) {
|
||||
background: var(--preview-nav-bg-hover);
|
||||
}
|
||||
|
||||
.skeuo-card__nav-button:disabled {
|
||||
.desk-card__nav-button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.skeuo-card__nav-button:focus-visible {
|
||||
outline: 2px solid var(--accent, #2684ff);
|
||||
.desk-card__nav-button:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.skeuo-card__nav-button svg {
|
||||
.desk-card__nav-button svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.skeuo-item__tags .tag-chip--tear-pending {
|
||||
.desk-item__tags .tag-chip--tear-pending {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
body.skeuo-cursor-remove,
|
||||
body.skeuo-cursor-remove * {
|
||||
body.desk-cursor-remove,
|
||||
body.desk-cursor-remove * {
|
||||
cursor: not-allowed !important;
|
||||
}
|
||||
|
||||
.skeuo-item__shadow {
|
||||
.desk-item__shadow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.skeuo-item__card {
|
||||
.desk-item__card {
|
||||
position: relative;
|
||||
border-radius: 0;
|
||||
display: flex;
|
||||
@@ -192,22 +192,22 @@ body.skeuo-cursor-remove * {
|
||||
justify-content: center;
|
||||
width: 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;
|
||||
}
|
||||
|
||||
.skeuo-item__card img {
|
||||
.desk-item__card img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.skeuo-item__card--empty {
|
||||
.desk-item__card--empty {
|
||||
box-shadow: 0 12px 32px var(--shadow-medium);
|
||||
background:
|
||||
radial-gradient(circle at 28% 24%, color-mix(in oklch, white 32%, transparent), transparent 60%),
|
||||
radial-gradient(circle at 72% 78%, color-mix(in oklch, black 8%, transparent), transparent 65%),
|
||||
linear-gradient(135deg, #e6e1d6 0%, #d2cdc2 100%);
|
||||
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%),
|
||||
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%);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
@@ -217,14 +217,14 @@ body.skeuo-cursor-remove * {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.skeuo-item__placeholder {
|
||||
.desk-item__placeholder {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: normal;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.skeuo-item__empty {
|
||||
.desk-item__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@@ -236,10 +236,10 @@ body.skeuo-cursor-remove * {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.skeuo-item__title {
|
||||
.desk-item__title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
color: #3b3b3b;
|
||||
color: var(--fg);
|
||||
max-width: 90%;
|
||||
overflow: hidden;
|
||||
overflow-wrap: anywhere;
|
||||
|
||||
@@ -8,7 +8,9 @@ import React, {
|
||||
} from 'react';
|
||||
import { resolveDocumentAssetUrl, createAssetView } from './asset_manager';
|
||||
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 { preventAll } from './desktop/events';
|
||||
import useDocumentDrag from './desktop/useDocumentDrag';
|
||||
@@ -23,9 +25,6 @@ const DEFAULT_CANVAS_WIDTH = 1024;
|
||||
const DEFAULT_CANVAS_HEIGHT = 680;
|
||||
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_MAX = 340;
|
||||
const TAG_REMOVE_DISTANCE = 160;
|
||||
@@ -125,7 +124,7 @@ const readTransferData = (dataTransfer, mimeTypes) => {
|
||||
}
|
||||
} catch (error) {
|
||||
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 {
|
||||
return JSON.parse(raw);
|
||||
} catch (error) {
|
||||
console.warn('[skeuo] parseTagTransferPayload failed', error);
|
||||
console.warn('[desk] parseTagTransferPayload failed', error);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
@@ -210,8 +209,8 @@ const DesktopPreviewCard = ({
|
||||
onNavigatorSnapshot,
|
||||
]);
|
||||
const hasPreview = Boolean(currentUrl);
|
||||
const cardClasses = ['skeuo-item__card'];
|
||||
if (!hasPreview) cardClasses.push('skeuo-item__card--empty');
|
||||
const cardClasses = ['desk-item__card'];
|
||||
if (!hasPreview) cardClasses.push('desk-item__card--empty');
|
||||
const showNav = hasPreview && (cardinality > 1 || canGoPrev || canGoNext);
|
||||
|
||||
return (
|
||||
@@ -219,18 +218,18 @@ const DesktopPreviewCard = ({
|
||||
{hasPreview ? (
|
||||
<img src={currentUrl} alt={title} />
|
||||
) : (
|
||||
<div className="skeuo-item__empty">
|
||||
<div className="skeuo-item__placeholder">DOC</div>
|
||||
<div className="skeuo-item__title" title={title}>
|
||||
<div className="desk-item__empty">
|
||||
<div className="desk-item__placeholder">DOC</div>
|
||||
<div className="desk-item__title" title={title}>
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{showNav ? (
|
||||
<div className="skeuo-card__nav">
|
||||
<div className="desk-card__nav">
|
||||
<button
|
||||
type="button"
|
||||
className="skeuo-card__nav-button"
|
||||
className="desk-card__nav-button"
|
||||
onClick={(event) => {
|
||||
preventAll(event);
|
||||
navigator.goPrev();
|
||||
@@ -254,7 +253,7 @@ const DesktopPreviewCard = ({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="skeuo-card__nav-button"
|
||||
className="desk-card__nav-button"
|
||||
onClick={(event) => {
|
||||
preventAll(event);
|
||||
navigator.goNext();
|
||||
@@ -466,7 +465,7 @@ const usePreviewMetadata = (documents, getDocumentAsset, ensureAssetUrl) => {
|
||||
metadata = view.getPrimaryMetadata();
|
||||
}
|
||||
} 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 = () => {
|
||||
try {
|
||||
if (DEBUG_FOCUS) {
|
||||
console.log('[skeuo] focusCanvas -> attempting focus', canvas);
|
||||
console.log('[desk] focusCanvas -> attempting focus', canvas);
|
||||
}
|
||||
canvas.focus({ preventScroll: true });
|
||||
if (DEBUG_FOCUS) {
|
||||
console.log('[skeuo] focusCanvas: applied focus. activeElement:', document?.activeElement);
|
||||
console.log('[desk] focusCanvas: applied focus. activeElement:', document?.activeElement);
|
||||
}
|
||||
} catch (error) {
|
||||
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) {
|
||||
console.log('[skeuo] requestCanvasFocus -> scheduling deferred focus');
|
||||
console.log('[desk] requestCanvasFocus -> scheduling deferred focus');
|
||||
}
|
||||
|
||||
if (typeof window.requestAnimationFrame === 'function') {
|
||||
window.requestAnimationFrame(() => {
|
||||
if (DEBUG_FOCUS) {
|
||||
console.log('[skeuo] requestCanvasFocus -> executing deferred focus (rAF)');
|
||||
console.log('[desk] requestCanvasFocus -> executing deferred focus (rAF)');
|
||||
}
|
||||
focusTarget();
|
||||
});
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
if (DEBUG_FOCUS) {
|
||||
console.log('[skeuo] requestCanvasFocus -> executing deferred focus (timeout)');
|
||||
console.log('[desk] requestCanvasFocus -> executing deferred focus (timeout)');
|
||||
}
|
||||
focusTarget();
|
||||
}, 0);
|
||||
@@ -805,9 +804,9 @@ const DesktopWorkspace = ({
|
||||
}
|
||||
removalCursorActiveRef.current = active;
|
||||
if (active) {
|
||||
body.classList.add('skeuo-cursor-remove');
|
||||
body.classList.add('desk-cursor-remove');
|
||||
} 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;
|
||||
if (nodeEnv !== 'production') {
|
||||
console.log('[skeuo] canvas element', container);
|
||||
console.log('[desk] canvas element', container);
|
||||
}
|
||||
|
||||
const commitSize = () => {
|
||||
@@ -1170,7 +1169,7 @@ const syncLayoutSnapshot = useCallback(() => {
|
||||
id: doc.id,
|
||||
width: docWidth,
|
||||
height: docHeight,
|
||||
seedKey: resolveSizeKey(doc),
|
||||
seedKey: doc.id,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1241,7 +1240,7 @@ const syncLayoutSnapshot = useCallback(() => {
|
||||
return;
|
||||
}
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
@@ -1356,24 +1355,24 @@ const syncLayoutSnapshot = useCallback(() => {
|
||||
async (event, doc) => {
|
||||
if (!doc || !isTagTransfer(event) || typeof onAssignTagToDocument !== 'function') {
|
||||
if (DEBUG_DROP) {
|
||||
console.log('[skeuo] handleTagDropOnDoc: drop ignored', { doc, hasTransfer: isTagTransfer(event) });
|
||||
console.log('[desk] handleTagDropOnDoc: drop ignored', { doc, hasTransfer: isTagTransfer(event) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
preventAll(event);
|
||||
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);
|
||||
|
||||
const payload = parseTagTransferPayload(event);
|
||||
if (!payload && DEBUG_DROP) {
|
||||
console.warn('[skeuo] handleTagDropOnDoc: failed to parse payload');
|
||||
console.warn('[desk] handleTagDropOnDoc: failed to parse payload');
|
||||
}
|
||||
|
||||
if (!payload?.id) {
|
||||
if (DEBUG_DROP) {
|
||||
console.log('[skeuo] handleTagDropOnDoc: missing tag id payload', payload);
|
||||
console.log('[desk] handleTagDropOnDoc: missing tag id payload', payload);
|
||||
}
|
||||
requestCanvasFocus();
|
||||
return;
|
||||
@@ -1382,13 +1381,13 @@ const syncLayoutSnapshot = useCallback(() => {
|
||||
const tagId = payload.id;
|
||||
const sourceDocId = payload.sourceDocId || null;
|
||||
if (DEBUG_DROP) {
|
||||
console.log('[skeuo] handleTagDropOnDoc: parsed payload', { tagId, sourceDocId });
|
||||
console.log('[desk] handleTagDropOnDoc: parsed payload', { tagId, sourceDocId });
|
||||
}
|
||||
|
||||
if (sourceDocId && sourceDocId === doc.id) {
|
||||
markActiveTagDropHandled(tagId, sourceDocId);
|
||||
if (DEBUG_DROP) {
|
||||
console.log('[skeuo] handleTagDropOnDoc: drop from same doc ignored', tagId);
|
||||
console.log('[desk] handleTagDropOnDoc: drop from same doc ignored', tagId);
|
||||
}
|
||||
requestCanvasFocus();
|
||||
return;
|
||||
@@ -1400,7 +1399,7 @@ const syncLayoutSnapshot = useCallback(() => {
|
||||
if (alreadyAssigned) {
|
||||
markActiveTagDropHandled(tagId, sourceDocId);
|
||||
if (DEBUG_DROP) {
|
||||
console.log('[skeuo] handleTagDropOnDoc: tag already assigned', tagId);
|
||||
console.log('[desk] handleTagDropOnDoc: tag already assigned', tagId);
|
||||
}
|
||||
requestCanvasFocus();
|
||||
return;
|
||||
@@ -1408,7 +1407,7 @@ const syncLayoutSnapshot = useCallback(() => {
|
||||
|
||||
const movingBetweenDocuments = Boolean(sourceDocId && sourceDocId !== doc.id);
|
||||
if (DEBUG_DROP) {
|
||||
console.log('[skeuo] handleTagDropOnDoc: movingBetweenDocuments', movingBetweenDocuments);
|
||||
console.log('[desk] handleTagDropOnDoc: movingBetweenDocuments', movingBetweenDocuments);
|
||||
}
|
||||
|
||||
setPendingTagDocId(doc.id);
|
||||
@@ -1416,14 +1415,14 @@ const syncLayoutSnapshot = useCallback(() => {
|
||||
await onAssignTagToDocument({ documentId: doc.id, tagId, tag: payload });
|
||||
markActiveTagDropHandled(tagId, sourceDocId);
|
||||
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') {
|
||||
setPendingRemovalTag({ docId: sourceDocId, tagId });
|
||||
try {
|
||||
await onRemoveTagFromDocument(sourceDocId, tagId);
|
||||
if (DEBUG_DROP) {
|
||||
console.log('[skeuo] handleTagDropOnDoc: removed tag from source doc', sourceDocId);
|
||||
console.log('[desk] handleTagDropOnDoc: removed tag from source doc', sourceDocId);
|
||||
}
|
||||
} finally {
|
||||
setPendingRemovalTag(null);
|
||||
@@ -1432,7 +1431,7 @@ const syncLayoutSnapshot = useCallback(() => {
|
||||
} finally {
|
||||
setPendingTagDocId(null);
|
||||
if (DEBUG_DROP) {
|
||||
console.log('[skeuo] handleTagDropOnDoc: finalizing drop for tag', tagId);
|
||||
console.log('[desk] handleTagDropOnDoc: finalizing drop for tag', tagId);
|
||||
}
|
||||
requestCanvasFocus();
|
||||
}
|
||||
@@ -1554,7 +1553,7 @@ const syncLayoutSnapshot = useCallback(() => {
|
||||
event.dataTransfer.effectAllowed = 'copyMove';
|
||||
}
|
||||
} 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 });
|
||||
@@ -1563,7 +1562,7 @@ const syncLayoutSnapshot = useCallback(() => {
|
||||
event.dataTransfer?.setData('text/papercrate-tag', payload);
|
||||
event.dataTransfer?.setData('text/plain', tag.label || 'Tag');
|
||||
} 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;
|
||||
@@ -1594,7 +1593,7 @@ const syncLayoutSnapshot = useCallback(() => {
|
||||
try {
|
||||
event.dataTransfer.setDragImage(preview.clone, preview.offsetX, preview.offsetY);
|
||||
} 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';
|
||||
console.log('[skeuo] dragEnd dropEffect', dropEffect, 'dropHandled', state.dropHandled);
|
||||
console.log('[desk] dragEnd dropEffect', dropEffect, 'dropHandled', state.dropHandled);
|
||||
const shouldRemove =
|
||||
!state.dropHandled &&
|
||||
dropEffect === 'none' &&
|
||||
@@ -1690,7 +1689,7 @@ const syncLayoutSnapshot = useCallback(() => {
|
||||
(state.distance || 0) >= TAG_REMOVE_DISTANCE;
|
||||
|
||||
if (!shouldRemove) {
|
||||
console.log('[skeuo] dragEnd -> no removal. distance:', state.distance);
|
||||
console.log('[desk] dragEnd -> no removal. distance:', state.distance);
|
||||
scheduleShowNode();
|
||||
requestCanvasFocus();
|
||||
updateRemovalCursor(false);
|
||||
@@ -1703,7 +1702,7 @@ const syncLayoutSnapshot = useCallback(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
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) {
|
||||
console.error('Failed to remove tag after drag', error);
|
||||
scheduleShowNode();
|
||||
@@ -1857,20 +1856,20 @@ const DesktopWorkspaceView = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="skeuo-shell">
|
||||
<div className="desk-shell">
|
||||
<div
|
||||
className="skeuo-canvas"
|
||||
className="desk-canvas"
|
||||
ref={containerRef}
|
||||
onDragOver={handleCanvasDragOver}
|
||||
onDragLeave={handleCanvasDragLeave}
|
||||
onDrop={handleCanvasDrop}
|
||||
>
|
||||
{!allSizesReady ? (
|
||||
<div className="skeuo-empty">
|
||||
<div className="desk-empty">
|
||||
<p>Loading previews…</p>
|
||||
</div>
|
||||
) : 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>
|
||||
</div>
|
||||
) : (
|
||||
@@ -1917,7 +1916,7 @@ const DesktopWorkspaceView = () => {
|
||||
activeTagSet.size === 0 || docTagKeys.some((key) => activeTagSet.has(key));
|
||||
const dropActive = tagDropTargetId === doc.id;
|
||||
const dropPending = pendingTagDocId === doc.id;
|
||||
const itemClasses = ['skeuo-item'];
|
||||
const itemClasses = ['desk-item'];
|
||||
if (dragging) itemClasses.push('is-dragging');
|
||||
if (dropActive) itemClasses.push('is-tag-target');
|
||||
if (dropPending) itemClasses.push('is-tag-pending');
|
||||
@@ -1954,7 +1953,7 @@ const DesktopWorkspaceView = () => {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="skeuo-item__body">
|
||||
<div className="desk-item__body">
|
||||
<DesktopPreviewCard
|
||||
doc={doc}
|
||||
title={title}
|
||||
@@ -1964,7 +1963,7 @@ const DesktopWorkspaceView = () => {
|
||||
shouldLoad={shouldLoad}
|
||||
/>
|
||||
{tags.length > 0 && (
|
||||
<div className="skeuo-item__tags" aria-hidden="true">
|
||||
<div className="desk-item__tags" aria-hidden="true">
|
||||
{tags.map((tag) => {
|
||||
const key = tag.id || tag.label || String(tag);
|
||||
if (
|
||||
@@ -2019,42 +2018,50 @@ const DesktopWorkspaceView = () => {
|
||||
|
||||
export default DesktopWorkspace;
|
||||
|
||||
export const createDesktopSurface = ({ workspaceProps, renderSidebarToggle }) => {
|
||||
export const createDesktopSurface = ({
|
||||
workspaceProps,
|
||||
renderSidebarToggle,
|
||||
parentBreadcrumb,
|
||||
onNavigateParent,
|
||||
}) => {
|
||||
if (!workspaceProps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { currentFolderName, searchResults, onRefresh, onExit } = workspaceProps;
|
||||
const {
|
||||
currentFolderName,
|
||||
searchResults,
|
||||
onRefresh,
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
} = workspaceProps;
|
||||
const title = Array.isArray(searchResults) ? 'Search results' : currentFolderName;
|
||||
const subtitle = Array.isArray(searchResults)
|
||||
? `${searchResults.length} matching document${searchResults.length === 1 ? '' : 's'}`
|
||||
: null;
|
||||
|
||||
const actions = createDocumentsTableHeaderActions({
|
||||
viewMode: viewMode || 'desk',
|
||||
onViewModeChange,
|
||||
onRefresh,
|
||||
});
|
||||
|
||||
const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null;
|
||||
const leading = sidebarToggle ? <>{sidebarToggle}</> : null;
|
||||
|
||||
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 {
|
||||
const surfaceConfig = createWorkspaceSurfaceConfig({
|
||||
key: 'workspace',
|
||||
variant: 'workspace',
|
||||
header: { title, subtitle, leading, actions },
|
||||
title,
|
||||
subtitle,
|
||||
sidebarToggle,
|
||||
parentBreadcrumb,
|
||||
onNavigateParent,
|
||||
actions,
|
||||
breadcrumbs: workspaceProps?.breadcrumbs || null,
|
||||
content: <DesktopWorkspace {...workspaceProps} />,
|
||||
});
|
||||
|
||||
return {
|
||||
...surfaceConfig,
|
||||
supportsDetail: false,
|
||||
};
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
Reference in New Issue
Block a user