Compare commits
77
Commits
main
..
cc37fdde9a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc37fdde9a | ||
|
|
e351638e52 | ||
|
|
90a642c1c5 | ||
|
|
e150b81190 | ||
|
|
94578475ea | ||
|
|
92c491b742 | ||
|
|
eb93a2131f | ||
|
|
1e177580c9 | ||
|
|
7df9a6415a | ||
|
|
1179f4fcd4 | ||
|
|
709d32050e | ||
|
|
b9d84fa72b | ||
|
|
7abc10acde | ||
|
|
9be0e3b5c4 | ||
|
|
ed0f0fb759 | ||
|
|
2d80515ad5 | ||
|
|
50125f4659 | ||
|
|
a6da34740f | ||
|
|
7ed06ccdcf | ||
|
|
db07e0debb | ||
|
|
a0be094cbd | ||
|
|
7366d2e16b | ||
|
|
82aa8948cf | ||
|
|
84a3a9a5b5 | ||
|
|
b260065245 | ||
|
|
264316eb29 | ||
|
|
c625c80958 | ||
|
|
6a04d29eed | ||
|
|
10e28cc5e2 | ||
|
|
970678986a | ||
|
|
40e45f3a01 | ||
|
|
518b79bec6 | ||
|
|
01fb4e308a | ||
|
|
b6300ffa30 | ||
|
|
b163a07e84 | ||
|
|
157f03b655 | ||
|
|
42a3a53314 | ||
|
|
9b7ca3d692 | ||
|
|
e51a59a829 | ||
|
|
4a06fa82eb | ||
|
|
80238bb7a1 | ||
|
|
dcbd46531e | ||
|
|
0864b39336 | ||
|
|
2a0c96bb4c | ||
|
|
c53213a357 | ||
|
|
62cadbcfa0 | ||
|
|
d7aefc4110 | ||
|
|
e972a8dddb | ||
|
|
dc633783e0 | ||
|
|
e33ab71fac | ||
|
|
88b9375a4e | ||
|
|
f7a3e3f0f8 | ||
|
|
72df3dec3b | ||
|
|
b80ec5c6ac | ||
|
|
fbd3aff6d8 | ||
|
|
0ad79c9bc1 | ||
|
|
e175d28c2c | ||
|
|
8e3f09774a | ||
|
|
dbc54032f7 | ||
|
|
047b99e2aa | ||
|
|
1859078cf4 | ||
|
|
c44ba91ec7 | ||
|
|
96a6d0ee5d | ||
|
|
5619bb27a1 | ||
|
|
dfc99c7d15 | ||
|
|
fb82f505b6 | ||
|
|
5970340a17 | ||
|
|
9a77e76ff4 | ||
|
|
3591415d46 | ||
|
|
ab06205744 | ||
|
|
0689e680fa | ||
|
|
297d5aca1f | ||
|
|
0a023c1556 | ||
|
|
aa600af7b2 | ||
|
|
fc4aae8c0c | ||
|
|
24c0b5fa52 | ||
|
|
2af2af3460 |
@@ -0,0 +1,53 @@
|
|||||||
|
name: ci
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- staging
|
||||||
|
tags:
|
||||||
|
- '*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
docker:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- service: frontend
|
||||||
|
context: frontend
|
||||||
|
dockerfile: frontend/Dockerfile
|
||||||
|
- service: backend
|
||||||
|
context: backend
|
||||||
|
dockerfile: backend/Dockerfile
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout with submodules
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
- name: Login to Docker Registry
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
registry: ${{ vars.REGISTRY_URL }}
|
||||||
|
username: ${{ vars.REGISTRY_USER }}
|
||||||
|
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
with:
|
||||||
|
driver: remote
|
||||||
|
endpoint: ${{ env.BUILDKIT_ARM64_ENDPOINT }}
|
||||||
|
|
||||||
|
- name: Build and Push ${{ matrix.service }} Image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: ${{ matrix.context }}
|
||||||
|
file: ${{ matrix.dockerfile }}
|
||||||
|
platforms: linux/arm64
|
||||||
|
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 }}:${{ gitea.sha }}
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
name: ci
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
- staging
|
|
||||||
- dev
|
|
||||||
tags:
|
|
||||||
- '*'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
docker:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- service: frontend
|
|
||||||
context: frontend
|
|
||||||
dockerfile: frontend/Dockerfile
|
|
||||||
- service: backend
|
|
||||||
context: backend
|
|
||||||
dockerfile: backend/Dockerfile
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout with submodules
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
submodules: recursive
|
|
||||||
|
|
||||||
- name: Derive repository metadata
|
|
||||||
id: repo_meta
|
|
||||||
run: |
|
|
||||||
repo="${GITHUB_REPOSITORY}"
|
|
||||||
owner="${repo%%/*}"
|
|
||||||
name="${repo##*/}"
|
|
||||||
echo "repo_owner=$owner" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "repo_name=$name" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Compute base tag
|
|
||||||
id: compute_tag
|
|
||||||
env:
|
|
||||||
GITHUB_SHA: ${{ github.sha }}
|
|
||||||
GITHUB_REF_TYPE: ${{ github.ref_type }}
|
|
||||||
GITHUB_REF_NAME: ${{ github.ref_name }}
|
|
||||||
run: |
|
|
||||||
sha="${GITHUB_SHA}"
|
|
||||||
ref_type="${GITHUB_REF_TYPE}"
|
|
||||||
ref_name="${GITHUB_REF_NAME}"
|
|
||||||
|
|
||||||
short="${sha:0:7}"
|
|
||||||
tag="$short"
|
|
||||||
|
|
||||||
if [ "$ref_type" = "tag" ]; then
|
|
||||||
tag="$ref_name"
|
|
||||||
elif [ "$ref_name" = "dev" ]; then
|
|
||||||
tag="${tag}-dev"
|
|
||||||
elif [ "$ref_name" = "staging" ]; then
|
|
||||||
tag="${tag}-staging"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "base_tag=$tag" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Login to local registry
|
|
||||||
if: ${{ vars.REGISTRY_URL != '' }}
|
|
||||||
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: ${{ github.actor }}
|
|
||||||
password: ${{ github.token }}
|
|
||||||
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@v3
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Determine branch alias tag
|
|
||||||
id: branch_alias
|
|
||||||
env:
|
|
||||||
REF_NAME: ${{ github.ref_name }}
|
|
||||||
run: |
|
|
||||||
alias=""
|
|
||||||
case "${REF_NAME}" in
|
|
||||||
dev) alias="latest-dev" ;;
|
|
||||||
staging) alias="latest-staging" ;;
|
|
||||||
main) alias="latest" ;;
|
|
||||||
esac
|
|
||||||
echo "alias=$alias" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Assemble image tags
|
|
||||||
id: tag_list
|
|
||||||
env:
|
|
||||||
REGISTRY_URL: ${{ vars.REGISTRY_URL }}
|
|
||||||
REPO_NAME: ${{ steps.repo_meta.outputs.repo_name }}
|
|
||||||
SERVICE: ${{ matrix.service }}
|
|
||||||
BASE_TAG: ${{ steps.compute_tag.outputs.base_tag }}
|
|
||||||
GIT_SHA: ${{ github.sha }}
|
|
||||||
BRANCH_ALIAS: ${{ steps.branch_alias.outputs.alias }}
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
tags=""
|
|
||||||
|
|
||||||
if [ -n "${REGISTRY_URL}" ]; then
|
|
||||||
repo_tag="${REGISTRY_URL}/${REPO_NAME}-${SERVICE}"
|
|
||||||
tags="${tags}${repo_tag}:${BASE_TAG}\n${repo_tag}:${GIT_SHA}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
ghcr_tag="ghcr.io/papercrate-dms/${REPO_NAME}-${SERVICE}"
|
|
||||||
if [ -n "${tags}" ]; then
|
|
||||||
tags="${tags}\n"
|
|
||||||
fi
|
|
||||||
tags="${tags}${ghcr_tag}:${BASE_TAG}\n${ghcr_tag}:${GIT_SHA}"
|
|
||||||
|
|
||||||
if [ -n "${BRANCH_ALIAS}" ]; then
|
|
||||||
if [ -n "${REGISTRY_URL}" ]; then
|
|
||||||
tags="${tags}\n${repo_tag}:${BRANCH_ALIAS}"
|
|
||||||
fi
|
|
||||||
tags="${tags}\n${ghcr_tag}:${BRANCH_ALIAS}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
export TAGS="${tags}"
|
|
||||||
|
|
||||||
python -c 'import os; tags=[t.strip() for t in os.environ["TAGS"].split("\\n") if t.strip()]; print("tags=" + ",".join(tags))' | tee -a "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Build and Push ${{ matrix.service }} Image
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: ${{ matrix.context }}
|
|
||||||
file: ${{ matrix.dockerfile }}
|
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
push: true
|
|
||||||
provenance: false
|
|
||||||
tags: ${{ steps.tag_list.outputs.tags }}
|
|
||||||
cache-from: type=gha
|
|
||||||
cache-to: type=gha,mode=max
|
|
||||||
-128
@@ -1,128 +0,0 @@
|
|||||||
# Development
|
|
||||||
|
|
||||||
This document collects runtime assumptions and workflows for local development,
|
|
||||||
integration testing, and infrastructure automation.
|
|
||||||
|
|
||||||
## Local Development
|
|
||||||
|
|
||||||
The entire application stack (frontend, backend, worker, database, minio, quickwit) runs fully containerized via Docker Compose.
|
|
||||||
|
|
||||||
### Start Development Environment
|
|
||||||
|
|
||||||
To start the stack (builds are handled automatically):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f docker-compose.dev.yml up --build
|
|
||||||
```
|
|
||||||
|
|
||||||
### Apply Code Changes
|
|
||||||
|
|
||||||
Hot-reloading is handled automatically by `cargo watch` inside the container.
|
|
||||||
When you save files in `backend/src`, the watcher will:
|
|
||||||
|
|
||||||
1. Rebuild the modified binaries.
|
|
||||||
2. Restart the `backend`, `worker`, and `webdav` services via `supervisord`.
|
|
||||||
|
|
||||||
No manual restart is required.
|
|
||||||
|
|
||||||
### Running Migrations
|
|
||||||
|
|
||||||
Since `diesel-cli` runs inside the container:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run pending migrations
|
|
||||||
docker compose -f docker-compose.dev.yml exec server diesel migration run
|
|
||||||
|
|
||||||
# Revert last migration
|
|
||||||
docker compose -f docker-compose.dev.yml exec server diesel migration revert
|
|
||||||
|
|
||||||
# Create new migration
|
|
||||||
docker compose -f docker-compose.dev.yml exec server diesel migration generate name_of_migration
|
|
||||||
```
|
|
||||||
|
|
||||||
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 run in a dedicated, ephemeral container stack. The repository includes a lightweight compose file that provisions a fresh Postgres instance (using tmpfs) and Quickwit for every run.
|
|
||||||
|
|
||||||
To run the tests:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f docker-compose.test.yml run --rm test-runner
|
|
||||||
```
|
|
||||||
|
|
||||||
This will:
|
|
||||||
1. Spin up `postgres-test` and `quickwit-test` (in background if not running).
|
|
||||||
2. Start the `test-runner` container.
|
|
||||||
3. Wait for DB, run migrations, and execute `cargo test`.
|
|
||||||
4. Remove the runner container after exit.
|
|
||||||
|
|
||||||
To clean up the infrastructure afterwards:
|
|
||||||
```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`: Used by the worker to extract text from images. If missing, the worker logs a warning and skips text extraction for that document.
|
|
||||||
- `Quickwit`: Used for full-text search. If configured (via `QUICKWIT_ENDPOINT`), the worker pushes extracted text to the index. If missing, search features will simply be unavailable.
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
- `PROXY_DOWNLOADS` – set to `true` when the object store is only reachable from
|
|
||||||
the backend network. When enabled, `/api/download/{token}` and asset-object fetches
|
|
||||||
stream bytes through the API instead of redirecting clients to S3/Hetzner.
|
|
||||||
|
|
||||||
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 with the `papercrate-admin` binary, which can execute schema migrations
|
|
||||||
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/papercrate-admin", "migrate-database"]
|
|
||||||
env:
|
|
||||||
- name: DATABASE_URL
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: papercrate-db
|
|
||||||
key: DATABASE_URL
|
|
||||||
|
|
||||||
- name: MIGRATIONS_DATABASE_URL
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: papercrate-db
|
|
||||||
key: DATABASE_URL
|
|
||||||
```
|
|
||||||
|
|
||||||
Run the Job manually or use the Helm hooks configured in `k8s/papercrate/templates/migrate-job.yaml`. The `papercrate-admin` binary is built specifically for administrative tasks.
|
|
||||||
@@ -1,48 +1,73 @@
|
|||||||
# Papercrate
|
# Papercrate
|
||||||
|
|
||||||

|
## Local Development
|
||||||
|
|
||||||
## Single-Host Deployment (Docker Compose)
|
Use the provided `papercrate.tmux` to spin up the full stack in one tmux session:
|
||||||
|
|
||||||
For self-hosting (for example on a Raspberry Pi), use the production-oriented
|
|
||||||
`docker-compose.yml`. Define the required secrets in a `.env` file alongside the
|
|
||||||
compose file before starting the stack:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cat <<'EOF' > .env
|
tmux -f papercrate.tmux attach
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Important:** the WebAuthn settings must match the public URL clients will use.
|
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 relying party (RP) identifier is the bare host name, while the origin must
|
|
||||||
include scheme and port. Adjust the values above if you serve Papercrate from a
|
|
||||||
different host, domain, or HTTPS endpoint—otherwise passkey registration and
|
|
||||||
login will fail.
|
|
||||||
|
|
||||||
The compose file builds the backend and frontend images locally, then launches
|
## Backend Integration Tests
|
||||||
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`.
|
|
||||||
|
|
||||||
## Screenshots
|
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:
|
||||||
|
|
||||||
For development workflows (local stack, integration tests, migrations, and
|
```bash
|
||||||
configuration details) see [DEVELOPMENT.md](./DEVELOPMENT.md).
|
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.
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
target/
|
|
||||||
Generated
+1132
-1412
File diff suppressed because it is too large
Load Diff
+19
-52
@@ -1,24 +1,26 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "papercrate"
|
name = "backend"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
# Web framework
|
# Web framework
|
||||||
axum = { version = "0.8", features = ["multipart"] }
|
axum = { version = "0.7", features = ["multipart"] }
|
||||||
tokio = { version = "1.48", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
tower = { version = "0.5", features = ["make", "util"] }
|
tower = { version = "0.4", features = ["make", "util"] }
|
||||||
tower-http = { version = "0.6", features = ["cors", "trace"] }
|
tower-http = { version = "0.6", features = ["cors", "trace"] }
|
||||||
axum-extra = { version = "0.12", features = ["typed-header"] }
|
axum-extra = { version = "0.9", features = ["typed-header"] }
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
diesel = { version = "2.3.3", features = ["postgres", "uuid", "chrono", "serde_json", "r2d2"] }
|
diesel = { version = "2.1", features = ["postgres", "uuid", "chrono", "serde_json", "r2d2"] }
|
||||||
diesel_migrations = "2.1"
|
diesel_migrations = "2.1"
|
||||||
uuid = { version = "1.6", features = ["v4", "serde"] }
|
uuid = { version = "1.6", features = ["v4", "serde"] }
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
|
||||||
# S3
|
# S3
|
||||||
rust-s3 = { version = "0.37", features = ["with-tokio", "tokio-rustls-tls"] }
|
aws-config = "1.1"
|
||||||
|
aws-sdk-s3 = "1.14"
|
||||||
|
aws-credential-types = "1.2"
|
||||||
|
|
||||||
# Serialization
|
# Serialization
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
@@ -32,68 +34,33 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
|||||||
dotenv = "0.15"
|
dotenv = "0.15"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
hmac = "0.12"
|
|
||||||
bytes = "1.5"
|
bytes = "1.5"
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] }
|
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
|
||||||
pdfium-render = "0.8.36"
|
pdfium-render = "0.8"
|
||||||
mime_guess = "2.0"
|
mime_guess = "2.0"
|
||||||
tempfile = "3.10"
|
tempfile = "3.10"
|
||||||
reqwest = { version = "0.12.24", default-features = false, features = ["json", "rustls-tls", "stream"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
|
||||||
percent-encoding = "2.3"
|
percent-encoding = "2.3"
|
||||||
base64 = "0.22"
|
base64 = "0.21"
|
||||||
quick-xml = "0.38"
|
quick-xml = "0.32"
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
url = "2.5"
|
url = "2.5"
|
||||||
once_cell = "1.19"
|
once_cell = "1.19"
|
||||||
regex = "1.11"
|
|
||||||
infer = "0.19"
|
|
||||||
utoipa = { version = "4.2", default-features = false, features = ["chrono", "uuid", "preserve_order"] }
|
utoipa = { version = "4.2", default-features = false, features = ["chrono", "uuid", "preserve_order"] }
|
||||||
clap = { version = "4.5", features = ["derive"] }
|
|
||||||
|
|
||||||
# Error handling
|
# Error handling
|
||||||
thiserror = "2.0"
|
thiserror = "1.0"
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
|
|
||||||
# Authentication & security
|
# Authentication & security
|
||||||
argon2 = "0.5"
|
argon2 = "0.5"
|
||||||
jsonwebtoken = { version = "10", features = ["rust_crypto"] }
|
jsonwebtoken = "9"
|
||||||
webauthn-rs = { version = "0.5", features = ["danger-allow-state-serialisation", "danger-credential-internals"] }
|
|
||||||
serde_bytes = "0.11"
|
|
||||||
serde_cbor_2 = "0.13"
|
|
||||||
|
|
||||||
# Misc
|
# Misc
|
||||||
rand = "0.9"
|
rand = "0.8"
|
||||||
hyper = "1.2"
|
|
||||||
http-body-util = "0.1"
|
|
||||||
chrono-tz = "0.8"
|
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
once_cell = "1.19"
|
once_cell = "1.19"
|
||||||
webauthn-rs-core = "0.5"
|
hyper = "1.2"
|
||||||
serde_yaml = "0.9"
|
http-body-util = "0.1"
|
||||||
|
|
||||||
[build-dependencies]
|
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
|
||||||
serde_yaml = "0.9"
|
|
||||||
serde_json = "1.0"
|
|
||||||
regex = "1.11"
|
|
||||||
|
|
||||||
[[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"
|
|
||||||
|
|||||||
+49
-222
@@ -1,235 +1,62 @@
|
|||||||
# ------------------------------------------------------------------------------
|
# syntax=docker/dockerfile:1
|
||||||
# Global Arguments
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
ARG RUST_VERSION=1
|
|
||||||
ARG RUNTIME_DEPS="ocrmypdf tesseract-ocr ghostscript qpdf ffmpeg"
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
FROM rust:1-slim AS builder
|
||||||
# Base Stage: Shared Logic (PDFium)
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
FROM --platform=$BUILDPLATFORM rust:${RUST_VERSION}-slim AS base
|
|
||||||
ARG RUNTIME_DEPS
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Install PDFium
|
|
||||||
ARG TARGETARCH
|
|
||||||
RUN set -eux; \
|
|
||||||
case "${TARGETARCH}" in \
|
|
||||||
amd64|x86_64) pdfium_package=pdfium-linux-x64.tgz ;; \
|
|
||||||
arm64|aarch64) pdfium_package=pdfium-linux-arm64.tgz ;; \
|
|
||||||
*) echo "Unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \
|
|
||||||
esac; \
|
|
||||||
apt-get update && apt-get install -y --no-install-recommends curl ca-certificates; \
|
|
||||||
curl -fsSL "https://github.com/bblanchon/pdfium-binaries/releases/latest/download/${pdfium_package}" -o /tmp/pdfium.tgz; \
|
|
||||||
mkdir -p /tmp/pdfium; \
|
|
||||||
tar -xzf /tmp/pdfium.tgz -C /tmp/pdfium --strip-components=1; \
|
|
||||||
pdfium_so="$(find /tmp/pdfium -name libpdfium.so -type f | head -n1)"; \
|
|
||||||
[ -n "${pdfium_so}" ]; \
|
|
||||||
mkdir -p /usr/local/lib; \
|
|
||||||
cp "${pdfium_so}" /usr/local/lib/libpdfium.so; \
|
|
||||||
rm -rf /tmp/pdfium.tgz /tmp/pdfium
|
|
||||||
|
|
||||||
# Install common build dependencies AND runtime deps (for dev/testing)
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
build-essential \
|
|
||||||
pkg-config \
|
|
||||||
libssl-dev \
|
|
||||||
libpq-dev \
|
|
||||||
libjpeg-dev \
|
|
||||||
libpng-dev \
|
|
||||||
zlib1g-dev \
|
|
||||||
ca-certificates \
|
|
||||||
curl \
|
|
||||||
git \
|
|
||||||
${RUNTIME_DEPS} \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# Development Stage
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# Chef Stage: Install cargo-chef (used for caching)
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
FROM base AS chef
|
|
||||||
RUN cargo install cargo-chef
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# Planner Stage: Compute lockfile recipe
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
FROM chef AS planner
|
|
||||||
COPY . .
|
|
||||||
RUN cargo chef prepare --recipe-path recipe.json
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# Cacher Stage: Build dependencies only
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
FROM chef AS cacher
|
|
||||||
ENV CARGO_TARGET_DIR=/cargo-target
|
|
||||||
COPY --from=planner /app/recipe.json recipe.json
|
|
||||||
# Build dependencies (including test deps) based on the recipe
|
|
||||||
RUN cargo chef cook --tests --recipe-path recipe.json
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# Development Stage
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
FROM base AS development
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Install additional development tools
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
procps \
|
|
||||||
postgresql-client \
|
|
||||||
supervisor \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Install diesel-cli for migrations
|
|
||||||
RUN cargo install diesel_cli --no-default-features --features postgres
|
|
||||||
|
|
||||||
# Install cargo-watch for hot reloading
|
|
||||||
RUN cargo install cargo-watch
|
|
||||||
|
|
||||||
# Setup Cache
|
|
||||||
ENV CARGO_TARGET_DIR=/cargo-target
|
|
||||||
COPY --from=cacher /cargo-target /cargo-target
|
|
||||||
COPY --from=cacher /usr/local/cargo /usr/local/cargo
|
|
||||||
|
|
||||||
# Copy PDFium from base
|
|
||||||
COPY --from=base /usr/local/lib/libpdfium.so /usr/local/lib/libpdfium.so
|
|
||||||
ENV LD_LIBRARY_PATH=/usr/local/lib
|
|
||||||
RUN ldconfig
|
|
||||||
|
|
||||||
ENV RUST_LOG=info
|
|
||||||
CMD ["./run-dev.sh"]
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# Production Builder Stage
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# We restart from base to keep the image clean, but copy PDFium if needed for build/tests
|
|
||||||
FROM base AS builder
|
|
||||||
ARG TARGETARCH
|
|
||||||
ENV TARGETARCH=${TARGETARCH}
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Resolve cross-compilation target script
|
|
||||||
RUN cat <<'SCRIPT' >/usr/local/bin/resolve-target.sh
|
|
||||||
#!/bin/sh
|
|
||||||
set -e
|
|
||||||
case "$1" in
|
|
||||||
amd64) echo x86_64-unknown-linux-gnu ;;
|
|
||||||
arm64) echo aarch64-unknown-linux-gnu ;;
|
|
||||||
*) echo "Unsupported TARGETARCH: $1" >&2; exit 1 ;;
|
|
||||||
esac
|
|
||||||
SCRIPT
|
|
||||||
RUN chmod +x /usr/local/bin/resolve-target.sh
|
|
||||||
|
|
||||||
# Install cross-compilation deps
|
|
||||||
ARG BUILDPLATFORM
|
|
||||||
RUN if [ "${TARGETARCH}" = "amd64" ]; then \
|
|
||||||
echo "x86_64-linux-gnu" > /tmp/target_deb_arch; \
|
|
||||||
elif [ "${TARGETARCH}" = "arm64" ]; then \
|
|
||||||
echo "aarch64-linux-gnu" > /tmp/target_deb_arch; \
|
|
||||||
else \
|
|
||||||
echo "Unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
RUN set -eux; \
|
|
||||||
# Detect build arch (assuming debian-like names compatible with apt)
|
|
||||||
dpkg_arch="$(dpkg --print-architecture)"; \
|
|
||||||
target_deb_arch="$(cat /tmp/target_deb_arch)"; \
|
|
||||||
\
|
|
||||||
# If we are cross-compiling
|
|
||||||
if [ "${dpkg_arch}" != "${TARGETARCH}" ]; then \
|
|
||||||
# Map target arch to debian package arch suffix if needed, but usually apt handles :arch
|
|
||||||
# For cross-compiling, we need to add the architecture
|
|
||||||
dpkg --add-architecture "${TARGETARCH}"; \
|
|
||||||
apt-get update; \
|
|
||||||
\
|
|
||||||
case "${TARGETARCH}" in \
|
|
||||||
arm64) CROSS_GCC=gcc-aarch64-linux-gnu ;; \
|
|
||||||
amd64) CROSS_GCC=gcc-x86-64-linux-gnu ;; \
|
|
||||||
esac; \
|
|
||||||
\
|
|
||||||
apt-get install -y --no-install-recommends \
|
|
||||||
"${CROSS_GCC}" \
|
|
||||||
"libc6-dev:${TARGETARCH}" \
|
|
||||||
"libssl-dev:${TARGETARCH}" \
|
|
||||||
"libpq-dev:${TARGETARCH}" \
|
|
||||||
"libjpeg-dev:${TARGETARCH}" \
|
|
||||||
"libpng-dev:${TARGETARCH}" \
|
|
||||||
"zlib1g-dev:${TARGETARCH}"; \
|
|
||||||
\
|
|
||||||
# Configure PKG_CONFIG and LINKER to find foreign libraries
|
|
||||||
case "${TARGETARCH}" in \
|
|
||||||
"amd64") \
|
|
||||||
GNU_ARCH="x86_64-linux-gnu" \
|
|
||||||
RUST_ARCH="x86_64_unknown_linux_gnu" \
|
|
||||||
RUST_ARCH_UPPER="X86_64_UNKNOWN_LINUX_GNU" \
|
|
||||||
;; \
|
|
||||||
"arm64") \
|
|
||||||
GNU_ARCH="aarch64-linux-gnu" \
|
|
||||||
RUST_ARCH="aarch64_unknown_linux_gnu" \
|
|
||||||
RUST_ARCH_UPPER="AARCH64_UNKNOWN_LINUX_GNU" \
|
|
||||||
;; \
|
|
||||||
esac; \
|
|
||||||
\
|
|
||||||
{ \
|
|
||||||
echo "export PKG_CONFIG_ALLOW_CROSS=1"; \
|
|
||||||
echo "export PKG_CONFIG_PATH=/usr/lib/${GNU_ARCH}/pkgconfig"; \
|
|
||||||
echo "export OPENSSL_DIR=/usr/lib/${GNU_ARCH}"; \
|
|
||||||
echo "export OPENSSL_LIB_DIR=/usr/lib/${GNU_ARCH}"; \
|
|
||||||
echo "export OPENSSL_INCLUDE_DIR=/usr/include/${GNU_ARCH}"; \
|
|
||||||
echo "export CARGO_TARGET_${RUST_ARCH_UPPER}_LINKER=${GNU_ARCH}-gcc"; \
|
|
||||||
echo "export CC_${RUST_ARCH}=${GNU_ARCH}-gcc"; \
|
|
||||||
echo "export CXX_${RUST_ARCH}=${GNU_ARCH}-g++"; \
|
|
||||||
} >> /etc/profile; \
|
|
||||||
fi; \
|
|
||||||
rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
ENV PKG_CONFIG_ALLOW_CROSS=1
|
|
||||||
|
|
||||||
COPY Cargo.toml Cargo.lock build.rs ./
|
|
||||||
COPY src ./src
|
|
||||||
COPY migrations ./migrations
|
|
||||||
COPY tests ./tests
|
|
||||||
COPY resources ./resources
|
|
||||||
COPY diesel.toml ./
|
|
||||||
|
|
||||||
RUN set -eux; \
|
|
||||||
echo "Loading cross-compilation environment..."; \
|
|
||||||
. /etc/profile; \
|
|
||||||
export PATH="$PATH:/usr/local/cargo/bin"; \
|
|
||||||
TARGET="$(/usr/local/bin/resolve-target.sh "${TARGETARCH}")"; \
|
|
||||||
rustup target add "${TARGET}"; \
|
|
||||||
cargo build --release --target "${TARGET}" --bin backend --bin worker --bin webdav --bin admin; \
|
|
||||||
mkdir -p /artifacts; \
|
|
||||||
for bin in backend worker webdav admin; do \
|
|
||||||
cp "target/${TARGET}/release/${bin}" "/artifacts/${bin}"; \
|
|
||||||
done
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# Production Runtime Stage
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
FROM debian:trixie-slim AS runtime
|
|
||||||
ARG TARGETARCH
|
|
||||||
ARG RUNTIME_DEPS
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends \
|
&& apt-get install -y --no-install-recommends \
|
||||||
ca-certificates curl libssl3 libpq5 libjpeg62-turbo libpng16-16 \
|
build-essential \
|
||||||
${RUNTIME_DEPS} \
|
pkg-config \
|
||||||
|
libssl-dev \
|
||||||
|
libpq-dev \
|
||||||
|
libjpeg-dev \
|
||||||
|
libpng-dev \
|
||||||
|
curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY Cargo.toml Cargo.lock ./
|
||||||
|
COPY src ./src
|
||||||
|
COPY migrations ./migrations
|
||||||
|
COPY tests ./tests
|
||||||
|
COPY diesel.toml ./
|
||||||
|
|
||||||
|
RUN cargo build --release --bin backend --bin worker --bin webdav --bin admin
|
||||||
|
RUN cargo install diesel_cli --no-default-features --features postgres
|
||||||
|
|
||||||
|
FROM debian:trixie-slim AS runtime
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates \
|
||||||
|
curl \
|
||||||
|
libssl3 \
|
||||||
|
libpq5 \
|
||||||
|
libjpeg62-turbo \
|
||||||
|
libpng16-16 \
|
||||||
|
ocrmypdf \
|
||||||
|
tesseract-ocr \
|
||||||
|
ghostscript \
|
||||||
|
qpdf \
|
||||||
&& rm -rf /var/lib/apt/lists/* \
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
&& mkdir -p /usr/local/lib \
|
&& mkdir -p /usr/local/lib \
|
||||||
|
&& curl -fsSL https://github.com/bblanchon/pdfium-binaries/releases/latest/download/pdfium-linux-arm64.tgz -o /tmp/pdfium.tgz \
|
||||||
|
&& mkdir -p /tmp/pdfium \
|
||||||
|
&& tar -xzf /tmp/pdfium.tgz -C /tmp/pdfium --strip-components=1 \
|
||||||
|
&& pdfium_so="$(find /tmp/pdfium -name libpdfium.so -type f | head -n1)" \
|
||||||
|
&& [ -n "${pdfium_so}" ] \
|
||||||
|
&& mv "${pdfium_so}" /usr/local/lib/libpdfium.so \
|
||||||
|
&& ldconfig \
|
||||||
|
&& rm -rf /tmp/pdfium.tgz /tmp/pdfium \
|
||||||
&& useradd --system --create-home --uid 10001 appuser
|
&& useradd --system --create-home --uid 10001 appuser
|
||||||
|
|
||||||
COPY --from=builder /artifacts/backend /usr/local/bin/papercrate-backend
|
COPY --from=builder /app/target/release/backend /usr/local/bin/papercrate-backend
|
||||||
COPY --from=builder /artifacts/worker /usr/local/bin/papercrate-worker
|
COPY --from=builder /app/target/release/worker /usr/local/bin/papercrate-worker
|
||||||
COPY --from=builder /artifacts/webdav /usr/local/bin/papercrate-webdav
|
COPY --from=builder /app/target/release/webdav /usr/local/bin/papercrate-webdav
|
||||||
COPY --from=builder /artifacts/admin /usr/local/bin/papercrate-admin
|
COPY --from=builder /app/target/release/admin /usr/local/bin/papercrate-admin
|
||||||
COPY --from=base /usr/local/lib/libpdfium.so /usr/local/lib/libpdfium.so
|
COPY --from=builder /usr/local/cargo/bin/diesel /usr/local/bin/diesel
|
||||||
|
|
||||||
RUN ldconfig
|
|
||||||
COPY migrations ./migrations
|
COPY migrations ./migrations
|
||||||
|
COPY diesel.toml ./
|
||||||
|
|
||||||
ENV RUST_LOG=info
|
ENV RUST_LOG=info
|
||||||
USER appuser
|
USER appuser
|
||||||
|
|||||||
@@ -1,123 +0,0 @@
|
|||||||
use std::env;
|
|
||||||
use std::fs;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
use regex::escape;
|
|
||||||
use serde::Deserialize;
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct CaseSuite {
|
|
||||||
cases: Vec<CaseName>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct CaseName {
|
|
||||||
name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct MonthSuite {
|
|
||||||
months: Vec<MonthDefinition>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct MonthDefinition {
|
|
||||||
name: String,
|
|
||||||
month: u32,
|
|
||||||
#[serde(default)]
|
|
||||||
locales: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sanitize(name: &str) -> String {
|
|
||||||
let mut out = String::with_capacity(name.len());
|
|
||||||
for ch in name.chars() {
|
|
||||||
if ch.is_ascii_alphanumeric() {
|
|
||||||
out.push(ch);
|
|
||||||
} else {
|
|
||||||
out.push('_');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if out.is_empty() {
|
|
||||||
"case".to_string()
|
|
||||||
} else if out.chars().next().unwrap().is_ascii_digit() {
|
|
||||||
format!("_{}", out)
|
|
||||||
} else {
|
|
||||||
out
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn quote(value: &str) -> String {
|
|
||||||
serde_json::to_string(value).expect("string literal")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn generate_tests() -> Result<String, Box<dyn std::error::Error>> {
|
|
||||||
let yaml_path = PathBuf::from("tests/data/issued_at_cases.yaml");
|
|
||||||
let contents = fs::read_to_string(&yaml_path)?;
|
|
||||||
let suite: CaseSuite = serde_yaml::from_str(&contents)?;
|
|
||||||
|
|
||||||
let mut output =
|
|
||||||
String::from("#[cfg(test)]\npub mod issued_at_generated_tests {\n use super::*;\n");
|
|
||||||
|
|
||||||
for case in suite.cases {
|
|
||||||
let ident = sanitize(&case.name);
|
|
||||||
output.push_str(&format!(
|
|
||||||
" #[test]\n fn {}() {{\n run_named_case(\"{}\");\n }}\n",
|
|
||||||
ident, case.name
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
output.push_str("}\n");
|
|
||||||
Ok(output)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn generate_months() -> Result<String, Box<dyn std::error::Error>> {
|
|
||||||
let yaml_path = PathBuf::from("resources/issued_at_months.yaml");
|
|
||||||
let contents = fs::read_to_string(&yaml_path)?;
|
|
||||||
let suite: MonthSuite = serde_yaml::from_str(&contents)?;
|
|
||||||
|
|
||||||
let mut pattern_parts = Vec::with_capacity(suite.months.len());
|
|
||||||
let mut entries = String::new();
|
|
||||||
for entry in &suite.months {
|
|
||||||
pattern_parts.push(escape(&entry.name));
|
|
||||||
let locales_literal = if entry.locales.is_empty() {
|
|
||||||
"&[]".to_string()
|
|
||||||
} else {
|
|
||||||
let joined = entry
|
|
||||||
.locales
|
|
||||||
.iter()
|
|
||||||
.map(|loc| quote(loc))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(", ");
|
|
||||||
format!("&[{}]", joined)
|
|
||||||
};
|
|
||||||
entries.push_str(&format!(
|
|
||||||
" MonthVariant {{ name: {}, month: {}, locales: {} }},\n",
|
|
||||||
quote(&entry.name),
|
|
||||||
entry.month,
|
|
||||||
locales_literal
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let pattern_literal = quote(&pattern_parts.join("|"));
|
|
||||||
let output = format!(
|
|
||||||
"pub(super) static MONTH_VARIANTS: &[MonthVariant] = &[\n{entries}];\n\n",
|
|
||||||
entries = entries
|
|
||||||
) + &format!(
|
|
||||||
"pub(super) const MONTH_PATTERN: &str = {};\n",
|
|
||||||
pattern_literal
|
|
||||||
);
|
|
||||||
Ok(output)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
println!("cargo:rerun-if-changed=tests/data/issued_at_cases.yaml");
|
|
||||||
println!("cargo:rerun-if-changed=resources/issued_at_months.yaml");
|
|
||||||
|
|
||||||
let out_dir = PathBuf::from(env::var("OUT_DIR")?);
|
|
||||||
fs::write(
|
|
||||||
out_dir.join("issued_at_generated_tests.rs"),
|
|
||||||
generate_tests()?,
|
|
||||||
)?;
|
|
||||||
fs::write(out_dir.join("issued_at_months.rs"), generate_months()?)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,2 +1,6 @@
|
|||||||
|
[print_schema]
|
||||||
|
file = "src/schema.rs"
|
||||||
|
custom_type_derives = ["diesel::query_builder::QueryId", "Clone"]
|
||||||
|
|
||||||
[migrations_directory]
|
[migrations_directory]
|
||||||
dir = "migrations"
|
dir = "migrations"
|
||||||
+1
-5
@@ -1,10 +1,8 @@
|
|||||||
DROP TRIGGER IF EXISTS trg_jobs_updated_at ON jobs;
|
DROP TRIGGER IF EXISTS trg_jobs_updated_at ON jobs;
|
||||||
DROP FUNCTION IF EXISTS touch_jobs_updated_at();
|
DROP FUNCTION IF EXISTS touch_jobs_updated_at();
|
||||||
|
|
||||||
DROP TABLE IF EXISTS webauthn_challenges;
|
|
||||||
DROP TABLE IF EXISTS user_passkeys;
|
|
||||||
DROP TABLE IF EXISTS webdav_tokens;
|
|
||||||
ALTER TABLE documents DROP CONSTRAINT IF EXISTS documents_current_version_fk;
|
ALTER TABLE documents DROP CONSTRAINT IF EXISTS documents_current_version_fk;
|
||||||
|
|
||||||
DROP TABLE IF EXISTS document_asset_objects;
|
DROP TABLE IF EXISTS document_asset_objects;
|
||||||
DROP TABLE IF EXISTS document_assets;
|
DROP TABLE IF EXISTS document_assets;
|
||||||
DROP TABLE IF EXISTS document_versions;
|
DROP TABLE IF EXISTS document_versions;
|
||||||
@@ -20,6 +18,4 @@ DROP TABLE IF EXISTS user_memberships;
|
|||||||
DROP TABLE IF EXISTS users;
|
DROP TABLE IF EXISTS users;
|
||||||
DROP TABLE IF EXISTS tenants;
|
DROP TABLE IF EXISTS tenants;
|
||||||
|
|
||||||
DROP TYPE IF EXISTS tenant_status;
|
|
||||||
|
|
||||||
DROP EXTENSION IF EXISTS "pgcrypto";
|
DROP EXTENSION IF EXISTS "pgcrypto";
|
||||||
+45
-96
@@ -1,17 +1,14 @@
|
|||||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||||
|
|
||||||
CREATE TYPE tenant_status AS ENUM ('creating', 'active', 'suspended', 'deleting', 'error');
|
|
||||||
|
|
||||||
CREATE TABLE tenants (
|
CREATE TABLE tenants (
|
||||||
id UUID PRIMARY KEY,
|
id UUID PRIMARY KEY,
|
||||||
name TEXT NOT NULL,
|
slug TEXT NOT NULL UNIQUE,
|
||||||
storage_root TEXT,
|
storage_root TEXT,
|
||||||
quickwit_index TEXT,
|
quickwit_index TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
config JSONB NOT NULL DEFAULT '{}'::jsonb,
|
config JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
status tenant_status NOT NULL,
|
|
||||||
created_by UUID
|
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE UNIQUE INDEX tenants_storage_root_unique
|
CREATE UNIQUE INDEX tenants_storage_root_unique
|
||||||
@@ -25,6 +22,7 @@ CREATE UNIQUE INDEX tenants_quickwit_index_unique
|
|||||||
CREATE TABLE users (
|
CREATE TABLE users (
|
||||||
id UUID PRIMARY KEY,
|
id UUID PRIMARY KEY,
|
||||||
username VARCHAR(100) NOT NULL UNIQUE,
|
username VARCHAR(100) NOT NULL UNIQUE,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
);
|
);
|
||||||
@@ -33,13 +31,14 @@ CREATE TABLE user_memberships (
|
|||||||
id UUID PRIMARY KEY,
|
id UUID PRIMARY KEY,
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
|
role TEXT NOT NULL,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
UNIQUE (user_id, tenant_id)
|
UNIQUE (user_id, tenant_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX user_memberships_tenant_id_idx ON user_memberships(tenant_id);
|
CREATE INDEX user_memberships_tenant_id_idx ON user_memberships (tenant_id);
|
||||||
CREATE INDEX user_memberships_user_id_idx ON user_memberships(user_id);
|
CREATE INDEX user_memberships_user_id_idx ON user_memberships (user_id);
|
||||||
|
|
||||||
CREATE TABLE folders (
|
CREATE TABLE folders (
|
||||||
id UUID PRIMARY KEY,
|
id UUID PRIMARY KEY,
|
||||||
@@ -52,12 +51,8 @@ CREATE TABLE folders (
|
|||||||
|
|
||||||
CREATE INDEX idx_folders_parent ON folders(parent_id);
|
CREATE INDEX idx_folders_parent ON folders(parent_id);
|
||||||
CREATE INDEX folders_tenant_id_idx ON folders(tenant_id);
|
CREATE INDEX folders_tenant_id_idx ON folders(tenant_id);
|
||||||
CREATE UNIQUE INDEX folders_tenant_parent_name_unique_idx
|
CREATE UNIQUE INDEX folders_parent_name_unique_idx
|
||||||
ON folders (
|
ON folders (COALESCE(parent_id, '00000000-0000-0000-0000-000000000000'::uuid), name);
|
||||||
tenant_id,
|
|
||||||
COALESCE(parent_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
|
||||||
name
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE documents (
|
CREATE TABLE documents (
|
||||||
id UUID PRIMARY KEY,
|
id UUID PRIMARY KEY,
|
||||||
@@ -75,10 +70,11 @@ CREATE TABLE documents (
|
|||||||
tenant_id UUID NOT NULL REFERENCES tenants(id)
|
tenant_id UUID NOT NULL REFERENCES tenants(id)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX idx_documents_folder ON documents(folder_id);
|
CREATE INDEX idx_documents_folder ON documents (folder_id);
|
||||||
CREATE INDEX idx_documents_deleted_at ON documents(deleted_at);
|
CREATE INDEX idx_documents_deleted_at ON documents (deleted_at);
|
||||||
CREATE INDEX documents_tenant_id_idx ON documents(tenant_id);
|
CREATE INDEX documents_tenant_id_idx ON documents (tenant_id);
|
||||||
CREATE INDEX idx_documents_current_version_id ON documents(current_version_id);
|
CREATE INDEX idx_documents_current_version_id ON documents (current_version_id);
|
||||||
|
|
||||||
CREATE INDEX idx_documents_folder_title
|
CREATE INDEX idx_documents_folder_title
|
||||||
ON documents (
|
ON documents (
|
||||||
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||||
@@ -86,9 +82,8 @@ CREATE INDEX idx_documents_folder_title
|
|||||||
)
|
)
|
||||||
WHERE deleted_at IS NULL;
|
WHERE deleted_at IS NULL;
|
||||||
|
|
||||||
CREATE UNIQUE INDEX documents_tenant_folder_filename_unique
|
CREATE UNIQUE INDEX documents_unique_folder_filename
|
||||||
ON documents (
|
ON documents (
|
||||||
tenant_id,
|
|
||||||
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||||
filename
|
filename
|
||||||
)
|
)
|
||||||
@@ -102,13 +97,14 @@ CREATE TABLE document_versions (
|
|||||||
size_bytes BIGINT NOT NULL,
|
size_bytes BIGINT NOT NULL,
|
||||||
checksum VARCHAR(64) NOT NULL,
|
checksum VARCHAR(64) NOT NULL,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
operations_summary JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
||||||
CONSTRAINT document_versions_unique_version UNIQUE (document_id, version_number)
|
CONSTRAINT document_versions_unique_version UNIQUE (document_id, version_number)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX idx_document_versions_document ON document_versions(document_id);
|
CREATE INDEX idx_document_versions_document ON document_versions (document_id);
|
||||||
CREATE INDEX document_versions_tenant_id_idx ON document_versions(tenant_id);
|
CREATE INDEX document_versions_tenant_id_idx ON document_versions (tenant_id);
|
||||||
|
|
||||||
ALTER TABLE documents
|
ALTER TABLE documents
|
||||||
ADD CONSTRAINT documents_current_version_fk
|
ADD CONSTRAINT documents_current_version_fk
|
||||||
@@ -118,14 +114,13 @@ ALTER TABLE documents
|
|||||||
|
|
||||||
CREATE TABLE tags (
|
CREATE TABLE tags (
|
||||||
id UUID PRIMARY KEY,
|
id UUID PRIMARY KEY,
|
||||||
label VARCHAR(100) NOT NULL,
|
label VARCHAR(100) NOT NULL UNIQUE,
|
||||||
color VARCHAR(7),
|
color VARCHAR(7),
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
tenant_id UUID NOT NULL REFERENCES tenants(id)
|
tenant_id UUID NOT NULL REFERENCES tenants(id)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE UNIQUE INDEX tags_tenant_label_unique ON tags(tenant_id, label);
|
CREATE INDEX tags_tenant_id_idx ON tags (tenant_id);
|
||||||
CREATE INDEX tags_tenant_id_idx ON tags(tenant_id);
|
|
||||||
|
|
||||||
CREATE TABLE document_tags (
|
CREATE TABLE document_tags (
|
||||||
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||||
@@ -136,8 +131,8 @@ CREATE TABLE document_tags (
|
|||||||
PRIMARY KEY (document_id, tag_id)
|
PRIMARY KEY (document_id, tag_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX idx_document_tags_tag ON document_tags(tag_id);
|
CREATE INDEX idx_document_tags_tag ON document_tags (tag_id);
|
||||||
CREATE INDEX document_tags_tenant_id_idx ON document_tags(tenant_id);
|
CREATE INDEX document_tags_tenant_id_idx ON document_tags (tenant_id);
|
||||||
|
|
||||||
CREATE TABLE correspondents (
|
CREATE TABLE correspondents (
|
||||||
id UUID PRIMARY KEY,
|
id UUID PRIMARY KEY,
|
||||||
@@ -145,25 +140,27 @@ CREATE TABLE correspondents (
|
|||||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
tenant_id UUID NOT NULL REFERENCES tenants(id)
|
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
||||||
|
CONSTRAINT correspondents_name_unique UNIQUE (name)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE UNIQUE INDEX correspondents_tenant_name_unique
|
CREATE INDEX correspondents_tenant_id_idx ON correspondents (tenant_id);
|
||||||
ON correspondents (tenant_id, name);
|
|
||||||
CREATE INDEX correspondents_tenant_id_idx ON correspondents(tenant_id);
|
|
||||||
|
|
||||||
CREATE TABLE document_correspondents (
|
CREATE TABLE document_correspondents (
|
||||||
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||||
correspondent_id UUID NOT NULL REFERENCES correspondents(id) ON DELETE CASCADE,
|
correspondent_id UUID NOT NULL REFERENCES correspondents(id) ON DELETE CASCADE,
|
||||||
|
role VARCHAR(32) NOT NULL,
|
||||||
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
assigned_by UUID REFERENCES users(id),
|
assigned_by UUID REFERENCES users(id),
|
||||||
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
||||||
PRIMARY KEY (document_id, correspondent_id)
|
PRIMARY KEY (document_id, correspondent_id, role),
|
||||||
|
CONSTRAINT document_correspondents_role_check CHECK (role IN ('sender', 'receiver', 'other'))
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX idx_document_correspondents_document ON document_correspondents(document_id);
|
CREATE INDEX idx_document_correspondents_document ON document_correspondents (document_id);
|
||||||
CREATE INDEX idx_document_correspondents_correspondent ON document_correspondents(correspondent_id);
|
CREATE INDEX idx_document_correspondents_correspondent ON document_correspondents (correspondent_id);
|
||||||
CREATE INDEX document_correspondents_tenant_id_idx ON document_correspondents(tenant_id);
|
CREATE INDEX idx_document_correspondents_role ON document_correspondents (role);
|
||||||
|
CREATE INDEX document_correspondents_tenant_id_idx ON document_correspondents (tenant_id);
|
||||||
|
|
||||||
CREATE TABLE document_assets (
|
CREATE TABLE document_assets (
|
||||||
id UUID PRIMARY KEY,
|
id UUID PRIMARY KEY,
|
||||||
@@ -178,9 +175,9 @@ CREATE TABLE document_assets (
|
|||||||
CONSTRAINT document_assets_cardinality_positive CHECK (cardinality IS NULL OR cardinality >= 1)
|
CONSTRAINT document_assets_cardinality_positive CHECK (cardinality IS NULL OR cardinality >= 1)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX idx_document_assets_version ON document_assets(document_version_id);
|
CREATE INDEX idx_document_assets_version ON document_assets (document_version_id);
|
||||||
CREATE INDEX idx_document_assets_type ON document_assets(asset_type);
|
CREATE INDEX idx_document_assets_type ON document_assets (asset_type);
|
||||||
CREATE INDEX document_assets_tenant_id_idx ON document_assets(tenant_id);
|
CREATE INDEX document_assets_tenant_id_idx ON document_assets (tenant_id);
|
||||||
|
|
||||||
CREATE TABLE document_asset_objects (
|
CREATE TABLE document_asset_objects (
|
||||||
id UUID PRIMARY KEY,
|
id UUID PRIMARY KEY,
|
||||||
@@ -194,8 +191,10 @@ CREATE TABLE document_asset_objects (
|
|||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX idx_document_asset_objects_asset_ordinal
|
CREATE INDEX idx_document_asset_objects_asset_ordinal
|
||||||
ON document_asset_objects(asset_id, ordinal);
|
ON document_asset_objects (asset_id, ordinal);
|
||||||
CREATE INDEX document_asset_objects_tenant_id_idx ON document_asset_objects(tenant_id);
|
|
||||||
|
CREATE INDEX document_asset_objects_tenant_id_idx
|
||||||
|
ON document_asset_objects (tenant_id);
|
||||||
|
|
||||||
CREATE TABLE jobs (
|
CREATE TABLE jobs (
|
||||||
id UUID PRIMARY KEY,
|
id UUID PRIMARY KEY,
|
||||||
@@ -211,9 +210,9 @@ CREATE TABLE jobs (
|
|||||||
CONSTRAINT jobs_status_check CHECK (status IN ('queued', 'processing', 'succeeded', 'failed'))
|
CONSTRAINT jobs_status_check CHECK (status IN ('queued', 'processing', 'succeeded', 'failed'))
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX idx_jobs_status_run_after ON jobs(status, run_after);
|
CREATE INDEX idx_jobs_status_run_after ON jobs (status, run_after);
|
||||||
CREATE INDEX idx_jobs_job_type ON jobs(job_type);
|
CREATE INDEX idx_jobs_job_type ON jobs (job_type);
|
||||||
CREATE INDEX jobs_tenant_id_idx ON jobs(tenant_id);
|
CREATE INDEX jobs_tenant_id_idx ON jobs (tenant_id);
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION touch_jobs_updated_at()
|
CREATE OR REPLACE FUNCTION touch_jobs_updated_at()
|
||||||
RETURNS TRIGGER AS $$
|
RETURNS TRIGGER AS $$
|
||||||
@@ -240,56 +239,6 @@ CREATE TABLE refresh_tokens (
|
|||||||
tenant_id UUID NOT NULL REFERENCES tenants(id)
|
tenant_id UUID NOT NULL REFERENCES tenants(id)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id);
|
CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens (user_id);
|
||||||
CREATE INDEX idx_refresh_tokens_token_hash ON refresh_tokens(token_hash);
|
CREATE INDEX idx_refresh_tokens_token_hash ON refresh_tokens (token_hash);
|
||||||
CREATE INDEX refresh_tokens_tenant_id_idx ON refresh_tokens(tenant_id);
|
CREATE INDEX refresh_tokens_tenant_id_idx ON refresh_tokens (tenant_id);
|
||||||
|
|
||||||
CREATE TABLE webdav_tokens (
|
|
||||||
id UUID PRIMARY KEY,
|
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
|
||||||
token_prefix TEXT NOT NULL,
|
|
||||||
token_hash TEXT NOT NULL,
|
|
||||||
label TEXT,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
last_used_at TIMESTAMPTZ,
|
|
||||||
expires_at TIMESTAMPTZ,
|
|
||||||
revoked_at TIMESTAMPTZ
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX webdav_tokens_token_prefix_key ON webdav_tokens(token_prefix);
|
|
||||||
CREATE INDEX webdav_tokens_user_tenant_idx ON webdav_tokens(user_id, tenant_id);
|
|
||||||
|
|
||||||
CREATE TABLE user_passkeys (
|
|
||||||
id UUID PRIMARY KEY,
|
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
credential_id BYTEA NOT NULL UNIQUE,
|
|
||||||
public_key BYTEA NOT NULL,
|
|
||||||
credential JSONB NOT NULL,
|
|
||||||
sign_count BIGINT NOT NULL,
|
|
||||||
transports TEXT[] NOT NULL DEFAULT '{}'::text[],
|
|
||||||
aaguid UUID,
|
|
||||||
nickname TEXT,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
last_used_at TIMESTAMPTZ,
|
|
||||||
revoked_at TIMESTAMPTZ,
|
|
||||||
revoked_by UUID,
|
|
||||||
revoked_reason TEXT
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX user_passkeys_user_id_idx ON user_passkeys(user_id);
|
|
||||||
|
|
||||||
CREATE TABLE webauthn_challenges (
|
|
||||||
id UUID PRIMARY KEY,
|
|
||||||
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
purpose TEXT NOT NULL,
|
|
||||||
challenge BYTEA NOT NULL,
|
|
||||||
state BYTEA NOT NULL,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
expires_at TIMESTAMPTZ NOT NULL,
|
|
||||||
CONSTRAINT webauthn_challenges_purpose_check CHECK (purpose IN ('registration', 'authentication'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX webauthn_challenges_user_id_idx ON webauthn_challenges(user_id);
|
|
||||||
CREATE INDEX webauthn_challenges_expires_at_idx ON webauthn_challenges(expires_at);
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
ALTER TABLE documents
|
|
||||||
RENAME COLUMN created_at TO uploaded_at;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
ALTER TABLE documents
|
|
||||||
RENAME COLUMN uploaded_at TO created_at;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
DROP TABLE magic_tokens;
|
|
||||||
DROP TYPE magic_token_kind;
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
CREATE TYPE magic_token_kind AS ENUM ('email_login', 'demo_login');
|
|
||||||
|
|
||||||
CREATE TABLE magic_tokens (
|
|
||||||
id UUID PRIMARY KEY,
|
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
kind magic_token_kind NOT NULL,
|
|
||||||
token_hash VARCHAR NOT NULL UNIQUE,
|
|
||||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
||||||
expires_at TIMESTAMPTZ NOT NULL,
|
|
||||||
max_uses INTEGER,
|
|
||||||
used_count INTEGER NOT NULL DEFAULT 0,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
|
||||||
last_used_at TIMESTAMPTZ
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX magic_tokens_token_hash_idx ON magic_tokens (token_hash);
|
|
||||||
CREATE INDEX magic_tokens_expires_at_idx ON magic_tokens (expires_at);
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
-- Move tables and types back to the public schema
|
|
||||||
ALTER TABLE tenant.webdav_tokens SET SCHEMA public;
|
|
||||||
ALTER TABLE tenant.user_memberships SET SCHEMA public;
|
|
||||||
ALTER TABLE tenant.refresh_tokens SET SCHEMA public;
|
|
||||||
ALTER TABLE tenant.tags SET SCHEMA public;
|
|
||||||
ALTER TABLE tenant.document_correspondents SET SCHEMA public;
|
|
||||||
ALTER TABLE tenant.document_tags SET SCHEMA public;
|
|
||||||
ALTER TABLE tenant.document_asset_objects SET SCHEMA public;
|
|
||||||
ALTER TABLE tenant.document_assets SET SCHEMA public;
|
|
||||||
ALTER TABLE tenant.document_versions SET SCHEMA public;
|
|
||||||
ALTER TABLE tenant.documents SET SCHEMA public;
|
|
||||||
ALTER TABLE tenant.folders SET SCHEMA public;
|
|
||||||
ALTER TABLE tenant.correspondents SET SCHEMA public;
|
|
||||||
|
|
||||||
ALTER FUNCTION shared.touch_jobs_updated_at() SET SCHEMA public;
|
|
||||||
|
|
||||||
ALTER TABLE shared.magic_tokens SET SCHEMA public;
|
|
||||||
ALTER TABLE shared.jobs SET SCHEMA public;
|
|
||||||
ALTER TABLE shared.webauthn_challenges SET SCHEMA public;
|
|
||||||
ALTER TABLE shared.user_passkeys SET SCHEMA public;
|
|
||||||
ALTER TABLE shared.users SET SCHEMA public;
|
|
||||||
ALTER TABLE shared.tenants SET SCHEMA public;
|
|
||||||
|
|
||||||
ALTER TYPE shared.magic_token_kind SET SCHEMA public;
|
|
||||||
ALTER TYPE shared.tenant_status SET SCHEMA public;
|
|
||||||
|
|
||||||
DROP SCHEMA IF EXISTS tenant CASCADE;
|
|
||||||
DROP SCHEMA IF EXISTS shared CASCADE;
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
CREATE SCHEMA IF NOT EXISTS shared;
|
|
||||||
CREATE SCHEMA IF NOT EXISTS tenant;
|
|
||||||
|
|
||||||
-- Move global types and tables into the shared schema
|
|
||||||
ALTER TYPE tenant_status SET SCHEMA shared;
|
|
||||||
ALTER TYPE magic_token_kind SET SCHEMA shared;
|
|
||||||
|
|
||||||
ALTER TABLE tenants SET SCHEMA shared;
|
|
||||||
ALTER TABLE users SET SCHEMA shared;
|
|
||||||
ALTER TABLE user_passkeys SET SCHEMA shared;
|
|
||||||
ALTER TABLE webauthn_challenges SET SCHEMA shared;
|
|
||||||
ALTER TABLE jobs SET SCHEMA shared;
|
|
||||||
ALTER TABLE magic_tokens SET SCHEMA shared;
|
|
||||||
|
|
||||||
ALTER FUNCTION touch_jobs_updated_at() SET SCHEMA shared;
|
|
||||||
|
|
||||||
-- Move tenant-scoped tables into the tenant schema
|
|
||||||
ALTER TABLE correspondents SET SCHEMA tenant;
|
|
||||||
ALTER TABLE folders SET SCHEMA tenant;
|
|
||||||
ALTER TABLE documents SET SCHEMA tenant;
|
|
||||||
ALTER TABLE document_versions SET SCHEMA tenant;
|
|
||||||
ALTER TABLE document_assets SET SCHEMA tenant;
|
|
||||||
ALTER TABLE document_asset_objects SET SCHEMA tenant;
|
|
||||||
ALTER TABLE document_tags SET SCHEMA tenant;
|
|
||||||
ALTER TABLE document_correspondents SET SCHEMA tenant;
|
|
||||||
ALTER TABLE tags SET SCHEMA tenant;
|
|
||||||
ALTER TABLE refresh_tokens SET SCHEMA tenant;
|
|
||||||
ALTER TABLE user_memberships SET SCHEMA tenant;
|
|
||||||
ALTER TABLE webdav_tokens SET SCHEMA tenant;
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'papercrate_app') THEN
|
|
||||||
RETURN;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
GRANT USAGE ON SCHEMA shared TO papercrate_app;
|
|
||||||
GRANT USAGE ON SCHEMA tenant TO papercrate_app;
|
|
||||||
GRANT SELECT ON ALL TABLES IN SCHEMA shared TO papercrate_app;
|
|
||||||
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA tenant TO papercrate_app;
|
|
||||||
|
|
||||||
ALTER DEFAULT PRIVILEGES IN SCHEMA shared GRANT SELECT ON TABLES TO papercrate_app;
|
|
||||||
ALTER DEFAULT PRIVILEGES IN SCHEMA tenant GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO papercrate_app;
|
|
||||||
END
|
|
||||||
$$;
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
DROP POLICY IF EXISTS tenant_membership_select_policy ON tenant.user_memberships;
|
|
||||||
ALTER TABLE tenant.user_memberships NO FORCE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE tenant.user_memberships DISABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.webdav_tokens;
|
|
||||||
ALTER TABLE tenant.webdav_tokens NO FORCE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE tenant.webdav_tokens DISABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.tags;
|
|
||||||
ALTER TABLE tenant.tags NO FORCE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE tenant.tags DISABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
DROP POLICY IF EXISTS tenant_refresh_token_policy ON tenant.refresh_tokens;
|
|
||||||
ALTER TABLE tenant.refresh_tokens NO FORCE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE tenant.refresh_tokens DISABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.folders;
|
|
||||||
ALTER TABLE tenant.folders NO FORCE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE tenant.folders DISABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.documents;
|
|
||||||
ALTER TABLE tenant.documents NO FORCE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE tenant.documents DISABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.document_versions;
|
|
||||||
ALTER TABLE tenant.document_versions NO FORCE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE tenant.document_versions DISABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.document_tags;
|
|
||||||
ALTER TABLE tenant.document_tags NO FORCE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE tenant.document_tags DISABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.document_correspondents;
|
|
||||||
ALTER TABLE tenant.document_correspondents NO FORCE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE tenant.document_correspondents DISABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.document_assets;
|
|
||||||
ALTER TABLE tenant.document_assets NO FORCE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE tenant.document_assets DISABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.document_asset_objects;
|
|
||||||
ALTER TABLE tenant.document_asset_objects NO FORCE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE tenant.document_asset_objects DISABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.correspondents;
|
|
||||||
ALTER TABLE tenant.correspondents NO FORCE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE tenant.correspondents DISABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
DROP FUNCTION IF EXISTS shared.current_refresh_token_hash();
|
|
||||||
DROP FUNCTION IF EXISTS shared.current_user_id();
|
|
||||||
DROP FUNCTION IF EXISTS shared.current_tenant_id();
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
CREATE OR REPLACE FUNCTION shared.current_tenant_id() RETURNS uuid AS $$
|
|
||||||
SELECT CASE
|
|
||||||
WHEN setting IS NULL OR setting = '' THEN NULL
|
|
||||||
ELSE setting::uuid
|
|
||||||
END
|
|
||||||
FROM (SELECT current_setting('papercrate.tenant_id', true) AS setting) s;
|
|
||||||
$$ LANGUAGE SQL STABLE;
|
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION shared.current_user_id() RETURNS uuid AS $$
|
|
||||||
SELECT CASE
|
|
||||||
WHEN setting IS NULL OR setting = '' THEN NULL
|
|
||||||
ELSE setting::uuid
|
|
||||||
END
|
|
||||||
FROM (SELECT current_setting('papercrate.user_id', true) AS setting) s;
|
|
||||||
$$ LANGUAGE SQL STABLE;
|
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION shared.current_refresh_token_hash() RETURNS text AS $$
|
|
||||||
SELECT NULLIF(current_setting('papercrate.refresh_token_hash', true), '')
|
|
||||||
$$ LANGUAGE SQL STABLE;
|
|
||||||
|
|
||||||
-- Helper to create tenant isolation policy
|
|
||||||
CREATE OR REPLACE FUNCTION shared.ensure_tenant_policy(table_reg regclass) RETURNS void AS $$
|
|
||||||
BEGIN
|
|
||||||
EXECUTE format('ALTER TABLE %s ENABLE ROW LEVEL SECURITY', table_reg);
|
|
||||||
EXECUTE format('ALTER TABLE %s FORCE ROW LEVEL SECURITY', table_reg);
|
|
||||||
EXECUTE format(
|
|
||||||
'CREATE POLICY tenant_isolation_policy ON %s USING (tenant_id = shared.current_tenant_id()) WITH CHECK (tenant_id = shared.current_tenant_id())',
|
|
||||||
table_reg
|
|
||||||
);
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
SELECT shared.ensure_tenant_policy('tenant.correspondents');
|
|
||||||
SELECT shared.ensure_tenant_policy('tenant.document_asset_objects');
|
|
||||||
SELECT shared.ensure_tenant_policy('tenant.document_assets');
|
|
||||||
SELECT shared.ensure_tenant_policy('tenant.document_correspondents');
|
|
||||||
SELECT shared.ensure_tenant_policy('tenant.document_tags');
|
|
||||||
SELECT shared.ensure_tenant_policy('tenant.document_versions');
|
|
||||||
SELECT shared.ensure_tenant_policy('tenant.documents');
|
|
||||||
SELECT shared.ensure_tenant_policy('tenant.folders');
|
|
||||||
SELECT shared.ensure_tenant_policy('tenant.tags');
|
|
||||||
SELECT shared.ensure_tenant_policy('tenant.webdav_tokens');
|
|
||||||
|
|
||||||
-- user_memberships has a special read policy to allow tenant discovery during login
|
|
||||||
ALTER TABLE tenant.user_memberships ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE tenant.user_memberships FORCE ROW LEVEL SECURITY;
|
|
||||||
CREATE POLICY tenant_membership_select_policy ON tenant.user_memberships
|
|
||||||
USING (
|
|
||||||
tenant_id = shared.current_tenant_id()
|
|
||||||
OR (
|
|
||||||
shared.current_user_id() IS NOT NULL
|
|
||||||
AND user_id = shared.current_user_id()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
WITH CHECK (tenant_id = shared.current_tenant_id());
|
|
||||||
|
|
||||||
ALTER TABLE tenant.refresh_tokens ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE tenant.refresh_tokens FORCE ROW LEVEL SECURITY;
|
|
||||||
CREATE POLICY tenant_refresh_token_policy ON tenant.refresh_tokens
|
|
||||||
USING (
|
|
||||||
tenant_id = shared.current_tenant_id()
|
|
||||||
OR (
|
|
||||||
shared.current_refresh_token_hash() IS NOT NULL
|
|
||||||
AND token_hash = shared.current_refresh_token_hash()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
WITH CHECK (tenant_id = shared.current_tenant_id());
|
|
||||||
|
|
||||||
DROP FUNCTION shared.ensure_tenant_policy(regclass);
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
DROP POLICY IF EXISTS tenant_webdav_token_policy ON tenant.webdav_tokens;
|
|
||||||
ALTER TABLE tenant.webdav_tokens NO FORCE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE tenant.webdav_tokens DISABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
DROP FUNCTION IF EXISTS shared.current_webdav_token_prefix();
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
CREATE OR REPLACE FUNCTION shared.current_webdav_token_prefix() RETURNS text AS $$
|
|
||||||
SELECT NULLIF(current_setting('papercrate.webdav_token_prefix', true), '')
|
|
||||||
$$ LANGUAGE SQL STABLE;
|
|
||||||
|
|
||||||
ALTER TABLE tenant.webdav_tokens ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE tenant.webdav_tokens FORCE ROW LEVEL SECURITY;
|
|
||||||
CREATE POLICY tenant_webdav_token_policy ON tenant.webdav_tokens
|
|
||||||
USING (
|
|
||||||
tenant_id = shared.current_tenant_id()
|
|
||||||
OR (
|
|
||||||
shared.current_webdav_token_prefix() IS NOT NULL
|
|
||||||
AND token_prefix = shared.current_webdav_token_prefix()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
WITH CHECK (tenant_id = shared.current_tenant_id());
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
DROP POLICY IF EXISTS tenant_api_token_policy ON tenant.api_tokens;
|
|
||||||
DROP FUNCTION IF EXISTS shared.current_api_token_prefix();
|
|
||||||
|
|
||||||
ALTER TABLE tenant.api_tokens DISABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE tenant.api_tokens NO FORCE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
ALTER TABLE tenant.api_tokens
|
|
||||||
DROP COLUMN IF EXISTS capabilities;
|
|
||||||
|
|
||||||
DROP TYPE IF EXISTS shared.api_token_capability;
|
|
||||||
|
|
||||||
ALTER TABLE tenant.api_tokens RENAME TO webdav_tokens;
|
|
||||||
ALTER INDEX tenant.api_tokens_token_prefix_key RENAME TO webdav_tokens_token_prefix_key;
|
|
||||||
ALTER INDEX tenant.api_tokens_user_tenant_idx RENAME TO webdav_tokens_user_tenant_idx;
|
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION shared.current_webdav_token_prefix() RETURNS text AS $$
|
|
||||||
SELECT NULLIF(current_setting('papercrate.webdav_token_prefix', true), '')
|
|
||||||
$$ LANGUAGE SQL STABLE;
|
|
||||||
|
|
||||||
ALTER TABLE tenant.webdav_tokens ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE tenant.webdav_tokens FORCE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
CREATE POLICY tenant_webdav_token_policy ON tenant.webdav_tokens
|
|
||||||
USING (
|
|
||||||
tenant_id = shared.current_tenant_id()
|
|
||||||
OR (
|
|
||||||
shared.current_webdav_token_prefix() IS NOT NULL
|
|
||||||
AND token_prefix = shared.current_webdav_token_prefix()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
WITH CHECK (tenant_id = shared.current_tenant_id());
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
ALTER TABLE tenant.webdav_tokens RENAME TO api_tokens;
|
|
||||||
ALTER INDEX tenant.webdav_tokens_token_prefix_key RENAME TO api_tokens_token_prefix_key;
|
|
||||||
ALTER INDEX tenant.webdav_tokens_user_tenant_idx RENAME TO api_tokens_user_tenant_idx;
|
|
||||||
|
|
||||||
DROP POLICY IF EXISTS tenant_webdav_token_policy ON tenant.api_tokens;
|
|
||||||
DROP FUNCTION IF EXISTS shared.current_webdav_token_prefix();
|
|
||||||
|
|
||||||
CREATE TYPE shared.api_token_capability AS ENUM ('api', 'webdav');
|
|
||||||
|
|
||||||
ALTER TABLE tenant.api_tokens
|
|
||||||
ADD COLUMN capabilities shared.api_token_capability[] NOT NULL DEFAULT ARRAY['webdav']::shared.api_token_capability[];
|
|
||||||
|
|
||||||
ALTER TABLE tenant.api_tokens ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE tenant.api_tokens FORCE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION shared.current_api_token_prefix() RETURNS text AS $$
|
|
||||||
SELECT NULLIF(current_setting('papercrate.api_token_prefix', true), '')
|
|
||||||
$$ LANGUAGE SQL STABLE;
|
|
||||||
|
|
||||||
CREATE POLICY tenant_api_token_policy ON tenant.api_tokens
|
|
||||||
USING (
|
|
||||||
tenant_id = shared.current_tenant_id()
|
|
||||||
OR (
|
|
||||||
shared.current_api_token_prefix() IS NOT NULL
|
|
||||||
AND token_prefix = shared.current_api_token_prefix()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
WITH CHECK (tenant_id = shared.current_tenant_id());
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
CREATE OR REPLACE FUNCTION shared.current_refresh_token_hash() RETURNS text AS $$
|
|
||||||
SELECT NULLIF(current_setting('papercrate.refresh_token_hash', true), '')
|
|
||||||
$$ LANGUAGE SQL STABLE;
|
|
||||||
|
|
||||||
ALTER POLICY tenant_user_session_policy ON tenant.user_sessions
|
|
||||||
USING (
|
|
||||||
tenant_id = shared.current_tenant_id()
|
|
||||||
OR (
|
|
||||||
shared.current_refresh_token_hash() IS NOT NULL
|
|
||||||
AND token_hash = shared.current_refresh_token_hash()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
ALTER POLICY tenant_user_session_policy ON tenant.user_sessions
|
|
||||||
WITH CHECK (tenant_id = shared.current_tenant_id());
|
|
||||||
|
|
||||||
ALTER POLICY tenant_user_session_policy ON tenant.user_sessions
|
|
||||||
RENAME TO tenant_refresh_token_policy;
|
|
||||||
|
|
||||||
ALTER INDEX tenant.idx_user_sessions_user_id RENAME TO idx_refresh_tokens_user_id;
|
|
||||||
ALTER INDEX tenant.idx_user_sessions_token_hash RENAME TO idx_refresh_tokens_token_hash;
|
|
||||||
ALTER INDEX tenant.user_sessions_tenant_id_idx RENAME TO refresh_tokens_tenant_id_idx;
|
|
||||||
|
|
||||||
ALTER TABLE tenant.user_sessions RENAME TO refresh_tokens;
|
|
||||||
|
|
||||||
DROP FUNCTION IF EXISTS shared.current_user_session_hash();
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
ALTER TABLE tenant.refresh_tokens RENAME TO user_sessions;
|
|
||||||
|
|
||||||
ALTER INDEX tenant.idx_refresh_tokens_user_id RENAME TO idx_user_sessions_user_id;
|
|
||||||
ALTER INDEX tenant.idx_refresh_tokens_token_hash RENAME TO idx_user_sessions_token_hash;
|
|
||||||
ALTER INDEX tenant.refresh_tokens_tenant_id_idx RENAME TO user_sessions_tenant_id_idx;
|
|
||||||
|
|
||||||
ALTER POLICY tenant_refresh_token_policy ON tenant.user_sessions
|
|
||||||
RENAME TO tenant_user_session_policy;
|
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION shared.current_user_session_hash() RETURNS text AS $$
|
|
||||||
SELECT NULLIF(current_setting('papercrate.user_session_hash', true), '')
|
|
||||||
$$ LANGUAGE SQL STABLE;
|
|
||||||
|
|
||||||
ALTER POLICY tenant_user_session_policy ON tenant.user_sessions
|
|
||||||
USING (
|
|
||||||
tenant_id = shared.current_tenant_id()
|
|
||||||
OR (
|
|
||||||
shared.current_user_session_hash() IS NOT NULL
|
|
||||||
AND token_hash = shared.current_user_session_hash()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
ALTER POLICY tenant_user_session_policy ON tenant.user_sessions
|
|
||||||
WITH CHECK (tenant_id = shared.current_tenant_id());
|
|
||||||
|
|
||||||
DROP FUNCTION IF EXISTS shared.current_refresh_token_hash();
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
ALTER TABLE tenant.document_tags
|
|
||||||
DROP CONSTRAINT IF EXISTS document_tags_assigned_by_fkey,
|
|
||||||
ADD CONSTRAINT document_tags_assigned_by_fkey
|
|
||||||
FOREIGN KEY (assigned_by)
|
|
||||||
REFERENCES shared.users (id)
|
|
||||||
ON DELETE NO ACTION;
|
|
||||||
|
|
||||||
ALTER TABLE tenant.document_correspondents
|
|
||||||
DROP CONSTRAINT IF EXISTS document_correspondents_assigned_by_fkey,
|
|
||||||
ADD CONSTRAINT document_correspondents_assigned_by_fkey
|
|
||||||
FOREIGN KEY (assigned_by)
|
|
||||||
REFERENCES shared.users (id)
|
|
||||||
ON DELETE NO ACTION;
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
ALTER TABLE tenant.document_tags
|
|
||||||
DROP CONSTRAINT IF EXISTS document_tags_assigned_by_fkey,
|
|
||||||
ADD CONSTRAINT document_tags_assigned_by_fkey
|
|
||||||
FOREIGN KEY (assigned_by)
|
|
||||||
REFERENCES shared.users (id)
|
|
||||||
ON DELETE SET NULL;
|
|
||||||
|
|
||||||
ALTER TABLE tenant.document_correspondents
|
|
||||||
DROP CONSTRAINT IF EXISTS document_correspondents_assigned_by_fkey,
|
|
||||||
ADD CONSTRAINT document_correspondents_assigned_by_fkey
|
|
||||||
FOREIGN KEY (assigned_by)
|
|
||||||
REFERENCES shared.users (id)
|
|
||||||
ON DELETE SET NULL;
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
CREATE TYPE api_token_capability AS ENUM ('api', 'webdav');
|
|
||||||
|
|
||||||
ALTER TABLE tenant.api_tokens
|
|
||||||
ADD COLUMN capabilities api_token_capability[] NOT NULL DEFAULT ARRAY[]::api_token_capability[];
|
|
||||||
|
|
||||||
UPDATE tenant.api_tokens t
|
|
||||||
SET capabilities = ARRAY['api']::api_token_capability[]
|
|
||||||
FROM tenant.capability_sets cs
|
|
||||||
WHERE t.capability_set_id = cs.id
|
|
||||||
AND cs.slug = 'owner';
|
|
||||||
|
|
||||||
UPDATE tenant.api_tokens t
|
|
||||||
SET capabilities = ARRAY['webdav']::api_token_capability[]
|
|
||||||
FROM tenant.capability_sets cs
|
|
||||||
WHERE t.capability_set_id = cs.id
|
|
||||||
AND cs.slug = 'webdav'
|
|
||||||
AND (t.capabilities IS NULL OR array_length(t.capabilities, 1) = 0);
|
|
||||||
|
|
||||||
ALTER TABLE tenant.api_tokens
|
|
||||||
DROP COLUMN capability_set_id;
|
|
||||||
|
|
||||||
ALTER TABLE tenant.user_memberships
|
|
||||||
DROP COLUMN capability_set_id;
|
|
||||||
|
|
||||||
DROP TABLE IF EXISTS tenant.capability_set_capabilities;
|
|
||||||
DROP TABLE IF EXISTS tenant.capability_sets;
|
|
||||||
|
|
||||||
DROP TYPE IF EXISTS api_capability;
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
CREATE TYPE api_capability AS ENUM (
|
|
||||||
'documents:read',
|
|
||||||
'documents:edit',
|
|
||||||
'documents:write',
|
|
||||||
'documents:upload',
|
|
||||||
'folders:read',
|
|
||||||
'folders:edit',
|
|
||||||
'folders:write',
|
|
||||||
'tags:read',
|
|
||||||
'tags:edit',
|
|
||||||
'tags:write',
|
|
||||||
'correspondents:read',
|
|
||||||
'correspondents:edit',
|
|
||||||
'correspondents:write',
|
|
||||||
'profile:read',
|
|
||||||
'profile:write',
|
|
||||||
'webdav:read',
|
|
||||||
'webdav:write',
|
|
||||||
'capability_sets:read',
|
|
||||||
'capability_sets:write'
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE tenant.capability_sets (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES shared.tenants(id) ON DELETE CASCADE,
|
|
||||||
slug TEXT NOT NULL,
|
|
||||||
cap_version INT NOT NULL DEFAULT 1,
|
|
||||||
is_system BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
UNIQUE (tenant_id, slug)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE tenant.capability_set_capabilities (
|
|
||||||
capability_set_id UUID NOT NULL REFERENCES tenant.capability_sets(id) ON DELETE CASCADE,
|
|
||||||
capability api_capability NOT NULL,
|
|
||||||
PRIMARY KEY (capability_set_id, capability)
|
|
||||||
);
|
|
||||||
|
|
||||||
ALTER TABLE tenant.api_tokens
|
|
||||||
ADD COLUMN capability_set_id UUID REFERENCES tenant.capability_sets(id);
|
|
||||||
|
|
||||||
ALTER TABLE tenant.user_memberships
|
|
||||||
ADD COLUMN capability_set_id UUID REFERENCES tenant.capability_sets(id);
|
|
||||||
|
|
||||||
WITH owner_sets AS (
|
|
||||||
INSERT INTO tenant.capability_sets (tenant_id, slug, is_system)
|
|
||||||
SELECT id, 'owner', TRUE
|
|
||||||
FROM shared.tenants
|
|
||||||
RETURNING id, tenant_id
|
|
||||||
),
|
|
||||||
user_sets AS (
|
|
||||||
INSERT INTO tenant.capability_sets (tenant_id, slug, is_system)
|
|
||||||
SELECT id, 'user', TRUE
|
|
||||||
FROM shared.tenants
|
|
||||||
RETURNING id, tenant_id
|
|
||||||
),
|
|
||||||
readonly_sets AS (
|
|
||||||
INSERT INTO tenant.capability_sets (tenant_id, slug, is_system)
|
|
||||||
SELECT id, 'readonly', TRUE
|
|
||||||
FROM shared.tenants
|
|
||||||
RETURNING id, tenant_id
|
|
||||||
),
|
|
||||||
webdav_sets AS (
|
|
||||||
INSERT INTO tenant.capability_sets (tenant_id, slug, is_system)
|
|
||||||
SELECT id, 'webdav', TRUE
|
|
||||||
FROM shared.tenants
|
|
||||||
RETURNING id, tenant_id
|
|
||||||
)
|
|
||||||
INSERT INTO tenant.capability_set_capabilities (capability_set_id, capability)
|
|
||||||
SELECT set_id,
|
|
||||||
capability
|
|
||||||
FROM (
|
|
||||||
SELECT os.id AS set_id,
|
|
||||||
UNNEST(ARRAY[
|
|
||||||
'documents:read'::api_capability,
|
|
||||||
'documents:edit'::api_capability,
|
|
||||||
'documents:write'::api_capability,
|
|
||||||
'documents:upload'::api_capability,
|
|
||||||
'folders:read'::api_capability,
|
|
||||||
'folders:edit'::api_capability,
|
|
||||||
'folders:write'::api_capability,
|
|
||||||
'tags:read'::api_capability,
|
|
||||||
'tags:edit'::api_capability,
|
|
||||||
'tags:write'::api_capability,
|
|
||||||
'correspondents:read'::api_capability,
|
|
||||||
'correspondents:edit'::api_capability,
|
|
||||||
'correspondents:write'::api_capability,
|
|
||||||
'profile:read'::api_capability,
|
|
||||||
'profile:write'::api_capability,
|
|
||||||
'webdav:read'::api_capability,
|
|
||||||
'webdav:write'::api_capability,
|
|
||||||
'capability_sets:read'::api_capability,
|
|
||||||
'capability_sets:write'::api_capability
|
|
||||||
]) AS capability
|
|
||||||
FROM owner_sets os
|
|
||||||
UNION ALL
|
|
||||||
SELECT us.id,
|
|
||||||
UNNEST(ARRAY[
|
|
||||||
'documents:read'::api_capability,
|
|
||||||
'documents:edit'::api_capability,
|
|
||||||
'documents:write'::api_capability,
|
|
||||||
'documents:upload'::api_capability,
|
|
||||||
'folders:read'::api_capability,
|
|
||||||
'folders:edit'::api_capability,
|
|
||||||
'folders:write'::api_capability,
|
|
||||||
'tags:read'::api_capability,
|
|
||||||
'tags:edit'::api_capability,
|
|
||||||
'tags:write'::api_capability,
|
|
||||||
'correspondents:read'::api_capability,
|
|
||||||
'correspondents:edit'::api_capability,
|
|
||||||
'correspondents:write'::api_capability,
|
|
||||||
'profile:read'::api_capability,
|
|
||||||
'profile:write'::api_capability
|
|
||||||
]) AS capability
|
|
||||||
FROM user_sets us
|
|
||||||
UNION ALL
|
|
||||||
SELECT rs.id,
|
|
||||||
UNNEST(ARRAY[
|
|
||||||
'documents:read'::api_capability,
|
|
||||||
'folders:read'::api_capability,
|
|
||||||
'tags:read'::api_capability,
|
|
||||||
'correspondents:read'::api_capability,
|
|
||||||
'webdav:read'::api_capability
|
|
||||||
]) AS capability
|
|
||||||
FROM readonly_sets rs
|
|
||||||
UNION ALL
|
|
||||||
SELECT ws.id,
|
|
||||||
UNNEST(ARRAY['webdav:read'::api_capability]) AS capability
|
|
||||||
FROM webdav_sets ws
|
|
||||||
) seeded;
|
|
||||||
|
|
||||||
UPDATE tenant.user_memberships um
|
|
||||||
SET capability_set_id = cs.id
|
|
||||||
FROM tenant.capability_sets cs
|
|
||||||
WHERE cs.tenant_id = um.tenant_id
|
|
||||||
AND cs.slug = 'owner';
|
|
||||||
|
|
||||||
UPDATE tenant.api_tokens t
|
|
||||||
SET capability_set_id = cs.id
|
|
||||||
FROM tenant.capability_sets cs
|
|
||||||
WHERE cs.tenant_id = t.tenant_id
|
|
||||||
AND cs.slug = 'owner';
|
|
||||||
|
|
||||||
UPDATE tenant.api_tokens t
|
|
||||||
SET capability_set_id = cs.id
|
|
||||||
FROM tenant.capability_sets cs
|
|
||||||
WHERE cs.tenant_id = t.tenant_id
|
|
||||||
AND cs.slug = 'webdav'
|
|
||||||
AND t.capability_set_id IS NULL;
|
|
||||||
|
|
||||||
ALTER TABLE tenant.api_tokens
|
|
||||||
ALTER COLUMN capability_set_id SET NOT NULL;
|
|
||||||
|
|
||||||
ALTER TABLE tenant.api_tokens
|
|
||||||
DROP COLUMN capabilities;
|
|
||||||
|
|
||||||
DROP TYPE IF EXISTS api_token_capability;
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
DROP INDEX IF EXISTS shared.jobs_purge_document_pending_unique;
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
CREATE UNIQUE INDEX jobs_purge_document_pending_unique
|
|
||||||
ON shared.jobs (
|
|
||||||
tenant_id,
|
|
||||||
((payload ->> 'document_id')::uuid)
|
|
||||||
)
|
|
||||||
WHERE job_type = 'purge-document'
|
|
||||||
AND payload ? 'document_id'
|
|
||||||
AND status IN ('queued', 'processing');
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
DROP INDEX IF EXISTS tenant.idx_documents_tenant_folder_updated_at;
|
|
||||||
DROP INDEX IF EXISTS tenant.idx_documents_tenant_folder_created_at;
|
|
||||||
DROP INDEX IF EXISTS tenant.idx_documents_tenant_folder_issued_at;
|
|
||||||
DROP INDEX IF EXISTS tenant.idx_documents_tenant_folder_title_order;
|
|
||||||
DROP COLLATION IF EXISTS unicode_ci;
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
CREATE COLLATION IF NOT EXISTS unicode_ci
|
|
||||||
(provider = icu, locale = 'und-u-ks-level2');
|
|
||||||
|
|
||||||
CREATE INDEX idx_documents_tenant_folder_title_order
|
|
||||||
ON tenant.documents (
|
|
||||||
tenant_id,
|
|
||||||
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
|
||||||
title COLLATE "unicode_ci"
|
|
||||||
)
|
|
||||||
WHERE deleted_at IS NULL;
|
|
||||||
|
|
||||||
CREATE INDEX idx_documents_tenant_folder_issued_at
|
|
||||||
ON tenant.documents (
|
|
||||||
tenant_id,
|
|
||||||
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
|
||||||
issued_at,
|
|
||||||
title COLLATE "unicode_ci"
|
|
||||||
)
|
|
||||||
WHERE deleted_at IS NULL;
|
|
||||||
|
|
||||||
CREATE INDEX idx_documents_tenant_folder_created_at
|
|
||||||
ON tenant.documents (
|
|
||||||
tenant_id,
|
|
||||||
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
|
||||||
created_at,
|
|
||||||
title COLLATE "unicode_ci"
|
|
||||||
)
|
|
||||||
WHERE deleted_at IS NULL;
|
|
||||||
|
|
||||||
CREATE INDEX idx_documents_tenant_folder_updated_at
|
|
||||||
ON tenant.documents (
|
|
||||||
tenant_id,
|
|
||||||
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
|
||||||
updated_at,
|
|
||||||
title COLLATE "unicode_ci"
|
|
||||||
)
|
|
||||||
WHERE deleted_at IS NULL;
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
ALTER TABLE shared.jobs
|
|
||||||
DROP CONSTRAINT jobs_tenant_id_fkey;
|
|
||||||
|
|
||||||
ALTER TABLE shared.jobs
|
|
||||||
ALTER COLUMN tenant_id SET NOT NULL;
|
|
||||||
|
|
||||||
ALTER TABLE shared.jobs
|
|
||||||
ADD CONSTRAINT jobs_tenant_id_fkey
|
|
||||||
FOREIGN KEY (tenant_id)
|
|
||||||
REFERENCES shared.tenants(id);
|
|
||||||
|
|
||||||
ALTER TABLE shared.jobs
|
|
||||||
DROP COLUMN result;
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
ALTER TABLE shared.jobs
|
|
||||||
ALTER COLUMN tenant_id DROP NOT NULL;
|
|
||||||
|
|
||||||
ALTER TABLE shared.jobs
|
|
||||||
DROP CONSTRAINT jobs_tenant_id_fkey;
|
|
||||||
|
|
||||||
ALTER TABLE shared.jobs
|
|
||||||
ADD CONSTRAINT jobs_tenant_id_fkey
|
|
||||||
FOREIGN KEY (tenant_id)
|
|
||||||
REFERENCES shared.tenants(id)
|
|
||||||
ON DELETE SET NULL;
|
|
||||||
|
|
||||||
ALTER TABLE shared.jobs
|
|
||||||
ADD COLUMN result JSONB;
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
-- diesel:run_in_transaction = false
|
|
||||||
|
|
||||||
-- Enum values cannot be removed safely; this down migration intentionally left empty.
|
|
||||||
SELECT 1;
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
-- diesel:run_in_transaction = false
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
BEGIN
|
|
||||||
EXECUTE 'ALTER TYPE tenant.api_capability ADD VALUE ''tenants:write''';
|
|
||||||
EXCEPTION
|
|
||||||
WHEN undefined_object THEN
|
|
||||||
BEGIN
|
|
||||||
EXECUTE 'ALTER TYPE api_capability ADD VALUE ''tenants:write''';
|
|
||||||
EXCEPTION
|
|
||||||
WHEN duplicate_object THEN NULL;
|
|
||||||
END;
|
|
||||||
WHEN duplicate_object THEN NULL;
|
|
||||||
END;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
BEGIN
|
|
||||||
EXECUTE 'ALTER TYPE tenant.api_capability ADD VALUE ''tenants:reset''';
|
|
||||||
EXCEPTION
|
|
||||||
WHEN undefined_object THEN
|
|
||||||
BEGIN
|
|
||||||
EXECUTE 'ALTER TYPE api_capability ADD VALUE ''tenants:reset''';
|
|
||||||
EXCEPTION
|
|
||||||
WHEN duplicate_object THEN NULL;
|
|
||||||
END;
|
|
||||||
WHEN duplicate_object THEN NULL;
|
|
||||||
END;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
BEGIN
|
|
||||||
EXECUTE 'ALTER TYPE tenant.api_capability ADD VALUE ''tenants:delete''';
|
|
||||||
EXCEPTION
|
|
||||||
WHEN undefined_object THEN
|
|
||||||
BEGIN
|
|
||||||
EXECUTE 'ALTER TYPE api_capability ADD VALUE ''tenants:delete''';
|
|
||||||
EXCEPTION
|
|
||||||
WHEN duplicate_object THEN NULL;
|
|
||||||
END;
|
|
||||||
WHEN duplicate_object THEN NULL;
|
|
||||||
END;
|
|
||||||
END $$;
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
-- diesel:run_in_transaction = false
|
|
||||||
|
|
||||||
DELETE FROM tenant.capability_set_capabilities
|
|
||||||
WHERE capability IN (
|
|
||||||
'tenants:write'::api_capability,
|
|
||||||
'tenants:reset'::api_capability,
|
|
||||||
'tenants:delete'::api_capability
|
|
||||||
)
|
|
||||||
AND capability_set_id IN (SELECT id FROM tenant.capability_sets WHERE slug = 'owner');
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
-- diesel:run_in_transaction = false
|
|
||||||
|
|
||||||
WITH owner_sets AS (
|
|
||||||
SELECT id FROM tenant.capability_sets WHERE slug = 'owner'
|
|
||||||
)
|
|
||||||
INSERT INTO tenant.capability_set_capabilities (capability_set_id, capability)
|
|
||||||
SELECT id, 'tenants:write'::api_capability FROM owner_sets
|
|
||||||
ON CONFLICT DO NOTHING;
|
|
||||||
|
|
||||||
WITH owner_sets AS (
|
|
||||||
SELECT id FROM tenant.capability_sets WHERE slug = 'owner'
|
|
||||||
)
|
|
||||||
INSERT INTO tenant.capability_set_capabilities (capability_set_id, capability)
|
|
||||||
SELECT id, 'tenants:reset'::api_capability FROM owner_sets
|
|
||||||
ON CONFLICT DO NOTHING;
|
|
||||||
|
|
||||||
WITH owner_sets AS (
|
|
||||||
SELECT id FROM tenant.capability_sets WHERE slug = 'owner'
|
|
||||||
)
|
|
||||||
INSERT INTO tenant.capability_set_capabilities (capability_set_id, capability)
|
|
||||||
SELECT id, 'tenants:delete'::api_capability FROM owner_sets
|
|
||||||
ON CONFLICT DO NOTHING;
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
CREATE TABLE tenant.document_asset_objects (
|
|
||||||
id UUID PRIMARY KEY,
|
|
||||||
asset_id UUID NOT NULL REFERENCES tenant.document_assets(id) ON DELETE CASCADE,
|
|
||||||
ordinal INT NOT NULL,
|
|
||||||
s3_key TEXT NOT NULL,
|
|
||||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
||||||
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
|
|
||||||
CONSTRAINT document_asset_objects_ordinal_positive CHECK (ordinal >= 1),
|
|
||||||
CONSTRAINT document_asset_objects_asset_ordinal_unique UNIQUE (asset_id, ordinal)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_document_asset_objects_asset_ordinal
|
|
||||||
ON tenant.document_asset_objects(asset_id, ordinal);
|
|
||||||
|
|
||||||
CREATE INDEX document_asset_objects_tenant_id_idx ON tenant.document_asset_objects(tenant_id);
|
|
||||||
|
|
||||||
ALTER TABLE tenant.document_assets ADD COLUMN cardinality INT;
|
|
||||||
UPDATE tenant.document_assets SET cardinality = 1;
|
|
||||||
|
|
||||||
INSERT INTO tenant.document_asset_objects (id, asset_id, ordinal, s3_key, metadata, tenant_id)
|
|
||||||
SELECT gen_random_uuid(), id, 1, s3_key, metadata, tenant_id
|
|
||||||
FROM tenant.document_assets;
|
|
||||||
|
|
||||||
ALTER TABLE tenant.document_assets DROP COLUMN s3_key;
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
-- Prevent concurrent inserts/updates during backfill.
|
|
||||||
LOCK TABLE tenant.document_asset_objects IN ACCESS EXCLUSIVE MODE;
|
|
||||||
LOCK TABLE tenant.document_assets IN ACCESS EXCLUSIVE MODE;
|
|
||||||
|
|
||||||
ALTER TABLE tenant.document_assets ADD COLUMN s3_key TEXT;
|
|
||||||
|
|
||||||
UPDATE tenant.document_assets AS da
|
|
||||||
SET s3_key = o.s3_key,
|
|
||||||
metadata = COALESCE(da.metadata, '{}'::jsonb) || COALESCE(o.metadata, '{}'::jsonb)
|
|
||||||
FROM tenant.document_asset_objects AS o
|
|
||||||
WHERE o.asset_id = da.id
|
|
||||||
AND o.ordinal = 1;
|
|
||||||
|
|
||||||
DELETE FROM tenant.document_asset_objects WHERE ordinal <> 1;
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF EXISTS (SELECT 1 FROM tenant.document_assets WHERE s3_key IS NULL) THEN
|
|
||||||
RAISE EXCEPTION 'cannot drop document_asset_objects: some assets are missing a populated ordinal 1 object (s3_key null)';
|
|
||||||
END IF;
|
|
||||||
END
|
|
||||||
$$;
|
|
||||||
|
|
||||||
ALTER TABLE tenant.document_assets ALTER COLUMN s3_key SET NOT NULL;
|
|
||||||
ALTER TABLE tenant.document_assets DROP COLUMN cardinality;
|
|
||||||
|
|
||||||
DROP TABLE tenant.document_asset_objects;
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
-- Revert column rename.
|
|
||||||
ALTER TABLE tenant.documents
|
|
||||||
RENAME COLUMN mime_type TO content_type;
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
-- Rename document content_type column to mime_type for consistency with API.
|
|
||||||
ALTER TABLE tenant.documents
|
|
||||||
RENAME COLUMN content_type TO mime_type;
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
UPDATE tenant.document_assets
|
|
||||||
SET asset_type = 'ocr-text'
|
|
||||||
WHERE asset_type = 'text-content';
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
UPDATE tenant.document_assets
|
|
||||||
SET asset_type = 'text-content'
|
|
||||||
WHERE asset_type = 'ocr-text';
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
DROP INDEX IF EXISTS tenant.idx_documents_title_trgm;
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
|
||||||
|
|
||||||
CREATE INDEX idx_documents_title_trgm
|
|
||||||
ON tenant.documents
|
|
||||||
USING gin (title gin_trgm_ops)
|
|
||||||
WHERE deleted_at IS NULL;
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
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
|
||||||
|
);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
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
|
||||||
|
);
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- 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;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- 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);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE document_versions
|
||||||
|
ADD COLUMN operations_summary JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE document_versions
|
||||||
|
DROP COLUMN IF EXISTS operations_summary;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
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);
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
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,21 +0,0 @@
|
|||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'papercrate_app') THEN
|
|
||||||
CREATE ROLE papercrate_app NOLOGIN;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'papercrate_app_login') THEN
|
|
||||||
CREATE ROLE papercrate_app_login LOGIN PASSWORD 'papercrate_app';
|
|
||||||
GRANT papercrate_app TO papercrate_app_login;
|
|
||||||
END IF;
|
|
||||||
END
|
|
||||||
$$;
|
|
||||||
|
|
||||||
-- Ensure the login role inherits and uses a sensible search path by default
|
|
||||||
ALTER ROLE papercrate_app_login INHERIT;
|
|
||||||
ALTER ROLE papercrate_app_login SET search_path = 'tenant, shared, public';
|
|
||||||
|
|
||||||
GRANT CONNECT ON DATABASE papercrate TO papercrate_app;
|
|
||||||
GRANT CONNECT ON DATABASE papercrate TO papercrate_app_login;
|
|
||||||
GRANT USAGE ON SCHEMA public TO papercrate_app;
|
|
||||||
GRANT USAGE ON SCHEMA public TO papercrate_app_login;
|
|
||||||
@@ -1,648 +0,0 @@
|
|||||||
months:
|
|
||||||
- name: "january"
|
|
||||||
month: 1
|
|
||||||
locales:
|
|
||||||
- "en"
|
|
||||||
- name: "jan"
|
|
||||||
month: 1
|
|
||||||
locales:
|
|
||||||
- "en"
|
|
||||||
- name: "janvier"
|
|
||||||
month: 1
|
|
||||||
locales:
|
|
||||||
- "fr"
|
|
||||||
- name: "janv"
|
|
||||||
month: 1
|
|
||||||
locales:
|
|
||||||
- "fr"
|
|
||||||
- name: "januar"
|
|
||||||
month: 1
|
|
||||||
locales:
|
|
||||||
- "de"
|
|
||||||
- name: "janu\u00e1r"
|
|
||||||
month: 1
|
|
||||||
locales:
|
|
||||||
- "hu"
|
|
||||||
- name: "leden"
|
|
||||||
month: 1
|
|
||||||
locales:
|
|
||||||
- "cs"
|
|
||||||
- name: "sije\u010danj"
|
|
||||||
month: 1
|
|
||||||
locales:
|
|
||||||
- "hr"
|
|
||||||
- name: "sijecanj"
|
|
||||||
month: 1
|
|
||||||
locales:
|
|
||||||
- "hr"
|
|
||||||
- name: "stycze\u0144"
|
|
||||||
month: 1
|
|
||||||
locales:
|
|
||||||
- "pl"
|
|
||||||
- name: "styczen"
|
|
||||||
month: 1
|
|
||||||
locales:
|
|
||||||
- "pl"
|
|
||||||
- name: "ocak"
|
|
||||||
month: 1
|
|
||||||
locales:
|
|
||||||
- "tr"
|
|
||||||
- name: "february"
|
|
||||||
month: 2
|
|
||||||
locales:
|
|
||||||
- "en"
|
|
||||||
- name: "feb"
|
|
||||||
month: 2
|
|
||||||
locales:
|
|
||||||
- "en"
|
|
||||||
- name: "f\u00e9vrier"
|
|
||||||
month: 2
|
|
||||||
locales:
|
|
||||||
- "fr"
|
|
||||||
- name: "fevrier"
|
|
||||||
month: 2
|
|
||||||
locales:
|
|
||||||
- "fr"
|
|
||||||
- name: "f\u00e9vr"
|
|
||||||
month: 2
|
|
||||||
locales:
|
|
||||||
- "fr"
|
|
||||||
- name: "fevr"
|
|
||||||
month: 2
|
|
||||||
locales:
|
|
||||||
- "fr"
|
|
||||||
- name: "februar"
|
|
||||||
month: 2
|
|
||||||
locales:
|
|
||||||
- "de"
|
|
||||||
- name: "\u00fanor"
|
|
||||||
month: 2
|
|
||||||
locales:
|
|
||||||
- "cs"
|
|
||||||
- name: "unor"
|
|
||||||
month: 2
|
|
||||||
locales:
|
|
||||||
- "cs"
|
|
||||||
- name: "\u00fanora"
|
|
||||||
month: 2
|
|
||||||
locales:
|
|
||||||
- "cs"
|
|
||||||
- name: "unora"
|
|
||||||
month: 2
|
|
||||||
locales:
|
|
||||||
- "cs"
|
|
||||||
- name: "velja\u010da"
|
|
||||||
month: 2
|
|
||||||
locales:
|
|
||||||
- "hr"
|
|
||||||
- name: "veljaca"
|
|
||||||
month: 2
|
|
||||||
locales:
|
|
||||||
- "hr"
|
|
||||||
- name: "velja\u010de"
|
|
||||||
month: 2
|
|
||||||
locales:
|
|
||||||
- "hr"
|
|
||||||
- name: "veljace"
|
|
||||||
month: 2
|
|
||||||
locales:
|
|
||||||
- "hr"
|
|
||||||
- name: "luty"
|
|
||||||
month: 2
|
|
||||||
locales:
|
|
||||||
- "pl"
|
|
||||||
- name: "\u015fubat"
|
|
||||||
month: 2
|
|
||||||
locales:
|
|
||||||
- "tr"
|
|
||||||
- name: "subat"
|
|
||||||
month: 2
|
|
||||||
locales:
|
|
||||||
- "tr"
|
|
||||||
- name: "march"
|
|
||||||
month: 3
|
|
||||||
locales:
|
|
||||||
- "en"
|
|
||||||
- name: "mar"
|
|
||||||
month: 3
|
|
||||||
locales:
|
|
||||||
- "en"
|
|
||||||
- name: "m\u00e4rz"
|
|
||||||
month: 3
|
|
||||||
locales:
|
|
||||||
- "de"
|
|
||||||
- name: "maerz"
|
|
||||||
month: 3
|
|
||||||
locales:
|
|
||||||
- "de"
|
|
||||||
- name: "mars"
|
|
||||||
month: 3
|
|
||||||
locales:
|
|
||||||
- "fr"
|
|
||||||
- name: "m\u00e4r"
|
|
||||||
month: 3
|
|
||||||
locales:
|
|
||||||
- "de"
|
|
||||||
- name: "marz"
|
|
||||||
month: 3
|
|
||||||
locales:
|
|
||||||
- "de"
|
|
||||||
- name: "b\u0159ezen"
|
|
||||||
month: 3
|
|
||||||
locales:
|
|
||||||
- "cs"
|
|
||||||
- name: "brezen"
|
|
||||||
month: 3
|
|
||||||
locales:
|
|
||||||
- "cs"
|
|
||||||
- name: "b\u0159ezna"
|
|
||||||
month: 3
|
|
||||||
locales:
|
|
||||||
- "cs"
|
|
||||||
- name: "brezna"
|
|
||||||
month: 3
|
|
||||||
locales:
|
|
||||||
- "cs"
|
|
||||||
- name: "o\u017eujak"
|
|
||||||
month: 3
|
|
||||||
locales:
|
|
||||||
- "hr"
|
|
||||||
- name: "ozujak"
|
|
||||||
month: 3
|
|
||||||
locales:
|
|
||||||
- "hr"
|
|
||||||
- name: "o\u017eujka"
|
|
||||||
month: 3
|
|
||||||
locales:
|
|
||||||
- "hr"
|
|
||||||
- name: "ozujka"
|
|
||||||
month: 3
|
|
||||||
locales:
|
|
||||||
- "hr"
|
|
||||||
- name: "marzec"
|
|
||||||
month: 3
|
|
||||||
locales:
|
|
||||||
- "pl"
|
|
||||||
- name: "mart"
|
|
||||||
month: 3
|
|
||||||
locales:
|
|
||||||
- "sr"
|
|
||||||
- "bs"
|
|
||||||
- name: "m\u00e1rcius"
|
|
||||||
month: 3
|
|
||||||
locales:
|
|
||||||
- "hu"
|
|
||||||
- name: "martie"
|
|
||||||
month: 3
|
|
||||||
locales:
|
|
||||||
- "ro"
|
|
||||||
- name: "april"
|
|
||||||
month: 4
|
|
||||||
locales:
|
|
||||||
- "en"
|
|
||||||
- name: "apr"
|
|
||||||
month: 4
|
|
||||||
locales:
|
|
||||||
- "en"
|
|
||||||
- name: "avril"
|
|
||||||
month: 4
|
|
||||||
locales:
|
|
||||||
- "fr"
|
|
||||||
- name: "abril"
|
|
||||||
month: 4
|
|
||||||
locales:
|
|
||||||
- "es"
|
|
||||||
- "pt"
|
|
||||||
- name: "duben"
|
|
||||||
month: 4
|
|
||||||
locales:
|
|
||||||
- "cs"
|
|
||||||
- name: "travanj"
|
|
||||||
month: 4
|
|
||||||
locales:
|
|
||||||
- "hr"
|
|
||||||
- name: "nisan"
|
|
||||||
month: 4
|
|
||||||
locales:
|
|
||||||
- "tr"
|
|
||||||
- name: "kwiecie\u0144"
|
|
||||||
month: 4
|
|
||||||
locales:
|
|
||||||
- "pl"
|
|
||||||
- name: "kwiecien"
|
|
||||||
month: 4
|
|
||||||
locales:
|
|
||||||
- "pl"
|
|
||||||
- name: "aprile"
|
|
||||||
month: 4
|
|
||||||
locales:
|
|
||||||
- "it"
|
|
||||||
- name: "may"
|
|
||||||
month: 5
|
|
||||||
locales:
|
|
||||||
- "en"
|
|
||||||
- name: "mai"
|
|
||||||
month: 5
|
|
||||||
locales:
|
|
||||||
- "fr"
|
|
||||||
- "de"
|
|
||||||
- name: "mayo"
|
|
||||||
month: 5
|
|
||||||
locales:
|
|
||||||
- "es"
|
|
||||||
- name: "kv\u011bten"
|
|
||||||
month: 5
|
|
||||||
locales:
|
|
||||||
- "cs"
|
|
||||||
- name: "kveten"
|
|
||||||
month: 5
|
|
||||||
locales:
|
|
||||||
- "cs"
|
|
||||||
- name: "kv\u011btna"
|
|
||||||
month: 5
|
|
||||||
locales:
|
|
||||||
- "cs"
|
|
||||||
- name: "kvetna"
|
|
||||||
month: 5
|
|
||||||
locales:
|
|
||||||
- "cs"
|
|
||||||
- name: "svibanj"
|
|
||||||
month: 5
|
|
||||||
locales:
|
|
||||||
- "hr"
|
|
||||||
- name: "maj"
|
|
||||||
month: 5
|
|
||||||
locales:
|
|
||||||
- "pl"
|
|
||||||
- "bs"
|
|
||||||
- "sr"
|
|
||||||
- "hr"
|
|
||||||
- name: "may\u0131s"
|
|
||||||
month: 5
|
|
||||||
locales:
|
|
||||||
- "tr"
|
|
||||||
- name: "mayis"
|
|
||||||
month: 5
|
|
||||||
locales:
|
|
||||||
- "tr"
|
|
||||||
- name: "maggio"
|
|
||||||
month: 5
|
|
||||||
locales:
|
|
||||||
- "it"
|
|
||||||
- name: "m\u00e1j"
|
|
||||||
month: 5
|
|
||||||
locales:
|
|
||||||
- "hu"
|
|
||||||
- "sk"
|
|
||||||
- name: "june"
|
|
||||||
month: 6
|
|
||||||
locales:
|
|
||||||
- "en"
|
|
||||||
- name: "jun"
|
|
||||||
month: 6
|
|
||||||
locales:
|
|
||||||
- "en"
|
|
||||||
- "de"
|
|
||||||
- name: "juin"
|
|
||||||
month: 6
|
|
||||||
locales:
|
|
||||||
- "fr"
|
|
||||||
- name: "junio"
|
|
||||||
month: 6
|
|
||||||
locales:
|
|
||||||
- "es"
|
|
||||||
- name: "juni"
|
|
||||||
month: 6
|
|
||||||
locales:
|
|
||||||
- "de"
|
|
||||||
- name: "\u010derven"
|
|
||||||
month: 6
|
|
||||||
locales:
|
|
||||||
- "cs"
|
|
||||||
- name: "cerven"
|
|
||||||
month: 6
|
|
||||||
locales:
|
|
||||||
- "cs"
|
|
||||||
- name: "lipanj"
|
|
||||||
month: 6
|
|
||||||
locales:
|
|
||||||
- "hr"
|
|
||||||
- name: "haziran"
|
|
||||||
month: 6
|
|
||||||
locales:
|
|
||||||
- "tr"
|
|
||||||
- name: "czerwiec"
|
|
||||||
month: 6
|
|
||||||
locales:
|
|
||||||
- "pl"
|
|
||||||
- name: "j\u00fanius"
|
|
||||||
month: 6
|
|
||||||
locales:
|
|
||||||
- "hu"
|
|
||||||
- name: "giugno"
|
|
||||||
month: 6
|
|
||||||
locales:
|
|
||||||
- "it"
|
|
||||||
- name: "july"
|
|
||||||
month: 7
|
|
||||||
locales:
|
|
||||||
- "en"
|
|
||||||
- name: "jul"
|
|
||||||
month: 7
|
|
||||||
locales:
|
|
||||||
- "en"
|
|
||||||
- "de"
|
|
||||||
- name: "juillet"
|
|
||||||
month: 7
|
|
||||||
locales:
|
|
||||||
- "fr"
|
|
||||||
- name: "julio"
|
|
||||||
month: 7
|
|
||||||
locales:
|
|
||||||
- "es"
|
|
||||||
- name: "temmuz"
|
|
||||||
month: 7
|
|
||||||
locales:
|
|
||||||
- "tr"
|
|
||||||
- name: "\u010dervenec"
|
|
||||||
month: 7
|
|
||||||
locales:
|
|
||||||
- "cs"
|
|
||||||
- name: "cervenec"
|
|
||||||
month: 7
|
|
||||||
locales:
|
|
||||||
- "cs"
|
|
||||||
- name: "srpanj"
|
|
||||||
month: 7
|
|
||||||
locales:
|
|
||||||
- "hr"
|
|
||||||
- name: "lipiec"
|
|
||||||
month: 7
|
|
||||||
locales:
|
|
||||||
- "pl"
|
|
||||||
- name: "luglio"
|
|
||||||
month: 7
|
|
||||||
locales:
|
|
||||||
- "it"
|
|
||||||
- name: "j\u00falius"
|
|
||||||
month: 7
|
|
||||||
locales:
|
|
||||||
- "hu"
|
|
||||||
- name: "august"
|
|
||||||
month: 8
|
|
||||||
locales:
|
|
||||||
- "en"
|
|
||||||
- name: "aug"
|
|
||||||
month: 8
|
|
||||||
locales:
|
|
||||||
- "en"
|
|
||||||
- "de"
|
|
||||||
- name: "ao\u00fbt"
|
|
||||||
month: 8
|
|
||||||
locales:
|
|
||||||
- "fr"
|
|
||||||
- name: "aout"
|
|
||||||
month: 8
|
|
||||||
locales:
|
|
||||||
- "fr"
|
|
||||||
- name: "agosto"
|
|
||||||
month: 8
|
|
||||||
locales:
|
|
||||||
- "es"
|
|
||||||
- "pt"
|
|
||||||
- "it"
|
|
||||||
- name: "a\u011fustos"
|
|
||||||
month: 8
|
|
||||||
locales:
|
|
||||||
- "tr"
|
|
||||||
- name: "agustos"
|
|
||||||
month: 8
|
|
||||||
locales:
|
|
||||||
- "tr"
|
|
||||||
- name: "kolovoz"
|
|
||||||
month: 8
|
|
||||||
locales:
|
|
||||||
- "hr"
|
|
||||||
- name: "srpen"
|
|
||||||
month: 8
|
|
||||||
locales:
|
|
||||||
- "cs"
|
|
||||||
- name: "sierpie\u0144"
|
|
||||||
month: 8
|
|
||||||
locales:
|
|
||||||
- "pl"
|
|
||||||
- name: "sierpien"
|
|
||||||
month: 8
|
|
||||||
locales:
|
|
||||||
- "pl"
|
|
||||||
- name: "augustus"
|
|
||||||
month: 8
|
|
||||||
locales:
|
|
||||||
- "nl"
|
|
||||||
- name: "agost"
|
|
||||||
month: 8
|
|
||||||
locales:
|
|
||||||
- "it"
|
|
||||||
- "ca"
|
|
||||||
- name: "september"
|
|
||||||
month: 9
|
|
||||||
locales:
|
|
||||||
- "en"
|
|
||||||
- name: "sept"
|
|
||||||
month: 9
|
|
||||||
locales:
|
|
||||||
- "en"
|
|
||||||
- "fr"
|
|
||||||
- name: "sep"
|
|
||||||
month: 9
|
|
||||||
locales:
|
|
||||||
- "en"
|
|
||||||
- name: "septembre"
|
|
||||||
month: 9
|
|
||||||
locales:
|
|
||||||
- "fr"
|
|
||||||
- name: "septiembre"
|
|
||||||
month: 9
|
|
||||||
locales:
|
|
||||||
- "es"
|
|
||||||
- name: "eyl\u00fcl"
|
|
||||||
month: 9
|
|
||||||
locales:
|
|
||||||
- "tr"
|
|
||||||
- name: "eylul"
|
|
||||||
month: 9
|
|
||||||
locales:
|
|
||||||
- "tr"
|
|
||||||
- name: "z\u00e1\u0159\u00ed"
|
|
||||||
month: 9
|
|
||||||
locales:
|
|
||||||
- "cs"
|
|
||||||
- name: "zari"
|
|
||||||
month: 9
|
|
||||||
locales:
|
|
||||||
- "cs"
|
|
||||||
- name: "rujan"
|
|
||||||
month: 9
|
|
||||||
locales:
|
|
||||||
- "hr"
|
|
||||||
- name: "wrzesie\u0144"
|
|
||||||
month: 9
|
|
||||||
locales:
|
|
||||||
- "pl"
|
|
||||||
- name: "wrzesien"
|
|
||||||
month: 9
|
|
||||||
locales:
|
|
||||||
- "pl"
|
|
||||||
- name: "septembrie"
|
|
||||||
month: 9
|
|
||||||
locales:
|
|
||||||
- "ro"
|
|
||||||
- name: "settembre"
|
|
||||||
month: 9
|
|
||||||
locales:
|
|
||||||
- "it"
|
|
||||||
- name: "october"
|
|
||||||
month: 10
|
|
||||||
locales:
|
|
||||||
- "en"
|
|
||||||
- name: "oct"
|
|
||||||
month: 10
|
|
||||||
locales:
|
|
||||||
- "en"
|
|
||||||
- name: "oktober"
|
|
||||||
month: 10
|
|
||||||
locales:
|
|
||||||
- "de"
|
|
||||||
- name: "okt\u00f3ber"
|
|
||||||
month: 10
|
|
||||||
locales:
|
|
||||||
- "hu"
|
|
||||||
- name: "octobre"
|
|
||||||
month: 10
|
|
||||||
locales:
|
|
||||||
- "fr"
|
|
||||||
- name: "octubre"
|
|
||||||
month: 10
|
|
||||||
locales:
|
|
||||||
- "es"
|
|
||||||
- name: "\u0159\u00edjen"
|
|
||||||
month: 10
|
|
||||||
locales:
|
|
||||||
- "cs"
|
|
||||||
- name: "rijen"
|
|
||||||
month: 10
|
|
||||||
locales:
|
|
||||||
- "hr"
|
|
||||||
- name: "pa\u017adziernik"
|
|
||||||
month: 10
|
|
||||||
locales:
|
|
||||||
- "pl"
|
|
||||||
- name: "pazdziernik"
|
|
||||||
month: 10
|
|
||||||
locales:
|
|
||||||
- "pl"
|
|
||||||
- name: "ekim"
|
|
||||||
month: 10
|
|
||||||
locales:
|
|
||||||
- "tr"
|
|
||||||
- name: "octombrie"
|
|
||||||
month: 10
|
|
||||||
locales:
|
|
||||||
- "ro"
|
|
||||||
- name: "november"
|
|
||||||
month: 11
|
|
||||||
locales:
|
|
||||||
- "en"
|
|
||||||
- name: "nov"
|
|
||||||
month: 11
|
|
||||||
locales:
|
|
||||||
- "en"
|
|
||||||
- name: "novembre"
|
|
||||||
month: 11
|
|
||||||
locales:
|
|
||||||
- "fr"
|
|
||||||
- name: "noviembre"
|
|
||||||
month: 11
|
|
||||||
locales:
|
|
||||||
- "es"
|
|
||||||
- name: "studeni"
|
|
||||||
month: 11
|
|
||||||
locales:
|
|
||||||
- "hr"
|
|
||||||
- name: "kas\u0131m"
|
|
||||||
month: 11
|
|
||||||
locales:
|
|
||||||
- "tr"
|
|
||||||
- name: "kasim"
|
|
||||||
month: 11
|
|
||||||
locales:
|
|
||||||
- "tr"
|
|
||||||
- name: "listopad"
|
|
||||||
month: 11
|
|
||||||
locales:
|
|
||||||
- "pl"
|
|
||||||
- "cs"
|
|
||||||
- name: "novembro"
|
|
||||||
month: 11
|
|
||||||
locales:
|
|
||||||
- "pt"
|
|
||||||
- name: "listopadu"
|
|
||||||
month: 11
|
|
||||||
locales:
|
|
||||||
- "cs"
|
|
||||||
- name: "noiembrie"
|
|
||||||
month: 11
|
|
||||||
locales:
|
|
||||||
- "ro"
|
|
||||||
- name: "december"
|
|
||||||
month: 12
|
|
||||||
locales:
|
|
||||||
- "en"
|
|
||||||
- name: "dec"
|
|
||||||
month: 12
|
|
||||||
locales:
|
|
||||||
- "en"
|
|
||||||
- name: "dezember"
|
|
||||||
month: 12
|
|
||||||
locales:
|
|
||||||
- "de"
|
|
||||||
- name: "d\u00e9cembre"
|
|
||||||
month: 12
|
|
||||||
locales:
|
|
||||||
- "fr"
|
|
||||||
- name: "decembre"
|
|
||||||
month: 12
|
|
||||||
locales:
|
|
||||||
- "fr"
|
|
||||||
- name: "prosinec"
|
|
||||||
month: 12
|
|
||||||
locales:
|
|
||||||
- "cs"
|
|
||||||
- name: "prosinac"
|
|
||||||
month: 12
|
|
||||||
locales:
|
|
||||||
- "hr"
|
|
||||||
- name: "grudzie\u0144"
|
|
||||||
month: 12
|
|
||||||
locales:
|
|
||||||
- "pl"
|
|
||||||
- name: "grudzien"
|
|
||||||
month: 12
|
|
||||||
locales:
|
|
||||||
- "pl"
|
|
||||||
- name: "grudnia"
|
|
||||||
month: 12
|
|
||||||
locales:
|
|
||||||
- "pl"
|
|
||||||
- name: "aral\u0131k"
|
|
||||||
month: 12
|
|
||||||
locales:
|
|
||||||
- "tr"
|
|
||||||
- name: "aralik"
|
|
||||||
month: 12
|
|
||||||
locales:
|
|
||||||
- "tr"
|
|
||||||
- name: "decembrie"
|
|
||||||
month: 12
|
|
||||||
locales:
|
|
||||||
- "ro"
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
set -e
|
|
||||||
|
|
||||||
echo "Waiting for Postgres..."
|
|
||||||
until pg_isready -h postgres -U papercrate; do
|
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "Running migrations..."
|
|
||||||
diesel migration run
|
|
||||||
|
|
||||||
echo "Building binaries for first run..."
|
|
||||||
cargo build --bin backend --bin worker --bin webdav
|
|
||||||
|
|
||||||
echo "Starting supervisord..."
|
|
||||||
exec supervisord -c supervisord.conf
|
|
||||||
@@ -1,324 +0,0 @@
|
|||||||
use argon2::{
|
|
||||||
password_hash::{rand_core::OsRng as PasswordHashOsRng, PasswordHasher, SaltString},
|
|
||||||
Argon2,
|
|
||||||
};
|
|
||||||
use chrono::{NaiveDateTime, Utc};
|
|
||||||
use diesel::prelude::*;
|
|
||||||
use rand::{rngs::OsRng, TryRngCore};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
auth::capability_sets::{load_capabilities_for_set, load_capability_set},
|
|
||||||
error::AppError,
|
|
||||||
models::{ApiCapability, ApiToken, CapabilitySet, NewApiToken},
|
|
||||||
schema::api_tokens,
|
|
||||||
state::PgPooledConnection,
|
|
||||||
tenants::{apply_api_token_prefix, clear_api_token_prefix},
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::schema::api_tokens::dsl as api_tokens_dsl;
|
|
||||||
|
|
||||||
const TOKEN_PREFIX_LENGTH: usize = 12;
|
|
||||||
const TOKEN_SECRET_LENGTH: usize = 32;
|
|
||||||
|
|
||||||
/// Represents a newly issued API token and the raw secret that was generated for it.
|
|
||||||
pub struct IssuedApiToken {
|
|
||||||
pub token: String,
|
|
||||||
pub record: ApiToken,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Creates a new API token for the supplied user/tenant combination.
|
|
||||||
pub fn create_api_token(
|
|
||||||
conn: &mut PgPooledConnection,
|
|
||||||
user_id: Uuid,
|
|
||||||
tenant_id: Uuid,
|
|
||||||
label: Option<String>,
|
|
||||||
expires_at: Option<NaiveDateTime>,
|
|
||||||
capability_set_id: Uuid,
|
|
||||||
) -> Result<IssuedApiToken, AppError> {
|
|
||||||
let capability_set =
|
|
||||||
validate_capability_set_belongs_to_tenant(conn, capability_set_id, tenant_id)?;
|
|
||||||
|
|
||||||
let raw_secret = generate_secret()?;
|
|
||||||
let token_prefix = raw_secret[..TOKEN_PREFIX_LENGTH].to_string();
|
|
||||||
let token_hash = hash_secret(&raw_secret)?;
|
|
||||||
let new_token = NewApiToken {
|
|
||||||
id: Uuid::new_v4(),
|
|
||||||
user_id,
|
|
||||||
tenant_id,
|
|
||||||
token_prefix,
|
|
||||||
token_hash,
|
|
||||||
label,
|
|
||||||
expires_at,
|
|
||||||
capability_set_id: capability_set.id,
|
|
||||||
};
|
|
||||||
|
|
||||||
let record = diesel::insert_into(api_tokens::table)
|
|
||||||
.values(&new_token)
|
|
||||||
.get_result::<ApiToken>(conn)?;
|
|
||||||
|
|
||||||
Ok(IssuedApiToken {
|
|
||||||
token: raw_secret,
|
|
||||||
record,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Lists API tokens belonging to a user within an optional tenant scope.
|
|
||||||
pub fn list_api_tokens(
|
|
||||||
conn: &mut PgPooledConnection,
|
|
||||||
user_id: Uuid,
|
|
||||||
tenant_id: Option<Uuid>,
|
|
||||||
) -> Result<Vec<ApiToken>, AppError> {
|
|
||||||
let mut query = api_tokens::table
|
|
||||||
.filter(api_tokens::user_id.eq(user_id))
|
|
||||||
.into_boxed();
|
|
||||||
|
|
||||||
if let Some(tenant_id) = tenant_id {
|
|
||||||
query = query.filter(api_tokens::tenant_id.eq(tenant_id));
|
|
||||||
}
|
|
||||||
|
|
||||||
let tokens = query
|
|
||||||
.order(api_tokens::created_at.asc())
|
|
||||||
.load::<ApiToken>(conn)?;
|
|
||||||
|
|
||||||
Ok(tokens)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Regenerates the secret value for an API token.
|
|
||||||
pub fn regenerate_api_token(
|
|
||||||
conn: &mut PgPooledConnection,
|
|
||||||
token_id: Uuid,
|
|
||||||
user_id: Uuid,
|
|
||||||
tenant_id: Option<Uuid>,
|
|
||||||
) -> Result<IssuedApiToken, AppError> {
|
|
||||||
let record = find_user_token(conn, token_id, user_id, tenant_id)?;
|
|
||||||
|
|
||||||
if record.revoked_at.is_some() {
|
|
||||||
return Err(AppError::bad_request(
|
|
||||||
"cannot regenerate a revoked API token",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let raw_secret = generate_secret()?;
|
|
||||||
let token_prefix = raw_secret[..TOKEN_PREFIX_LENGTH].to_string();
|
|
||||||
let token_hash = hash_secret(&raw_secret)?;
|
|
||||||
|
|
||||||
let updated = diesel::update(api_tokens::table.find(record.id))
|
|
||||||
.set((
|
|
||||||
api_tokens::token_prefix.eq(&token_prefix),
|
|
||||||
api_tokens::token_hash.eq(&token_hash),
|
|
||||||
api_tokens::last_used_at.eq::<Option<NaiveDateTime>>(None),
|
|
||||||
))
|
|
||||||
.get_result::<ApiToken>(conn)?;
|
|
||||||
|
|
||||||
Ok(IssuedApiToken {
|
|
||||||
token: raw_secret,
|
|
||||||
record: updated,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Attempts to resolve an API token by its secret value while ensuring it provides the
|
|
||||||
/// requested capability.
|
|
||||||
pub fn find_active_token_by_secret(
|
|
||||||
conn: &mut PgPooledConnection,
|
|
||||||
tenant_id: Option<Uuid>,
|
|
||||||
secret: &str,
|
|
||||||
required_capability: Option<ApiCapability>,
|
|
||||||
) -> Result<Option<ApiToken>, AppError> {
|
|
||||||
if secret.len() < TOKEN_PREFIX_LENGTH {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let prefix = &secret[..TOKEN_PREFIX_LENGTH];
|
|
||||||
let candidates = with_api_token_prefix(conn, prefix, |conn| {
|
|
||||||
let mut query = api_tokens::table
|
|
||||||
.filter(api_tokens::token_prefix.eq(prefix))
|
|
||||||
.filter(api_tokens::revoked_at.is_null())
|
|
||||||
.into_boxed();
|
|
||||||
|
|
||||||
let now = Utc::now().naive_utc();
|
|
||||||
query = query.filter(
|
|
||||||
api_tokens::expires_at
|
|
||||||
.is_null()
|
|
||||||
.or(api_tokens::expires_at.gt(now)),
|
|
||||||
);
|
|
||||||
|
|
||||||
if let Some(tenant_id) = tenant_id {
|
|
||||||
query = query.filter(api_tokens::tenant_id.eq(tenant_id));
|
|
||||||
}
|
|
||||||
|
|
||||||
query.load::<ApiToken>(conn).map_err(AppError::from)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
for token in candidates {
|
|
||||||
if let Some(required) = required_capability {
|
|
||||||
let capabilities = load_capabilities_for_set(conn, token.capability_set_id)?;
|
|
||||||
if !capabilities.contains(&required) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if verify_token_secret(secret, &token.token_hash)? {
|
|
||||||
return Ok(Some(token));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Revokes an API token belonging to the specified user.
|
|
||||||
pub fn revoke_api_token(
|
|
||||||
conn: &mut PgPooledConnection,
|
|
||||||
token_id: Uuid,
|
|
||||||
user_id: Uuid,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let token = find_user_token(conn, token_id, user_id, None)?;
|
|
||||||
|
|
||||||
diesel::update(api_tokens::table.find(token.id))
|
|
||||||
.set(api_tokens::revoked_at.eq(Utc::now().naive_utc()))
|
|
||||||
.execute(conn)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Updates the last-used timestamp for a token.
|
|
||||||
pub fn touch_api_token(conn: &mut PgPooledConnection, token_id: Uuid) -> Result<(), AppError> {
|
|
||||||
diesel::update(api_tokens::table.filter(api_tokens::id.eq(token_id)))
|
|
||||||
.set(api_tokens::last_used_at.eq(Utc::now().naive_utc()))
|
|
||||||
.execute(conn)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Verifies a secret against its stored hash representation.
|
|
||||||
pub fn verify_token_secret(secret: &str, token_hash: &str) -> Result<bool, AppError> {
|
|
||||||
crate::auth::password::verify_password(secret, token_hash).map_err(|err| {
|
|
||||||
tracing::error!(error = ?err, "failed to verify token");
|
|
||||||
AppError::internal("failed to verify token")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn find_user_token(
|
|
||||||
conn: &mut PgPooledConnection,
|
|
||||||
token_id: Uuid,
|
|
||||||
user_id: Uuid,
|
|
||||||
tenant_id: Option<Uuid>,
|
|
||||||
) -> Result<ApiToken, AppError> {
|
|
||||||
let mut query = api_tokens_dsl::api_tokens
|
|
||||||
.filter(api_tokens_dsl::id.eq(token_id))
|
|
||||||
.filter(api_tokens_dsl::user_id.eq(user_id))
|
|
||||||
.into_boxed();
|
|
||||||
|
|
||||||
if let Some(tid) = tenant_id {
|
|
||||||
query = query.filter(api_tokens_dsl::tenant_id.eq(tid));
|
|
||||||
}
|
|
||||||
|
|
||||||
query
|
|
||||||
.first::<ApiToken>(conn)
|
|
||||||
.optional()
|
|
||||||
.map_err(AppError::from)?
|
|
||||||
.ok_or_else(AppError::not_found)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_capability_set_belongs_to_tenant(
|
|
||||||
conn: &mut PgPooledConnection,
|
|
||||||
capability_set_id: Uuid,
|
|
||||||
tenant_id: Uuid,
|
|
||||||
) -> Result<CapabilitySet, AppError> {
|
|
||||||
let capability_set = load_capability_set(conn, capability_set_id)?;
|
|
||||||
if capability_set.tenant_id != tenant_id {
|
|
||||||
return Err(AppError::bad_request(
|
|
||||||
"capability set does not belong to the tenant",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(capability_set)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn with_api_token_prefix<T, F>(
|
|
||||||
conn: &mut PgPooledConnection,
|
|
||||||
prefix: &str,
|
|
||||||
operation: F,
|
|
||||||
) -> Result<T, AppError>
|
|
||||||
where
|
|
||||||
F: FnOnce(&mut PgPooledConnection) -> Result<T, AppError>,
|
|
||||||
{
|
|
||||||
apply_api_token_prefix(conn, prefix)?;
|
|
||||||
let operation_result = operation(conn);
|
|
||||||
let clear_result = clear_api_token_prefix(conn);
|
|
||||||
|
|
||||||
if let Err(err) = clear_result {
|
|
||||||
return Err(err);
|
|
||||||
}
|
|
||||||
|
|
||||||
operation_result
|
|
||||||
}
|
|
||||||
|
|
||||||
fn generate_secret() -> Result<String, AppError> {
|
|
||||||
let mut buffer = [0u8; TOKEN_SECRET_LENGTH];
|
|
||||||
OsRng.try_fill_bytes(&mut buffer).map_err(|err| {
|
|
||||||
tracing::error!(error = ?err, "failed to generate token");
|
|
||||||
AppError::internal("failed to generate token")
|
|
||||||
})?;
|
|
||||||
Ok(hex::encode(buffer))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn hash_secret(secret: &str) -> Result<String, AppError> {
|
|
||||||
let mut salt_rng = PasswordHashOsRng;
|
|
||||||
let salt = SaltString::generate(&mut salt_rng);
|
|
||||||
let hash = Argon2::default()
|
|
||||||
.hash_password(secret.as_bytes(), &salt)
|
|
||||||
.map_err(|err| {
|
|
||||||
tracing::error!(error = ?err, "failed to hash token");
|
|
||||||
AppError::internal("failed to hash token")
|
|
||||||
})?;
|
|
||||||
Ok(hash.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::auth::capability_sets::{
|
|
||||||
compute_slug, normalize_capabilities, owner_capabilities, webdav_capabilities,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn generated_secret_has_expected_length() {
|
|
||||||
let secret = generate_secret().unwrap();
|
|
||||||
assert_eq!(secret.len(), TOKEN_SECRET_LENGTH * 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn hash_and_verify_secret_round_trip() {
|
|
||||||
let secret = generate_secret().unwrap();
|
|
||||||
let hash = hash_secret(&secret).unwrap();
|
|
||||||
assert!(verify_token_secret(&secret, &hash).unwrap());
|
|
||||||
assert!(!verify_token_secret("wrong", &hash).unwrap());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn normalize_capabilities_deduplicates() {
|
|
||||||
let mut caps = owner_capabilities().to_vec();
|
|
||||||
caps.push(ApiCapability::DocumentsRead);
|
|
||||||
let normalized = normalize_capabilities(caps).unwrap();
|
|
||||||
assert_eq!(normalized.len(), owner_capabilities().len());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn normalize_capabilities_rejects_empty() {
|
|
||||||
assert!(normalize_capabilities(Vec::new()).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn compute_slug_matches_system_sets() {
|
|
||||||
let owner_slug = compute_slug(owner_capabilities());
|
|
||||||
assert_eq!(owner_slug, "owner");
|
|
||||||
|
|
||||||
let webdav_slug = compute_slug(webdav_capabilities());
|
|
||||||
assert_eq!(webdav_slug, "webdav");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn prefix_length_is_less_than_secret_length() {
|
|
||||||
assert!(TOKEN_PREFIX_LENGTH < TOKEN_SECRET_LENGTH * 2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use axum::{
|
|
||||||
http::{Request, StatusCode},
|
|
||||||
response::IntoResponse,
|
|
||||||
};
|
|
||||||
use tower::{Layer, Service};
|
|
||||||
|
|
||||||
use crate::{auth::AuthenticatedUser, error::AppError, models::ApiCapability};
|
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
|
||||||
pub enum CapabilityStrategy {
|
|
||||||
All,
|
|
||||||
Any,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct RequireCapabilitiesLayer {
|
|
||||||
required: Arc<Vec<ApiCapability>>,
|
|
||||||
strategy: CapabilityStrategy,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RequireCapabilitiesLayer {
|
|
||||||
pub fn all<I>(caps: I) -> Self
|
|
||||||
where
|
|
||||||
I: IntoIterator<Item = ApiCapability>,
|
|
||||||
{
|
|
||||||
Self {
|
|
||||||
required: Arc::new(caps.into_iter().collect()),
|
|
||||||
strategy: CapabilityStrategy::All,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn any<I>(caps: I) -> Self
|
|
||||||
where
|
|
||||||
I: IntoIterator<Item = ApiCapability>,
|
|
||||||
{
|
|
||||||
Self {
|
|
||||||
required: Arc::new(caps.into_iter().collect()),
|
|
||||||
strategy: CapabilityStrategy::Any,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<S> Layer<S> for RequireCapabilitiesLayer {
|
|
||||||
type Service = RequireCapabilities<S>;
|
|
||||||
|
|
||||||
fn layer(&self, inner: S) -> Self::Service {
|
|
||||||
RequireCapabilities {
|
|
||||||
inner,
|
|
||||||
required: Arc::clone(&self.required),
|
|
||||||
strategy: self.strategy,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct RequireCapabilities<S> {
|
|
||||||
inner: S,
|
|
||||||
required: Arc<Vec<ApiCapability>>,
|
|
||||||
strategy: CapabilityStrategy,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<S, B> Service<Request<B>> for RequireCapabilities<S>
|
|
||||||
where
|
|
||||||
S: Service<Request<B>, Response = axum::response::Response> + Send,
|
|
||||||
S::Future: Send + 'static,
|
|
||||||
B: Send + 'static,
|
|
||||||
{
|
|
||||||
type Response = S::Response;
|
|
||||||
type Error = S::Error;
|
|
||||||
type Future = std::pin::Pin<
|
|
||||||
Box<dyn std::future::Future<Output = Result<Self::Response, Self::Error>> + Send>,
|
|
||||||
>;
|
|
||||||
|
|
||||||
fn poll_ready(
|
|
||||||
&mut self,
|
|
||||||
cx: &mut std::task::Context<'_>,
|
|
||||||
) -> std::task::Poll<Result<(), Self::Error>> {
|
|
||||||
self.inner.poll_ready(cx)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn call(&mut self, req: Request<B>) -> Self::Future {
|
|
||||||
if self.required.is_empty() {
|
|
||||||
let fut = self.inner.call(req);
|
|
||||||
return Box::pin(async move { fut.await });
|
|
||||||
}
|
|
||||||
|
|
||||||
let (parts, body) = req.into_parts();
|
|
||||||
let user = match parts.extensions.get::<AuthenticatedUser>() {
|
|
||||||
Some(user) => user,
|
|
||||||
None => {
|
|
||||||
let response = AppError::unauthorized().into_response();
|
|
||||||
return Box::pin(async move { Ok(response) });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let allowed = match self.strategy {
|
|
||||||
CapabilityStrategy::All => self
|
|
||||||
.required
|
|
||||||
.iter()
|
|
||||||
.all(|cap| user.capabilities.contains(cap)),
|
|
||||||
CapabilityStrategy::Any => self
|
|
||||||
.required
|
|
||||||
.iter()
|
|
||||||
.any(|cap| user.capabilities.contains(cap)),
|
|
||||||
};
|
|
||||||
|
|
||||||
if !allowed {
|
|
||||||
let response = AppError::new(StatusCode::FORBIDDEN, "missing required capability")
|
|
||||||
.with_code("missing_capability")
|
|
||||||
.into_response();
|
|
||||||
return Box::pin(async move { Ok(response) });
|
|
||||||
}
|
|
||||||
|
|
||||||
let req = Request::from_parts(parts, body);
|
|
||||||
let fut = self.inner.call(req);
|
|
||||||
Box::pin(async move { fut.await })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,323 +0,0 @@
|
|||||||
use chrono::Utc;
|
|
||||||
use diesel::{pg::Pg, prelude::*, Connection};
|
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
error::AppError,
|
|
||||||
models::{ApiCapability, CapabilitySet, NewCapabilitySet, NewCapabilitySetCapability},
|
|
||||||
schema::{
|
|
||||||
capability_set_capabilities, capability_set_capabilities::dsl as csc_dsl, capability_sets,
|
|
||||||
capability_sets::dsl as cs_dsl,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const OWNER_CAPABILITIES: [ApiCapability; 22] = [
|
|
||||||
ApiCapability::CorrespondentsEdit,
|
|
||||||
ApiCapability::CorrespondentsRead,
|
|
||||||
ApiCapability::CorrespondentsWrite,
|
|
||||||
ApiCapability::DocumentsEdit,
|
|
||||||
ApiCapability::DocumentsRead,
|
|
||||||
ApiCapability::DocumentsUpload,
|
|
||||||
ApiCapability::DocumentsWrite,
|
|
||||||
ApiCapability::FoldersEdit,
|
|
||||||
ApiCapability::FoldersRead,
|
|
||||||
ApiCapability::FoldersWrite,
|
|
||||||
ApiCapability::ProfileRead,
|
|
||||||
ApiCapability::ProfileWrite,
|
|
||||||
ApiCapability::TagsEdit,
|
|
||||||
ApiCapability::TagsRead,
|
|
||||||
ApiCapability::TagsWrite,
|
|
||||||
ApiCapability::WebdavRead,
|
|
||||||
ApiCapability::WebdavWrite,
|
|
||||||
ApiCapability::CapabilitySetsRead,
|
|
||||||
ApiCapability::CapabilitySetsWrite,
|
|
||||||
ApiCapability::TenantsWrite,
|
|
||||||
ApiCapability::TenantsReset,
|
|
||||||
ApiCapability::TenantsDelete,
|
|
||||||
];
|
|
||||||
|
|
||||||
const USER_CAPABILITIES: [ApiCapability; 16] = [
|
|
||||||
ApiCapability::CorrespondentsEdit,
|
|
||||||
ApiCapability::CorrespondentsRead,
|
|
||||||
ApiCapability::CorrespondentsWrite,
|
|
||||||
ApiCapability::DocumentsEdit,
|
|
||||||
ApiCapability::DocumentsRead,
|
|
||||||
ApiCapability::DocumentsUpload,
|
|
||||||
ApiCapability::DocumentsWrite,
|
|
||||||
ApiCapability::FoldersEdit,
|
|
||||||
ApiCapability::FoldersRead,
|
|
||||||
ApiCapability::FoldersWrite,
|
|
||||||
ApiCapability::ProfileRead,
|
|
||||||
ApiCapability::ProfileWrite,
|
|
||||||
ApiCapability::TagsEdit,
|
|
||||||
ApiCapability::TagsRead,
|
|
||||||
ApiCapability::TagsWrite,
|
|
||||||
ApiCapability::WebdavRead,
|
|
||||||
];
|
|
||||||
|
|
||||||
const READONLY_CAPABILITIES: [ApiCapability; 5] = [
|
|
||||||
ApiCapability::CorrespondentsRead,
|
|
||||||
ApiCapability::DocumentsRead,
|
|
||||||
ApiCapability::FoldersRead,
|
|
||||||
ApiCapability::TagsRead,
|
|
||||||
ApiCapability::WebdavRead,
|
|
||||||
];
|
|
||||||
|
|
||||||
const WEBDAV_CAPABILITIES: [ApiCapability; 1] = [ApiCapability::WebdavRead];
|
|
||||||
|
|
||||||
pub fn owner_capabilities() -> &'static [ApiCapability] {
|
|
||||||
&OWNER_CAPABILITIES
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn user_capabilities() -> &'static [ApiCapability] {
|
|
||||||
&USER_CAPABILITIES
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn readonly_capabilities() -> &'static [ApiCapability] {
|
|
||||||
&READONLY_CAPABILITIES
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn webdav_capabilities() -> &'static [ApiCapability] {
|
|
||||||
&WEBDAV_CAPABILITIES
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_system_slug(slug: &str) -> bool {
|
|
||||||
matches!(slug, "owner" | "user" | "readonly" | "webdav")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn create_capability_set<C>(
|
|
||||||
conn: &mut C,
|
|
||||||
tenant_id: Uuid,
|
|
||||||
slug: &str,
|
|
||||||
capabilities: Vec<ApiCapability>,
|
|
||||||
) -> Result<CapabilitySet, AppError>
|
|
||||||
where
|
|
||||||
C: Connection<Backend = Pg> + diesel::connection::LoadConnection,
|
|
||||||
{
|
|
||||||
let normalized = normalize_capabilities(capabilities)?;
|
|
||||||
|
|
||||||
conn.transaction::<CapabilitySet, AppError, _>(|conn| {
|
|
||||||
if cs_dsl::capability_sets
|
|
||||||
.filter(cs_dsl::tenant_id.eq(tenant_id))
|
|
||||||
.filter(cs_dsl::slug.eq(slug))
|
|
||||||
.first::<CapabilitySet>(conn)
|
|
||||||
.optional()
|
|
||||||
.map_err(AppError::from)?
|
|
||||||
.is_some()
|
|
||||||
{
|
|
||||||
return Err(AppError::conflict("capability set slug already exists"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let set = NewCapabilitySet {
|
|
||||||
id: Uuid::new_v4(),
|
|
||||||
tenant_id,
|
|
||||||
slug: slug.to_owned(),
|
|
||||||
cap_version: 1,
|
|
||||||
is_system: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
diesel::insert_into(capability_sets::table)
|
|
||||||
.values(&set)
|
|
||||||
.execute(conn)
|
|
||||||
.map_err(AppError::from)?;
|
|
||||||
|
|
||||||
persist_capabilities(conn, set.id, &normalized)?;
|
|
||||||
|
|
||||||
capability_sets::table
|
|
||||||
.find(set.id)
|
|
||||||
.first::<CapabilitySet>(conn)
|
|
||||||
.map_err(AppError::from)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn normalize_capabilities(
|
|
||||||
mut capabilities: Vec<ApiCapability>,
|
|
||||||
) -> Result<Vec<ApiCapability>, AppError> {
|
|
||||||
if capabilities.is_empty() {
|
|
||||||
return Err(AppError::bad_request("at least one capability is required"));
|
|
||||||
}
|
|
||||||
|
|
||||||
capabilities.sort_by(|a, b| a.as_str().cmp(b.as_str()));
|
|
||||||
capabilities.dedup();
|
|
||||||
Ok(capabilities)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn load_capabilities_for_set<C>(
|
|
||||||
conn: &mut C,
|
|
||||||
capability_set_id: Uuid,
|
|
||||||
) -> Result<Vec<ApiCapability>, AppError>
|
|
||||||
where
|
|
||||||
C: Connection<Backend = Pg> + diesel::connection::LoadConnection,
|
|
||||||
{
|
|
||||||
let mut capabilities: Vec<ApiCapability> = csc_dsl::capability_set_capabilities
|
|
||||||
.filter(csc_dsl::capability_set_id.eq(capability_set_id))
|
|
||||||
.select(csc_dsl::capability)
|
|
||||||
.load(conn)
|
|
||||||
.map_err(AppError::from)?;
|
|
||||||
|
|
||||||
capabilities.sort_by(|a, b| a.as_str().cmp(b.as_str()));
|
|
||||||
Ok(capabilities)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn ensure_capability_set<C>(
|
|
||||||
conn: &mut C,
|
|
||||||
tenant_id: Uuid,
|
|
||||||
capabilities: &[ApiCapability],
|
|
||||||
) -> Result<CapabilitySet, AppError>
|
|
||||||
where
|
|
||||||
C: Connection<Backend = Pg> + diesel::connection::LoadConnection,
|
|
||||||
{
|
|
||||||
if capabilities.is_empty() {
|
|
||||||
return Err(AppError::bad_request("at least one capability is required"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let slug = compute_slug(capabilities);
|
|
||||||
conn.transaction::<CapabilitySet, AppError, _>(|conn| {
|
|
||||||
if let Some(existing) = cs_dsl::capability_sets
|
|
||||||
.filter(cs_dsl::tenant_id.eq(tenant_id))
|
|
||||||
.filter(cs_dsl::slug.eq(&slug))
|
|
||||||
.first::<CapabilitySet>(conn)
|
|
||||||
.optional()
|
|
||||||
.map_err(AppError::from)?
|
|
||||||
{
|
|
||||||
ensure_capability_membership(conn, &existing, capabilities)?;
|
|
||||||
return Ok(existing);
|
|
||||||
}
|
|
||||||
|
|
||||||
let set = NewCapabilitySet {
|
|
||||||
id: Uuid::new_v4(),
|
|
||||||
tenant_id,
|
|
||||||
slug: slug.clone(),
|
|
||||||
cap_version: 1,
|
|
||||||
is_system: matches!(slug.as_str(), "owner" | "user" | "readonly" | "webdav"),
|
|
||||||
};
|
|
||||||
|
|
||||||
diesel::insert_into(capability_sets::table)
|
|
||||||
.values(&set)
|
|
||||||
.execute(conn)
|
|
||||||
.map_err(AppError::from)?;
|
|
||||||
|
|
||||||
persist_capabilities(conn, set.id, capabilities)?;
|
|
||||||
|
|
||||||
Ok(capability_sets::table
|
|
||||||
.find(set.id)
|
|
||||||
.first::<CapabilitySet>(conn)
|
|
||||||
.map_err(AppError::from)?)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn refresh_capability_set<C>(
|
|
||||||
conn: &mut C,
|
|
||||||
set: &CapabilitySet,
|
|
||||||
capabilities: &[ApiCapability],
|
|
||||||
) -> Result<CapabilitySet, AppError>
|
|
||||||
where
|
|
||||||
C: Connection<Backend = Pg> + diesel::connection::LoadConnection,
|
|
||||||
{
|
|
||||||
conn.transaction::<CapabilitySet, AppError, _>(|conn| {
|
|
||||||
diesel::delete(
|
|
||||||
csc_dsl::capability_set_capabilities.filter(csc_dsl::capability_set_id.eq(set.id)),
|
|
||||||
)
|
|
||||||
.execute(conn)
|
|
||||||
.map_err(AppError::from)?;
|
|
||||||
|
|
||||||
persist_capabilities(conn, set.id, capabilities)?;
|
|
||||||
|
|
||||||
diesel::update(capability_sets::table.find(set.id))
|
|
||||||
.set((
|
|
||||||
cs_dsl::cap_version.eq(set.cap_version + 1),
|
|
||||||
cs_dsl::updated_at.eq(Utc::now().naive_utc()),
|
|
||||||
))
|
|
||||||
.execute(conn)
|
|
||||||
.map_err(AppError::from)?;
|
|
||||||
|
|
||||||
capability_sets::table
|
|
||||||
.find(set.id)
|
|
||||||
.first::<CapabilitySet>(conn)
|
|
||||||
.map_err(AppError::from)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn load_capability_set<C>(conn: &mut C, id: Uuid) -> Result<CapabilitySet, AppError>
|
|
||||||
where
|
|
||||||
C: Connection<Backend = Pg> + diesel::connection::LoadConnection,
|
|
||||||
{
|
|
||||||
capability_sets::table
|
|
||||||
.find(id)
|
|
||||||
.first::<CapabilitySet>(conn)
|
|
||||||
.map_err(AppError::from)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn compute_slug(capabilities: &[ApiCapability]) -> String {
|
|
||||||
if capabilities == owner_capabilities() {
|
|
||||||
return "owner".to_string();
|
|
||||||
}
|
|
||||||
|
|
||||||
if capabilities == user_capabilities() {
|
|
||||||
return "user".to_string();
|
|
||||||
}
|
|
||||||
|
|
||||||
if capabilities == readonly_capabilities() {
|
|
||||||
return "readonly".to_string();
|
|
||||||
}
|
|
||||||
|
|
||||||
if capabilities == webdav_capabilities() {
|
|
||||||
return "webdav".to_string();
|
|
||||||
}
|
|
||||||
|
|
||||||
let joined = capabilities
|
|
||||||
.iter()
|
|
||||||
.map(|cap| cap.as_str())
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(",");
|
|
||||||
|
|
||||||
let digest = Sha256::digest(joined.as_bytes());
|
|
||||||
let hex = hex::encode(digest);
|
|
||||||
format!("caps-{}", &hex[..12])
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ensure_capability_membership<C>(
|
|
||||||
conn: &mut C,
|
|
||||||
set: &CapabilitySet,
|
|
||||||
desired: &[ApiCapability],
|
|
||||||
) -> Result<(), AppError>
|
|
||||||
where
|
|
||||||
C: Connection<Backend = Pg> + diesel::connection::LoadConnection,
|
|
||||||
{
|
|
||||||
let current = load_capabilities_for_set(conn, set.id)?;
|
|
||||||
if current == desired {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let _ = refresh_capability_set(conn, set, desired)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn persist_capabilities<C>(
|
|
||||||
conn: &mut C,
|
|
||||||
set_id: Uuid,
|
|
||||||
capabilities: &[ApiCapability],
|
|
||||||
) -> Result<(), AppError>
|
|
||||||
where
|
|
||||||
C: Connection<Backend = Pg> + diesel::connection::LoadConnection,
|
|
||||||
{
|
|
||||||
if capabilities.is_empty() {
|
|
||||||
return Err(AppError::bad_request("at least one capability is required"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let records: Vec<NewCapabilitySetCapability> = capabilities
|
|
||||||
.iter()
|
|
||||||
.map(|cap| NewCapabilitySetCapability {
|
|
||||||
capability_set_id: set_id,
|
|
||||||
capability: *cap,
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
diesel::insert_into(capability_set_capabilities::table)
|
|
||||||
.values(&records)
|
|
||||||
.execute(conn)
|
|
||||||
.map_err(AppError::from)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
+8
-114
@@ -2,29 +2,10 @@ use anyhow::Result;
|
|||||||
use chrono::{Duration, Utc};
|
use chrono::{Duration, Utc};
|
||||||
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
|
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use utoipa::ToSchema;
|
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::config::AppConfig;
|
use crate::config::AppConfig;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, ToSchema)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum PrincipalKind {
|
|
||||||
UserSession,
|
|
||||||
ApiToken,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct AccessTokenContext {
|
|
||||||
pub user_id: Uuid,
|
|
||||||
pub tenant_id: Uuid,
|
|
||||||
pub username: String,
|
|
||||||
pub principal_kind: PrincipalKind,
|
|
||||||
pub principal_id: Uuid,
|
|
||||||
pub capability_set_id: Uuid,
|
|
||||||
pub cap_version: i32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct JwtService {
|
pub struct JwtService {
|
||||||
encoding: EncodingKey,
|
encoding: EncodingKey,
|
||||||
@@ -36,40 +17,30 @@ pub struct JwtService {
|
|||||||
download_expiry: Duration,
|
download_expiry: Duration,
|
||||||
selector_audience: String,
|
selector_audience: String,
|
||||||
selector_expiry: Duration,
|
selector_expiry: Duration,
|
||||||
signup_audience: String,
|
|
||||||
signup_expiry: Duration,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl JwtService {
|
impl JwtService {
|
||||||
pub fn from_config(config: &AppConfig) -> Result<Self> {
|
pub fn from_config(config: &AppConfig) -> Result<Self> {
|
||||||
let access_expiry = Duration::minutes(config.jwt_expiry_minutes);
|
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
encoding: EncodingKey::from_secret(config.jwt_secret.as_bytes()),
|
encoding: EncodingKey::from_secret(config.jwt_secret.as_bytes()),
|
||||||
decoding: DecodingKey::from_secret(config.jwt_secret.as_bytes()),
|
decoding: DecodingKey::from_secret(config.jwt_secret.as_bytes()),
|
||||||
issuer: config.jwt_issuer.clone(),
|
issuer: config.jwt_issuer.clone(),
|
||||||
audience: config.jwt_audience.clone(),
|
audience: config.jwt_audience.clone(),
|
||||||
expiry: access_expiry,
|
expiry: Duration::minutes(config.jwt_expiry_minutes),
|
||||||
download_audience: config.download_token_audience.clone(),
|
download_audience: config.download_token_audience.clone(),
|
||||||
download_expiry: Duration::minutes(config.download_token_expiry_minutes),
|
download_expiry: Duration::minutes(config.download_token_expiry_minutes),
|
||||||
selector_audience: format!("{}:tenant-selector", config.jwt_audience),
|
selector_audience: format!("{}:tenant-selector", config.jwt_audience),
|
||||||
selector_expiry: access_expiry,
|
selector_expiry: Duration::minutes(15),
|
||||||
signup_audience: format!("{}:signup", config.jwt_audience),
|
|
||||||
signup_expiry: Duration::minutes(15),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn generate_token(&self, context: AccessTokenContext) -> Result<String> {
|
pub fn generate_token(&self, user_id: Uuid, tenant_id: Uuid, username: &str) -> Result<String> {
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
let exp = now + self.expiry;
|
let exp = now + self.expiry;
|
||||||
let claims = Claims {
|
let claims = Claims {
|
||||||
sub: context.user_id,
|
sub: user_id,
|
||||||
tenant_id: context.tenant_id,
|
tenant_id,
|
||||||
username: context.username,
|
username: username.to_owned(),
|
||||||
principal_kind: context.principal_kind,
|
|
||||||
principal_id: context.principal_id,
|
|
||||||
capability_set_id: context.capability_set_id,
|
|
||||||
cap_version: context.cap_version,
|
|
||||||
iss: self.issuer.clone(),
|
iss: self.issuer.clone(),
|
||||||
aud: self.audience.clone(),
|
aud: self.audience.clone(),
|
||||||
iat: now.timestamp() as usize,
|
iat: now.timestamp() as usize,
|
||||||
@@ -90,38 +61,13 @@ impl JwtService {
|
|||||||
pub fn generate_download_token(
|
pub fn generate_download_token(
|
||||||
&self,
|
&self,
|
||||||
document_id: Uuid,
|
document_id: Uuid,
|
||||||
version_id: Uuid,
|
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
tenant_id: Uuid,
|
tenant_id: Uuid,
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
let exp = now + self.download_expiry;
|
let exp = now + self.download_expiry;
|
||||||
let claims = DownloadClaims {
|
let claims = DownloadClaims {
|
||||||
subject: DownloadSubject::Document {
|
doc_id: document_id,
|
||||||
doc_id: document_id,
|
|
||||||
version_id,
|
|
||||||
},
|
|
||||||
user_id,
|
|
||||||
tenant_id,
|
|
||||||
iss: self.issuer.clone(),
|
|
||||||
aud: self.download_audience.clone(),
|
|
||||||
iat: now.timestamp() as usize,
|
|
||||||
exp: exp.timestamp() as usize,
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(encode(&Header::default(), &claims, &self.encoding)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn generate_asset_download_token(
|
|
||||||
&self,
|
|
||||||
asset_id: Uuid,
|
|
||||||
user_id: Uuid,
|
|
||||||
tenant_id: Uuid,
|
|
||||||
) -> Result<String> {
|
|
||||||
let now = Utc::now();
|
|
||||||
let exp = now + self.download_expiry;
|
|
||||||
let claims = DownloadClaims {
|
|
||||||
subject: DownloadSubject::Asset { asset_id },
|
|
||||||
user_id,
|
user_id,
|
||||||
tenant_id,
|
tenant_id,
|
||||||
iss: self.issuer.clone(),
|
iss: self.issuer.clone(),
|
||||||
@@ -162,35 +108,6 @@ impl JwtService {
|
|||||||
let data = decode::<TenantSelectionClaims>(token, &self.decoding, &validation)?;
|
let data = decode::<TenantSelectionClaims>(token, &self.decoding, &validation)?;
|
||||||
Ok(data.claims)
|
Ok(data.claims)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn generate_signup_token(
|
|
||||||
&self,
|
|
||||||
user_id: Uuid,
|
|
||||||
challenge_id: Uuid,
|
|
||||||
username: String,
|
|
||||||
) -> Result<String> {
|
|
||||||
let now = Utc::now();
|
|
||||||
let exp = now + self.signup_expiry;
|
|
||||||
let claims = SignupClaims {
|
|
||||||
sub: user_id,
|
|
||||||
challenge_id,
|
|
||||||
username,
|
|
||||||
iss: self.issuer.clone(),
|
|
||||||
aud: self.signup_audience.clone(),
|
|
||||||
iat: now.timestamp() as usize,
|
|
||||||
exp: exp.timestamp() as usize,
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(encode(&Header::default(), &claims, &self.encoding)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn verify_signup_token(&self, token: &str) -> Result<SignupClaims> {
|
|
||||||
let mut validation = Validation::default();
|
|
||||||
validation.set_audience(&[self.signup_audience.clone()]);
|
|
||||||
validation.set_issuer(&[self.issuer.clone()]);
|
|
||||||
let data = decode::<SignupClaims>(token, &self.decoding, &validation)?;
|
|
||||||
Ok(data.claims)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -198,27 +115,15 @@ pub struct Claims {
|
|||||||
pub sub: Uuid,
|
pub sub: Uuid,
|
||||||
pub tenant_id: Uuid,
|
pub tenant_id: Uuid,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub principal_kind: PrincipalKind,
|
|
||||||
pub principal_id: Uuid,
|
|
||||||
pub capability_set_id: Uuid,
|
|
||||||
pub cap_version: i32,
|
|
||||||
pub iss: String,
|
pub iss: String,
|
||||||
pub aud: String,
|
pub aud: String,
|
||||||
pub iat: usize,
|
pub iat: usize,
|
||||||
pub exp: usize,
|
pub exp: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
#[serde(tag = "scope", rename_all = "snake_case")]
|
|
||||||
pub enum DownloadSubject {
|
|
||||||
Document { doc_id: Uuid, version_id: Uuid },
|
|
||||||
Asset { asset_id: Uuid },
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct DownloadClaims {
|
pub struct DownloadClaims {
|
||||||
#[serde(flatten)]
|
pub doc_id: Uuid,
|
||||||
pub subject: DownloadSubject,
|
|
||||||
pub user_id: Uuid,
|
pub user_id: Uuid,
|
||||||
pub tenant_id: Uuid,
|
pub tenant_id: Uuid,
|
||||||
pub iss: String,
|
pub iss: String,
|
||||||
@@ -235,14 +140,3 @@ pub struct TenantSelectionClaims {
|
|||||||
pub iat: usize,
|
pub iat: usize,
|
||||||
pub exp: usize,
|
pub exp: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct SignupClaims {
|
|
||||||
pub sub: Uuid,
|
|
||||||
pub challenge_id: Uuid,
|
|
||||||
pub username: String,
|
|
||||||
pub iss: String,
|
|
||||||
pub aud: String,
|
|
||||||
pub iat: usize,
|
|
||||||
pub exp: usize,
|
|
||||||
}
|
|
||||||
|
|||||||
+42
-175
@@ -1,153 +1,55 @@
|
|||||||
pub mod api_tokens;
|
|
||||||
pub mod capability_guard;
|
|
||||||
pub mod capability_sets;
|
|
||||||
pub mod jwt;
|
pub mod jwt;
|
||||||
pub mod passkeys;
|
|
||||||
pub mod password;
|
pub mod password;
|
||||||
|
|
||||||
use std::sync::{Arc, Mutex};
|
use axum::{async_trait, extract::FromRequestParts, http::request::Parts};
|
||||||
|
|
||||||
use axum::{
|
|
||||||
extract::FromRequestParts,
|
|
||||||
http::{request::Parts, StatusCode},
|
|
||||||
};
|
|
||||||
use axum_extra::headers::{authorization::Bearer, Authorization};
|
use axum_extra::headers::{authorization::Bearer, Authorization};
|
||||||
use axum_extra::TypedHeader;
|
use axum_extra::TypedHeader;
|
||||||
use diesel::{pg::PgConnection, prelude::*};
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use utoipa::ToSchema;
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
auth::capability_sets::{load_capabilities_for_set, load_capability_set},
|
error::AppError,
|
||||||
error::{AppError, AppResult},
|
|
||||||
models::{ApiCapability, TenantStatus},
|
|
||||||
schema::tenants::dsl as tenant_dsl,
|
|
||||||
state::{AppState, PgPooledConnection},
|
state::{AppState, PgPooledConnection},
|
||||||
};
|
};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::auth::jwt::PrincipalKind;
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct TenantMembershipUser {
|
|
||||||
pub user_id: Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FromRequestParts<AppState> for TenantMembershipUser {
|
|
||||||
type Rejection = AppError;
|
|
||||||
|
|
||||||
#[allow(refining_impl_trait)]
|
|
||||||
fn from_request_parts<'a>(
|
|
||||||
parts: &'a mut Parts,
|
|
||||||
state: &AppState,
|
|
||||||
) -> impl std::future::Future<Output = Result<Self, Self::Rejection>> + Send + 'a {
|
|
||||||
let state = state.clone();
|
|
||||||
async move {
|
|
||||||
let TypedHeader(Authorization(bearer)) =
|
|
||||||
TypedHeader::<Authorization<Bearer>>::from_request_parts(parts, &state)
|
|
||||||
.await
|
|
||||||
.map_err(|_| AppError::unauthorized())?;
|
|
||||||
|
|
||||||
if let Ok(claims) = state.jwt.verify_token(bearer.token()) {
|
|
||||||
return Ok(Self {
|
|
||||||
user_id: claims.sub,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let selector = state
|
|
||||||
.jwt
|
|
||||||
.verify_tenant_selector_token(bearer.token())
|
|
||||||
.map_err(|_| AppError::unauthorized())?;
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
user_id: selector.sub,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct TenantConnectionHolder {
|
|
||||||
inner: Arc<Mutex<Option<PgPooledConnection>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TenantConnectionHolder {
|
|
||||||
pub fn new(conn: PgPooledConnection) -> Self {
|
|
||||||
Self {
|
|
||||||
inner: Arc::new(Mutex::new(Some(conn))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn into_conn(self) -> Option<PgPooledConnection> {
|
|
||||||
self.inner.lock().ok()?.take()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
|
||||||
pub struct AuthenticatedUser {
|
pub struct AuthenticatedUser {
|
||||||
pub user_id: uuid::Uuid,
|
pub user_id: uuid::Uuid,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub tenant_id: uuid::Uuid,
|
pub tenant_id: uuid::Uuid,
|
||||||
pub principal_kind: PrincipalKind,
|
|
||||||
pub principal_id: Uuid,
|
|
||||||
pub capability_set_id: Uuid,
|
|
||||||
pub cap_version: i32,
|
|
||||||
pub capabilities: Vec<ApiCapability>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
impl FromRequestParts<AppState> for AuthenticatedUser {
|
impl FromRequestParts<AppState> for AuthenticatedUser {
|
||||||
type Rejection = AppError;
|
type Rejection = AppError;
|
||||||
|
|
||||||
#[allow(refining_impl_trait)]
|
async fn from_request_parts(
|
||||||
fn from_request_parts<'a>(
|
parts: &mut Parts,
|
||||||
parts: &'a mut Parts,
|
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
) -> impl std::future::Future<Output = Result<Self, Self::Rejection>> + Send + 'a {
|
) -> Result<Self, Self::Rejection> {
|
||||||
let state = state.clone();
|
if let Some(user) = parts.extensions.get::<AuthenticatedUser>() {
|
||||||
async move {
|
return Ok(user.clone());
|
||||||
if let Some(user) = parts.extensions.get::<AuthenticatedUser>() {
|
|
||||||
return Ok(user.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
let TypedHeader(Authorization(bearer)) =
|
|
||||||
TypedHeader::<Authorization<Bearer>>::from_request_parts(parts, &state)
|
|
||||||
.await
|
|
||||||
.map_err(|_| AppError::unauthorized())?;
|
|
||||||
|
|
||||||
let claims = state
|
|
||||||
.jwt
|
|
||||||
.verify_token(bearer.token())
|
|
||||||
.map_err(|_| AppError::unauthorized())?;
|
|
||||||
|
|
||||||
let mut tenant_conn = state.db_for_tenant(claims.tenant_id)?;
|
|
||||||
let capability_set = load_capability_set(&mut tenant_conn, claims.capability_set_id)
|
|
||||||
.map_err(|_| AppError::unauthorized())?;
|
|
||||||
|
|
||||||
if capability_set.cap_version != claims.cap_version {
|
|
||||||
return Err(AppError::unauthorized());
|
|
||||||
}
|
|
||||||
|
|
||||||
let capabilities = load_capabilities_for_set(&mut tenant_conn, capability_set.id)
|
|
||||||
.map_err(|_| AppError::unauthorized())?;
|
|
||||||
|
|
||||||
let user = AuthenticatedUser {
|
|
||||||
user_id: claims.sub,
|
|
||||||
username: claims.username,
|
|
||||||
tenant_id: claims.tenant_id,
|
|
||||||
principal_kind: claims.principal_kind,
|
|
||||||
principal_id: claims.principal_id,
|
|
||||||
capability_set_id: claims.capability_set_id,
|
|
||||||
cap_version: claims.cap_version,
|
|
||||||
capabilities,
|
|
||||||
};
|
|
||||||
|
|
||||||
parts.extensions.insert(user.clone());
|
|
||||||
parts
|
|
||||||
.extensions
|
|
||||||
.insert(TenantConnectionHolder::new(tenant_conn));
|
|
||||||
|
|
||||||
Ok(user)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let TypedHeader(Authorization(bearer)) =
|
||||||
|
TypedHeader::<Authorization<Bearer>>::from_request_parts(parts, state)
|
||||||
|
.await
|
||||||
|
.map_err(|_| AppError::unauthorized())?;
|
||||||
|
|
||||||
|
let claims = state
|
||||||
|
.jwt
|
||||||
|
.verify_token(bearer.token())
|
||||||
|
.map_err(|_| AppError::unauthorized())?;
|
||||||
|
|
||||||
|
let user = AuthenticatedUser {
|
||||||
|
user_id: claims.sub,
|
||||||
|
username: claims.username,
|
||||||
|
tenant_id: claims.tenant_id,
|
||||||
|
};
|
||||||
|
|
||||||
|
parts.extensions.insert(user.clone());
|
||||||
|
|
||||||
|
Ok(user)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,58 +66,23 @@ impl TenantScopedConn {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
impl FromRequestParts<AppState> for TenantScopedConn {
|
impl FromRequestParts<AppState> for TenantScopedConn {
|
||||||
type Rejection = AppError;
|
type Rejection = AppError;
|
||||||
|
|
||||||
#[allow(refining_impl_trait)]
|
async fn from_request_parts(
|
||||||
fn from_request_parts<'a>(
|
parts: &mut Parts,
|
||||||
parts: &'a mut Parts,
|
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
) -> impl std::future::Future<Output = Result<Self, Self::Rejection>> + Send + 'a {
|
) -> Result<Self, Self::Rejection> {
|
||||||
let state = state.clone();
|
let user = AuthenticatedUser::from_request_parts(parts, state).await?;
|
||||||
async move {
|
let tenant_id = user.tenant_id;
|
||||||
let user = AuthenticatedUser::from_request_parts(parts, &state).await?;
|
let conn = state.db_for_tenant(tenant_id)?;
|
||||||
let tenant_id = user.tenant_id;
|
|
||||||
let mut conn = if let Some(holder) = parts.extensions.remove::<TenantConnectionHolder>()
|
|
||||||
{
|
|
||||||
holder
|
|
||||||
.into_conn()
|
|
||||||
.ok_or_else(|| AppError::internal("tenant connection unavailable"))?
|
|
||||||
} else {
|
|
||||||
state.db_for_tenant(tenant_id)?
|
|
||||||
};
|
|
||||||
|
|
||||||
ensure_active_tenant_with_conn(&mut conn, tenant_id)?;
|
Ok(Self {
|
||||||
|
conn,
|
||||||
Ok(Self {
|
tenant_id,
|
||||||
conn,
|
user_id: user.user_id,
|
||||||
tenant_id,
|
user,
|
||||||
user_id: user.user_id,
|
})
|
||||||
user,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn ensure_active_tenant(state: &AppState, tenant_id: Uuid) -> AppResult<()> {
|
|
||||||
let mut conn = state.db_unscoped()?;
|
|
||||||
ensure_active_tenant_with_conn(&mut conn, tenant_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn ensure_active_tenant_with_conn(
|
|
||||||
conn: &mut PgConnection,
|
|
||||||
tenant_id: Uuid,
|
|
||||||
) -> AppResult<()> {
|
|
||||||
use tenant_dsl::tenants;
|
|
||||||
|
|
||||||
let status: TenantStatus = tenants
|
|
||||||
.find(tenant_id)
|
|
||||||
.select(tenant_dsl::status)
|
|
||||||
.first(conn)?;
|
|
||||||
|
|
||||||
if status != TenantStatus::Active {
|
|
||||||
return Err(AppError::new(StatusCode::FORBIDDEN, "tenant is not active"));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,592 +0,0 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
|
||||||
use chrono::{Duration as ChronoDuration, NaiveDateTime, Utc};
|
|
||||||
use diesel::{dsl::count_star, prelude::*};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use utoipa::ToSchema;
|
|
||||||
use uuid::Uuid;
|
|
||||||
use webauthn_rs::prelude::{Credential, *};
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
config::AppConfig,
|
|
||||||
error::{AppError, AppResult},
|
|
||||||
models::{NewUserPasskey, NewWebauthnChallenge, User, UserPasskey, WebauthnChallenge},
|
|
||||||
schema::{user_passkeys::dsl as passkey_dsl, webauthn_challenges::dsl as challenge_dsl},
|
|
||||||
};
|
|
||||||
|
|
||||||
const PURPOSE_REGISTRATION: &str = "registration";
|
|
||||||
const PURPOSE_AUTHENTICATION: &str = "authentication";
|
|
||||||
const DEFAULT_CHALLENGE_TTL_MINUTES: i64 = 10;
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct PasskeyService {
|
|
||||||
webauthn: Arc<Webauthn>,
|
|
||||||
challenge_ttl: ChronoDuration,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct PreparedPasskey {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub credential_id: Vec<u8>,
|
|
||||||
pub public_key: Vec<u8>,
|
|
||||||
pub credential: serde_json::Value,
|
|
||||||
pub sign_count: i64,
|
|
||||||
pub transports: Vec<Option<String>>,
|
|
||||||
pub aaguid: Option<Uuid>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PreparedPasskey {
|
|
||||||
pub fn into_new_user_passkey(self, user_id: Uuid, nickname: Option<String>) -> NewUserPasskey {
|
|
||||||
NewUserPasskey {
|
|
||||||
id: self.id,
|
|
||||||
user_id,
|
|
||||||
credential_id: self.credential_id,
|
|
||||||
public_key: self.public_key,
|
|
||||||
credential: self.credential,
|
|
||||||
sign_count: self.sign_count,
|
|
||||||
transports: self.transports,
|
|
||||||
aaguid: self.aaguid,
|
|
||||||
nickname,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct RegistrationChallengeResponse {
|
|
||||||
pub challenge_id: Uuid,
|
|
||||||
#[serde(flatten)]
|
|
||||||
#[schema(value_type = Object)]
|
|
||||||
pub challenge: CreationChallengeResponse,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct AuthenticationChallengeResponse {
|
|
||||||
pub challenge_id: Uuid,
|
|
||||||
#[serde(flatten)]
|
|
||||||
#[schema(value_type = Object)]
|
|
||||||
pub challenge: RequestChallengeResponse,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct PasskeySummary {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub nickname: Option<String>,
|
|
||||||
pub created_at: NaiveDateTime,
|
|
||||||
pub last_used_at: Option<NaiveDateTime>,
|
|
||||||
pub transports: Vec<String>,
|
|
||||||
pub revoked_at: Option<NaiveDateTime>,
|
|
||||||
pub revoked_reason: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PasskeyService {
|
|
||||||
pub fn try_new(config: &AppConfig) -> Result<Option<Self>> {
|
|
||||||
let rp_id = match config.webauthn_rp_id.as_deref().map(str::trim) {
|
|
||||||
Some(rp_id) if !rp_id.is_empty() => rp_id,
|
|
||||||
_ => return Ok(None),
|
|
||||||
};
|
|
||||||
let rp_origin = match config.webauthn_origin.as_ref().map(|s| s.trim()) {
|
|
||||||
Some(origin) if !origin.is_empty() => origin,
|
|
||||||
_ => return Ok(None),
|
|
||||||
};
|
|
||||||
|
|
||||||
let origin = Url::parse(rp_origin).context("invalid webauthn_origin")?;
|
|
||||||
|
|
||||||
let builder = WebauthnBuilder::new(rp_id, &origin)
|
|
||||||
.context("failed to initialise WebAuthn builder")?
|
|
||||||
.rp_name(&config.webauthn_rp_name)
|
|
||||||
.allow_subdomains(false)
|
|
||||||
.allow_any_port(false);
|
|
||||||
|
|
||||||
let webauthn = builder
|
|
||||||
.build()
|
|
||||||
.context("failed to build WebAuthn instance")?;
|
|
||||||
|
|
||||||
Ok(Some(Self {
|
|
||||||
webauthn: Arc::new(webauthn),
|
|
||||||
challenge_ttl: ChronoDuration::minutes(DEFAULT_CHALLENGE_TTL_MINUTES),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn prune_expired(&self, conn: &mut PgConnection) {
|
|
||||||
let now = Utc::now().naive_utc();
|
|
||||||
let _ = diesel::delete(
|
|
||||||
challenge_dsl::webauthn_challenges.filter(challenge_dsl::expires_at.le(now)),
|
|
||||||
)
|
|
||||||
.execute(conn);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn begin_registration(
|
|
||||||
&self,
|
|
||||||
conn: &mut PgConnection,
|
|
||||||
user_id: Uuid,
|
|
||||||
username: &str,
|
|
||||||
challenge_user_id: Option<Uuid>,
|
|
||||||
exclude: Option<Vec<CredentialID>>,
|
|
||||||
) -> AppResult<RegistrationChallengeResponse> {
|
|
||||||
self.prune_expired(conn);
|
|
||||||
|
|
||||||
let (challenge, state) = self
|
|
||||||
.webauthn
|
|
||||||
.start_passkey_registration(user_id, username, username, exclude)
|
|
||||||
.map_err(|err| {
|
|
||||||
tracing::error!(error = %err, "failed to start passkey registration");
|
|
||||||
AppError::internal("failed to start passkey registration")
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let challenge_id = Uuid::new_v4();
|
|
||||||
let expires_at = (Utc::now() + self.challenge_ttl).naive_utc();
|
|
||||||
let challenge_bytes: Vec<u8> = challenge.public_key.challenge.clone().into();
|
|
||||||
let state_bytes = serde_json::to_vec(&state)
|
|
||||||
.context("failed to encode passkey registration state")
|
|
||||||
.map_err(AppError::internal)?;
|
|
||||||
|
|
||||||
let record = NewWebauthnChallenge {
|
|
||||||
id: challenge_id,
|
|
||||||
user_id: challenge_user_id,
|
|
||||||
purpose: PURPOSE_REGISTRATION.to_string(),
|
|
||||||
challenge: challenge_bytes,
|
|
||||||
state: state_bytes,
|
|
||||||
expires_at,
|
|
||||||
};
|
|
||||||
|
|
||||||
diesel::insert_into(challenge_dsl::webauthn_challenges)
|
|
||||||
.values(&record)
|
|
||||||
.execute(conn)?;
|
|
||||||
|
|
||||||
Ok(RegistrationChallengeResponse {
|
|
||||||
challenge_id,
|
|
||||||
challenge,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn start_registration(
|
|
||||||
&self,
|
|
||||||
conn: &mut PgConnection,
|
|
||||||
user: &User,
|
|
||||||
) -> AppResult<RegistrationChallengeResponse> {
|
|
||||||
let existing: Vec<UserPasskey> = passkey_dsl::user_passkeys
|
|
||||||
.filter(passkey_dsl::user_id.eq(user.id))
|
|
||||||
.filter(passkey_dsl::revoked_at.is_null())
|
|
||||||
.load(conn)?;
|
|
||||||
|
|
||||||
let exclude = if existing.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(
|
|
||||||
existing
|
|
||||||
.iter()
|
|
||||||
.map(|pk| CredentialID::from(pk.credential_id.clone()))
|
|
||||||
.collect(),
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
self.begin_registration(conn, user.id, &user.username, Some(user.id), exclude)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn start_signup_registration(
|
|
||||||
&self,
|
|
||||||
conn: &mut PgConnection,
|
|
||||||
user_id: Uuid,
|
|
||||||
username: &str,
|
|
||||||
) -> AppResult<RegistrationChallengeResponse> {
|
|
||||||
self.begin_registration(conn, user_id, username, None, None)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn complete_registration(
|
|
||||||
&self,
|
|
||||||
conn: &mut PgConnection,
|
|
||||||
challenge_id: Uuid,
|
|
||||||
credential: &RegisterPublicKeyCredential,
|
|
||||||
expected_user: Option<Uuid>,
|
|
||||||
) -> AppResult<PreparedPasskey> {
|
|
||||||
let record: WebauthnChallenge = challenge_dsl::webauthn_challenges
|
|
||||||
.find(challenge_id)
|
|
||||||
.first(conn)
|
|
||||||
.map_err(|err| {
|
|
||||||
if matches!(err, diesel::result::Error::NotFound) {
|
|
||||||
AppError::bad_request("challenge not found")
|
|
||||||
} else {
|
|
||||||
AppError::from(err)
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if record.purpose != PURPOSE_REGISTRATION {
|
|
||||||
return Err(AppError::bad_request("challenge is not for registration"));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(expected) = expected_user {
|
|
||||||
if record.user_id != Some(expected) {
|
|
||||||
return Err(AppError::unauthorized());
|
|
||||||
}
|
|
||||||
} else if record.user_id.is_some() {
|
|
||||||
return Err(AppError::bad_request(
|
|
||||||
"unexpected user context for signup registration",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
if record.expires_at < Utc::now().naive_utc() {
|
|
||||||
diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?;
|
|
||||||
return Err(AppError::bad_request("challenge expired"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let state: PasskeyRegistration = serde_json::from_slice(&record.state)
|
|
||||||
.context("failed to decode registration state")
|
|
||||||
.map_err(AppError::internal)?;
|
|
||||||
|
|
||||||
let passkey = self
|
|
||||||
.webauthn
|
|
||||||
.finish_passkey_registration(credential, &state)
|
|
||||||
.map_err(|err| {
|
|
||||||
tracing::warn!(error = %err, "passkey registration validation failed");
|
|
||||||
AppError::bad_request("invalid passkey attestation")
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let credential_struct: Credential = passkey.clone().into();
|
|
||||||
let credential_id_vec: Vec<u8> = credential_struct.cred_id.clone().into();
|
|
||||||
|
|
||||||
let duplicate = passkey_dsl::user_passkeys
|
|
||||||
.filter(passkey_dsl::credential_id.eq(&credential_id_vec))
|
|
||||||
.first::<UserPasskey>(conn)
|
|
||||||
.optional()?;
|
|
||||||
if duplicate.is_some() {
|
|
||||||
diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?;
|
|
||||||
return Err(AppError::conflict("credential already registered"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let public_key_bytes = serde_cbor_2::to_vec(&credential_struct.cred)
|
|
||||||
.context("failed to encode credential public key")
|
|
||||||
.map_err(AppError::internal)?;
|
|
||||||
|
|
||||||
let transports: Vec<Option<String>> = credential_struct
|
|
||||||
.transports
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_default()
|
|
||||||
.into_iter()
|
|
||||||
.map(|transport| Some(transport.as_ref().to_string()))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let aaguid = match credential_struct.attestation.metadata {
|
|
||||||
AttestationMetadata::Packed { aaguid } | AttestationMetadata::Tpm { aaguid, .. } => {
|
|
||||||
Some(aaguid)
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let credential_json = serde_json::to_value(&passkey)
|
|
||||||
.context("failed to serialise passkey")
|
|
||||||
.map_err(AppError::internal)?;
|
|
||||||
|
|
||||||
diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?;
|
|
||||||
|
|
||||||
Ok(PreparedPasskey {
|
|
||||||
id: Uuid::new_v4(),
|
|
||||||
credential_id: credential_id_vec,
|
|
||||||
public_key: public_key_bytes,
|
|
||||||
credential: credential_json,
|
|
||||||
sign_count: credential_struct.counter as i64,
|
|
||||||
transports,
|
|
||||||
aaguid,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn finish_registration(
|
|
||||||
&self,
|
|
||||||
conn: &mut PgConnection,
|
|
||||||
user: &User,
|
|
||||||
challenge_id: Uuid,
|
|
||||||
credential: RegisterPublicKeyCredential,
|
|
||||||
nickname: Option<String>,
|
|
||||||
) -> AppResult<UserPasskey> {
|
|
||||||
let prepared =
|
|
||||||
self.complete_registration(conn, challenge_id, &credential, Some(user.id))?;
|
|
||||||
|
|
||||||
let new_passkey = prepared.into_new_user_passkey(user.id, nickname);
|
|
||||||
|
|
||||||
diesel::insert_into(passkey_dsl::user_passkeys)
|
|
||||||
.values(&new_passkey)
|
|
||||||
.execute(conn)?;
|
|
||||||
|
|
||||||
let created: UserPasskey = passkey_dsl::user_passkeys
|
|
||||||
.find(new_passkey.id)
|
|
||||||
.select(UserPasskey::as_select())
|
|
||||||
.first(conn)?;
|
|
||||||
|
|
||||||
Ok(created)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn start_authentication(
|
|
||||||
&self,
|
|
||||||
conn: &mut PgConnection,
|
|
||||||
user: &User,
|
|
||||||
) -> AppResult<AuthenticationChallengeResponse> {
|
|
||||||
self.prune_expired(conn);
|
|
||||||
|
|
||||||
let stored: Vec<UserPasskey> = passkey_dsl::user_passkeys
|
|
||||||
.filter(passkey_dsl::user_id.eq(user.id))
|
|
||||||
.filter(passkey_dsl::revoked_at.is_null())
|
|
||||||
.select(UserPasskey::as_select())
|
|
||||||
.load(conn)?;
|
|
||||||
|
|
||||||
if stored.is_empty() {
|
|
||||||
return Err(AppError::bad_request("no passkeys registered"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut passkeys = Vec::with_capacity(stored.len());
|
|
||||||
for pk in &stored {
|
|
||||||
let passkey: Passkey = serde_json::from_value(pk.credential.clone())
|
|
||||||
.context("failed to parse stored passkey")
|
|
||||||
.map_err(AppError::internal)?;
|
|
||||||
passkeys.push(passkey);
|
|
||||||
}
|
|
||||||
|
|
||||||
let (challenge, state) = self
|
|
||||||
.webauthn
|
|
||||||
.start_passkey_authentication(&passkeys)
|
|
||||||
.map_err(|err| {
|
|
||||||
tracing::error!(error = %err, "failed to start passkey authentication");
|
|
||||||
AppError::internal("failed to start passkey authentication")
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let challenge_id = Uuid::new_v4();
|
|
||||||
let expires_at = (Utc::now() + self.challenge_ttl).naive_utc();
|
|
||||||
let challenge_bytes: Vec<u8> = challenge.public_key.challenge.clone().into();
|
|
||||||
let state_bytes = serde_json::to_vec(&state)
|
|
||||||
.context("failed to encode authentication state")
|
|
||||||
.map_err(AppError::internal)?;
|
|
||||||
|
|
||||||
let record = NewWebauthnChallenge {
|
|
||||||
id: challenge_id,
|
|
||||||
user_id: Some(user.id),
|
|
||||||
purpose: PURPOSE_AUTHENTICATION.to_string(),
|
|
||||||
challenge: challenge_bytes,
|
|
||||||
state: state_bytes,
|
|
||||||
expires_at,
|
|
||||||
};
|
|
||||||
|
|
||||||
diesel::insert_into(challenge_dsl::webauthn_challenges)
|
|
||||||
.values(&record)
|
|
||||||
.execute(conn)?;
|
|
||||||
|
|
||||||
Ok(AuthenticationChallengeResponse {
|
|
||||||
challenge_id,
|
|
||||||
challenge,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn list_for_user(
|
|
||||||
&self,
|
|
||||||
conn: &mut PgConnection,
|
|
||||||
user_id: Uuid,
|
|
||||||
) -> AppResult<Vec<PasskeySummary>> {
|
|
||||||
let passkeys: Vec<UserPasskey> = passkey_dsl::user_passkeys
|
|
||||||
.filter(passkey_dsl::user_id.eq(user_id))
|
|
||||||
.order(passkey_dsl::created_at.asc())
|
|
||||||
.select(UserPasskey::as_select())
|
|
||||||
.load(conn)?;
|
|
||||||
|
|
||||||
Ok(passkeys.into_iter().map(PasskeySummary::from).collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn active_passkey_count(&self, conn: &mut PgConnection, user_id: Uuid) -> AppResult<i64> {
|
|
||||||
let count: i64 = passkey_dsl::user_passkeys
|
|
||||||
.filter(passkey_dsl::user_id.eq(user_id))
|
|
||||||
.filter(passkey_dsl::revoked_at.is_null())
|
|
||||||
.select(count_star())
|
|
||||||
.first(conn)?;
|
|
||||||
Ok(count)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn consume_signup_challenge(
|
|
||||||
&self,
|
|
||||||
conn: &mut PgConnection,
|
|
||||||
challenge_id: Uuid,
|
|
||||||
credential: &RegisterPublicKeyCredential,
|
|
||||||
) -> AppResult<PreparedPasskey> {
|
|
||||||
self.complete_registration(conn, challenge_id, credential, None)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn revoke_passkey(
|
|
||||||
&self,
|
|
||||||
conn: &mut PgConnection,
|
|
||||||
user_id: Uuid,
|
|
||||||
passkey_id: Uuid,
|
|
||||||
reason: Option<String>,
|
|
||||||
) -> AppResult<()> {
|
|
||||||
let now = Utc::now().naive_utc();
|
|
||||||
let updated = diesel::update(
|
|
||||||
passkey_dsl::user_passkeys
|
|
||||||
.filter(passkey_dsl::id.eq(passkey_id))
|
|
||||||
.filter(passkey_dsl::user_id.eq(user_id))
|
|
||||||
.filter(passkey_dsl::revoked_at.is_null()),
|
|
||||||
)
|
|
||||||
.set((
|
|
||||||
passkey_dsl::revoked_at.eq(Some(now)),
|
|
||||||
passkey_dsl::revoked_reason.eq(reason),
|
|
||||||
passkey_dsl::updated_at.eq(now),
|
|
||||||
))
|
|
||||||
.execute(conn)?;
|
|
||||||
|
|
||||||
if updated == 0 {
|
|
||||||
return Err(AppError::not_found());
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn finish_authentication(
|
|
||||||
&self,
|
|
||||||
conn: &mut PgConnection,
|
|
||||||
challenge_id: Uuid,
|
|
||||||
credential: PublicKeyCredential,
|
|
||||||
) -> AppResult<(User, UserPasskey, AuthenticationResult)> {
|
|
||||||
let record: WebauthnChallenge = challenge_dsl::webauthn_challenges
|
|
||||||
.find(challenge_id)
|
|
||||||
.first(conn)
|
|
||||||
.map_err(|err| {
|
|
||||||
if matches!(err, diesel::result::Error::NotFound) {
|
|
||||||
AppError::bad_request("challenge not found")
|
|
||||||
} else {
|
|
||||||
AppError::from(err)
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if record.purpose != PURPOSE_AUTHENTICATION {
|
|
||||||
return Err(AppError::bad_request("challenge is not for authentication"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let user_id = record
|
|
||||||
.user_id
|
|
||||||
.ok_or_else(|| AppError::bad_request("challenge missing user context"))?;
|
|
||||||
|
|
||||||
if record.expires_at < Utc::now().naive_utc() {
|
|
||||||
diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?;
|
|
||||||
return Err(AppError::bad_request("challenge expired"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let state: PasskeyAuthentication = serde_json::from_slice(&record.state)
|
|
||||||
.context("failed to decode authentication state")
|
|
||||||
.map_err(AppError::internal)?;
|
|
||||||
|
|
||||||
let auth_result = self
|
|
||||||
.webauthn
|
|
||||||
.finish_passkey_authentication(&credential, &state)
|
|
||||||
.map_err(|err| {
|
|
||||||
tracing::warn!(error = %err, "passkey authentication failed");
|
|
||||||
AppError::unauthorized()
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let credential_id_vec: Vec<u8> = auth_result.cred_id().clone().into();
|
|
||||||
|
|
||||||
let mut passkey: UserPasskey = passkey_dsl::user_passkeys
|
|
||||||
.filter(passkey_dsl::user_id.eq(user_id))
|
|
||||||
.filter(passkey_dsl::credential_id.eq(&credential_id_vec))
|
|
||||||
.filter(passkey_dsl::revoked_at.is_null())
|
|
||||||
.select(UserPasskey::as_select())
|
|
||||||
.first(conn)
|
|
||||||
.map_err(|err| {
|
|
||||||
if matches!(err, diesel::result::Error::NotFound) {
|
|
||||||
AppError::unauthorized()
|
|
||||||
} else {
|
|
||||||
AppError::from(err)
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let mut passkey_model: Passkey = serde_json::from_value(passkey.credential.clone())
|
|
||||||
.context("failed to parse stored passkey")
|
|
||||||
.map_err(AppError::internal)?;
|
|
||||||
|
|
||||||
if auth_result.needs_update() {
|
|
||||||
let _ = passkey_model.update_credential(&auth_result);
|
|
||||||
}
|
|
||||||
|
|
||||||
let credential_struct: Credential = passkey_model.clone().into();
|
|
||||||
let public_key_bytes = serde_cbor_2::to_vec(&credential_struct.cred)
|
|
||||||
.context("failed to encode credential public key")
|
|
||||||
.map_err(AppError::internal)?;
|
|
||||||
|
|
||||||
let transports: Vec<Option<String>> = credential_struct
|
|
||||||
.transports
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_default()
|
|
||||||
.into_iter()
|
|
||||||
.map(|transport| Some(transport.as_ref().to_string()))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let credential_json = serde_json::to_value(&passkey_model)
|
|
||||||
.context("failed to serialise passkey")
|
|
||||||
.map_err(AppError::internal)?;
|
|
||||||
|
|
||||||
let now = Utc::now().naive_utc();
|
|
||||||
diesel::update(passkey_dsl::user_passkeys.find(passkey.id))
|
|
||||||
.set((
|
|
||||||
passkey_dsl::sign_count.eq(auth_result.counter() as i64),
|
|
||||||
passkey_dsl::transports.eq(&transports),
|
|
||||||
passkey_dsl::credential.eq(credential_json.clone()),
|
|
||||||
passkey_dsl::public_key.eq(public_key_bytes),
|
|
||||||
passkey_dsl::last_used_at.eq(Some(now)),
|
|
||||||
passkey_dsl::updated_at.eq(now),
|
|
||||||
))
|
|
||||||
.execute(conn)?;
|
|
||||||
|
|
||||||
passkey.sign_count = auth_result.counter() as i64;
|
|
||||||
passkey.transports = transports;
|
|
||||||
passkey.credential = credential_json;
|
|
||||||
passkey.last_used_at = Some(now);
|
|
||||||
passkey.updated_at = now;
|
|
||||||
|
|
||||||
diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?;
|
|
||||||
|
|
||||||
let user = crate::schema::users::table
|
|
||||||
.find(user_id)
|
|
||||||
.first::<User>(conn)?;
|
|
||||||
|
|
||||||
Ok((user, passkey, auth_result))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<UserPasskey> for PasskeySummary {
|
|
||||||
fn from(passkey: UserPasskey) -> Self {
|
|
||||||
let transports = passkey
|
|
||||||
.transports
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|value| value)
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Self {
|
|
||||||
id: passkey.id,
|
|
||||||
nickname: passkey.nickname,
|
|
||||||
created_at: passkey.created_at,
|
|
||||||
last_used_at: passkey.last_used_at,
|
|
||||||
transports,
|
|
||||||
revoked_at: passkey.revoked_at,
|
|
||||||
revoked_reason: passkey.revoked_reason,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct PasskeyRegistrationFinishPayload {
|
|
||||||
pub challenge_id: Uuid,
|
|
||||||
#[schema(value_type = Object)]
|
|
||||||
pub credential: RegisterPublicKeyCredential,
|
|
||||||
#[serde(default)]
|
|
||||||
pub nickname: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct PasskeyLoginStartPayload {
|
|
||||||
pub username: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct PasskeyLoginFinishPayload {
|
|
||||||
pub challenge_id: Uuid,
|
|
||||||
#[schema(value_type = Object)]
|
|
||||||
pub credential: PublicKeyCredential,
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
use argon2::{
|
use argon2::{
|
||||||
password_hash::{
|
password_hash::{PasswordHash, PasswordVerifier},
|
||||||
rand_core::OsRng as PasswordHashOsRng, PasswordHash, PasswordHasher, PasswordVerifier,
|
|
||||||
SaltString,
|
|
||||||
},
|
|
||||||
Argon2,
|
Argon2,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -13,12 +10,3 @@ pub fn verify_password(password: &str, password_hash: &str) -> Result<bool> {
|
|||||||
.verify_password(password.as_bytes(), &parsed_hash)
|
.verify_password(password.as_bytes(), &parsed_hash)
|
||||||
.is_ok())
|
.is_ok())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn hash_password(password: &str) -> Result<String> {
|
|
||||||
let mut rng = PasswordHashOsRng;
|
|
||||||
let salt = SaltString::generate(&mut rng);
|
|
||||||
let hash = Argon2::default()
|
|
||||||
.hash_password(password.as_bytes(), &salt)
|
|
||||||
.map_err(|err| anyhow!(err))?;
|
|
||||||
Ok(hash.to_string())
|
|
||||||
}
|
|
||||||
|
|||||||
+382
-563
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
|||||||
use papercrate::openapi::ApiDoc;
|
use backend::openapi::ApiDoc;
|
||||||
use utoipa::OpenApi;
|
use utoipa::OpenApi;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use std::net::SocketAddr;
|
|||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use tower::make::Shared;
|
use tower::make::Shared;
|
||||||
|
|
||||||
use papercrate::{routes::webdav, utils::bootstrap::init_component};
|
use backend::{routes::webdav, utils::bootstrap::init_component};
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> anyhow::Result<()> {
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use tokio::signal;
|
use tokio::signal;
|
||||||
|
|
||||||
use papercrate::{default_handlers, utils::bootstrap::init_component, Worker};
|
use backend::{default_handlers, utils::bootstrap::init_component, Worker};
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> anyhow::Result<()> {
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
|||||||
+5
-115
@@ -1,7 +1,6 @@
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
use serde::de::Deserializer;
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_aux::field_attributes::deserialize_bool_from_anything;
|
use serde_aux::field_attributes::deserialize_bool_from_anything;
|
||||||
|
|
||||||
@@ -10,8 +9,6 @@ use crate::db::DEFAULT_MAX_POOL_SIZE;
|
|||||||
#[derive(Clone, Debug, Deserialize)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
pub struct AppConfig {
|
pub struct AppConfig {
|
||||||
pub database_url: String,
|
pub database_url: String,
|
||||||
#[serde(default)]
|
|
||||||
pub migrations_database_url: Option<String>,
|
|
||||||
#[serde(default = "default_database_max_pool_size")]
|
#[serde(default = "default_database_max_pool_size")]
|
||||||
pub database_max_pool_size: u32,
|
pub database_max_pool_size: u32,
|
||||||
#[serde(default = "default_server_host")]
|
#[serde(default = "default_server_host")]
|
||||||
@@ -44,8 +41,6 @@ pub struct AppConfig {
|
|||||||
pub refresh_cookie_domain: Option<String>,
|
pub refresh_cookie_domain: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub cors_allowed_origin: Option<String>,
|
pub cors_allowed_origin: Option<String>,
|
||||||
#[serde(default, deserialize_with = "deserialize_bool_from_anything")]
|
|
||||||
pub proxy_downloads: bool,
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub aws_endpoint_url: Option<String>,
|
pub aws_endpoint_url: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -59,26 +54,8 @@ pub struct AppConfig {
|
|||||||
pub quickwit_endpoint: Option<String>,
|
pub quickwit_endpoint: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub quickwit_index: Option<String>,
|
pub quickwit_index: Option<String>,
|
||||||
#[serde(default = "default_worker_max_document_bytes")]
|
#[serde(default = "default_tenant_slug")]
|
||||||
pub worker_max_document_bytes: u64,
|
pub default_tenant_slug: String,
|
||||||
#[serde(default = "default_upload_body_limit_bytes")]
|
|
||||||
pub upload_body_limit_bytes: u64,
|
|
||||||
#[serde(default = "default_service_timezone")]
|
|
||||||
pub service_timezone: String,
|
|
||||||
#[serde(default = "default_issued_at_date_order")]
|
|
||||||
pub issued_at_date_order: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub issued_at_filename_date_order: Option<String>,
|
|
||||||
#[serde(default, deserialize_with = "deserialize_string_list")]
|
|
||||||
pub issued_at_date_parser_locales: Vec<String>,
|
|
||||||
#[serde(default, deserialize_with = "deserialize_string_list")]
|
|
||||||
pub issued_at_ignore_dates: Vec<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub webauthn_rp_id: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub webauthn_origin: Option<String>,
|
|
||||||
#[serde(default = "default_webauthn_rp_name")]
|
|
||||||
pub webauthn_rp_name: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppConfig {
|
impl AppConfig {
|
||||||
@@ -88,14 +65,9 @@ impl AppConfig {
|
|||||||
tracing::info!(
|
tracing::info!(
|
||||||
component,
|
component,
|
||||||
database_url = %config.redacted_database_url(),
|
database_url = %config.redacted_database_url(),
|
||||||
migrations_database_url = %config.redacted_migrations_database_url(),
|
|
||||||
pool_size = config.database_max_pool_size,
|
pool_size = config.database_max_pool_size,
|
||||||
quickwit_enabled = config.quickwit_endpoint.is_some(),
|
quickwit_enabled = config.quickwit_endpoint.is_some(),
|
||||||
passkeys_enabled = config.webauthn_origin.is_some(),
|
|
||||||
s3_bucket = %config.s3_bucket,
|
s3_bucket = %config.s3_bucket,
|
||||||
worker_max_document_bytes = config.worker_max_document_bytes,
|
|
||||||
upload_body_limit_bytes = config.upload_body_limit_bytes,
|
|
||||||
proxy_downloads = config.proxy_downloads,
|
|
||||||
"loaded backend configuration"
|
"loaded backend configuration"
|
||||||
);
|
);
|
||||||
Ok(config)
|
Ok(config)
|
||||||
@@ -110,18 +82,6 @@ impl AppConfig {
|
|||||||
pub fn redacted_database_url(&self) -> String {
|
pub fn redacted_database_url(&self) -> String {
|
||||||
redact_database_url(&self.database_url)
|
redact_database_url(&self.database_url)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn redacted_migrations_database_url(&self) -> String {
|
|
||||||
redact_database_url(self.migrations_database_url())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn migrations_database_url(&self) -> &str {
|
|
||||||
if let Some(ref url) = self.migrations_database_url {
|
|
||||||
url
|
|
||||||
} else {
|
|
||||||
&self.database_url
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppConfig {
|
impl AppConfig {
|
||||||
@@ -129,22 +89,6 @@ impl AppConfig {
|
|||||||
if self.webdav_host.is_empty() {
|
if self.webdav_host.is_empty() {
|
||||||
self.webdav_host = self.server_host.clone();
|
self.webdav_host = self.server_host.clone();
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.webauthn_rp_id.is_none() {
|
|
||||||
self.webauthn_rp_id = Some(self.server_host.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.webauthn_origin.is_none() {
|
|
||||||
let scheme = if self.server_host == "127.0.0.1" || self.server_host == "localhost" {
|
|
||||||
"http"
|
|
||||||
} else {
|
|
||||||
"https"
|
|
||||||
};
|
|
||||||
self.webauthn_origin = Some(format!(
|
|
||||||
"{scheme}://{}:{}",
|
|
||||||
self.server_host, self.server_port
|
|
||||||
));
|
|
||||||
}
|
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -201,65 +145,11 @@ fn default_aws_region() -> String {
|
|||||||
"us-east-1".to_string()
|
"us-east-1".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_worker_max_document_bytes() -> u64 {
|
fn default_tenant_slug() -> String {
|
||||||
200 * 1024 * 1024
|
"admin".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_upload_body_limit_bytes() -> u64 {
|
fn redact_database_url(raw: &str) -> String {
|
||||||
128 * 1024 * 1024
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_service_timezone() -> String {
|
|
||||||
"UTC".to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_issued_at_date_order() -> String {
|
|
||||||
"DMY".to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn deserialize_string_list<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
|
|
||||||
where
|
|
||||||
D: Deserializer<'de>,
|
|
||||||
{
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
#[serde(untagged)]
|
|
||||||
enum Helper {
|
|
||||||
List(Vec<String>),
|
|
||||||
Single(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
let helper = Option::<Helper>::deserialize(deserializer)?;
|
|
||||||
let mut values = Vec::new();
|
|
||||||
|
|
||||||
if let Some(helper) = helper {
|
|
||||||
match helper {
|
|
||||||
Helper::List(list) => {
|
|
||||||
for entry in list {
|
|
||||||
let trimmed = entry.trim();
|
|
||||||
if !trimmed.is_empty() {
|
|
||||||
values.push(trimmed.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Helper::Single(value) => {
|
|
||||||
for part in value.split(',') {
|
|
||||||
let trimmed = part.trim();
|
|
||||||
if !trimmed.is_empty() {
|
|
||||||
values.push(trimmed.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(values)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_webauthn_rp_name() -> String {
|
|
||||||
"Papercrate".to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn redact_database_url(raw: &str) -> String {
|
|
||||||
match Url::parse(raw) {
|
match Url::parse(raw) {
|
||||||
Ok(mut parsed) => {
|
Ok(mut parsed) => {
|
||||||
if parsed.password().is_some() {
|
if parsed.password().is_some() {
|
||||||
|
|||||||
+1
-30
@@ -1,40 +1,12 @@
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use diesel::pg::PgConnection;
|
use diesel::pg::PgConnection;
|
||||||
use diesel::r2d2::{ConnectionManager, CustomizeConnection, Pool};
|
use diesel::r2d2::{ConnectionManager, Pool};
|
||||||
use diesel::RunQueryDsl;
|
|
||||||
|
|
||||||
pub type PgPool = Pool<ConnectionManager<PgConnection>>;
|
pub type PgPool = Pool<ConnectionManager<PgConnection>>;
|
||||||
|
|
||||||
pub const DEFAULT_MAX_POOL_SIZE: u32 = 2;
|
pub const DEFAULT_MAX_POOL_SIZE: u32 = 2;
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct SchemaCustomizer;
|
|
||||||
|
|
||||||
impl CustomizeConnection<PgConnection, diesel::r2d2::Error> for SchemaCustomizer {
|
|
||||||
fn on_acquire(&self, conn: &mut PgConnection) -> Result<(), diesel::r2d2::Error> {
|
|
||||||
diesel::sql_query(
|
|
||||||
"SELECT set_config('search_path', (
|
|
||||||
SELECT string_agg(schema_name, ', ')
|
|
||||||
FROM (
|
|
||||||
SELECT 'tenant' AS schema_name WHERE EXISTS (
|
|
||||||
SELECT 1 FROM pg_namespace WHERE nspname = 'tenant'
|
|
||||||
)
|
|
||||||
UNION ALL
|
|
||||||
SELECT 'shared' AS schema_name WHERE EXISTS (
|
|
||||||
SELECT 1 FROM pg_namespace WHERE nspname = 'shared'
|
|
||||||
)
|
|
||||||
UNION ALL
|
|
||||||
SELECT 'public' AS schema_name
|
|
||||||
) AS schemas
|
|
||||||
), false)",
|
|
||||||
)
|
|
||||||
.execute(conn)
|
|
||||||
.map(|_| ())
|
|
||||||
.map_err(diesel::r2d2::Error::QueryError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn init_pool(database_url: &str) -> anyhow::Result<PgPool> {
|
pub fn init_pool(database_url: &str) -> anyhow::Result<PgPool> {
|
||||||
init_pool_with_size(database_url, DEFAULT_MAX_POOL_SIZE)
|
init_pool_with_size(database_url, DEFAULT_MAX_POOL_SIZE)
|
||||||
}
|
}
|
||||||
@@ -45,7 +17,6 @@ pub fn init_pool_with_size(database_url: &str, max_size: u32) -> anyhow::Result<
|
|||||||
let pool = Pool::builder()
|
let pool = Pool::builder()
|
||||||
.max_size(pool_size)
|
.max_size(pool_size)
|
||||||
.connection_timeout(Duration::from_secs(10))
|
.connection_timeout(Duration::from_secs(10))
|
||||||
.connection_customizer(Box::new(SchemaCustomizer))
|
|
||||||
.build(manager)?;
|
.build(manager)?;
|
||||||
Ok(pool)
|
Ok(pool)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::Path as FsPath;
|
use std::path::Path as FsPath;
|
||||||
|
|
||||||
use chrono::{Duration as ChronoDuration, Utc};
|
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
@@ -9,27 +8,30 @@ use utoipa::ToSchema;
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::error::{AppError, AppResult};
|
use crate::error::{AppError, AppResult};
|
||||||
use crate::models::{Document, DocumentAsset, DocumentVersion};
|
use crate::models::{Document, DocumentAsset, DocumentAssetObject, DocumentVersion};
|
||||||
use crate::schema::{document_assets, document_versions};
|
use crate::schema::{document_asset_objects, document_assets, document_versions};
|
||||||
use crate::state::{AppState, PgPooledConnection};
|
use crate::state::AppState;
|
||||||
use crate::utils::{http::inline_content_disposition, time::to_iso};
|
use crate::utils::time::to_iso;
|
||||||
|
|
||||||
#[derive(Serialize, Clone, ToSchema)]
|
|
||||||
pub struct DownloadLink {
|
|
||||||
pub url: String,
|
|
||||||
pub expires_at: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Clone, ToSchema)]
|
#[derive(Serialize, Clone, ToSchema)]
|
||||||
pub struct DocumentAssetResponse {
|
pub struct DocumentAssetResponse {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub asset_type: String,
|
pub asset_type: String,
|
||||||
pub mime_type: String,
|
pub mime_type: String,
|
||||||
#[schema(value_type = Object)]
|
|
||||||
pub metadata: Value,
|
pub metadata: Value,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
#[schema(nullable)]
|
pub cardinality: Option<i32>,
|
||||||
pub download: Option<DownloadLink>,
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Clone, ToSchema)]
|
||||||
|
pub struct DocumentAssetObjectResponse {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub ordinal: i32,
|
||||||
|
pub metadata: Value,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub url: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub expires_at: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize, ToSchema)]
|
||||||
@@ -37,12 +39,12 @@ pub struct DocumentAssetDetailResponse {
|
|||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub asset_type: String,
|
pub asset_type: String,
|
||||||
pub mime_type: String,
|
pub mime_type: String,
|
||||||
#[schema(value_type = Object)]
|
|
||||||
pub metadata: Value,
|
pub metadata: Value,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
#[schema(nullable)]
|
pub cardinality: Option<i32>,
|
||||||
pub download: Option<DownloadLink>,
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub objects: Vec<DocumentAssetObjectResponse>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Clone, ToSchema)]
|
#[derive(Serialize, Clone, ToSchema)]
|
||||||
@@ -52,7 +54,6 @@ pub struct DocumentVersionResponse {
|
|||||||
pub size_bytes: i64,
|
pub size_bytes: i64,
|
||||||
pub checksum: String,
|
pub checksum: String,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
#[schema(value_type = Object)]
|
|
||||||
pub metadata: Value,
|
pub metadata: Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,35 +63,19 @@ pub struct DocumentVersionDetailResponse {
|
|||||||
pub version: DocumentVersionResponse,
|
pub version: DocumentVersionResponse,
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
pub assets: Vec<DocumentAssetResponse>,
|
pub assets: Vec<DocumentAssetResponse>,
|
||||||
pub download: DownloadLink,
|
pub download_path: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn build_download_link(
|
pub fn build_download_path(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
document: &Document,
|
document: &Document,
|
||||||
version_id: Uuid,
|
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
) -> AppResult<DownloadLink> {
|
) -> AppResult<String> {
|
||||||
state
|
state
|
||||||
.jwt
|
.jwt
|
||||||
.generate_download_token(document.id, version_id, user_id, document.tenant_id)
|
.generate_download_token(document.id, user_id, document.tenant_id)
|
||||||
.map_err(|err| {
|
.map(|token| format!("/download/{token}"))
|
||||||
tracing::error!(error = ?err, "failed to generate download token");
|
.map_err(|err| AppError::internal(format!("failed to generate download token: {err}")))
|
||||||
AppError::internal("failed to generate download token")
|
|
||||||
})
|
|
||||||
.and_then(|token| {
|
|
||||||
let expires_at = Utc::now()
|
|
||||||
.checked_add_signed(ChronoDuration::minutes(
|
|
||||||
state.config.download_token_expiry_minutes,
|
|
||||||
))
|
|
||||||
.ok_or_else(|| AppError::internal("failed to compute download expiry"))?
|
|
||||||
.timestamp_millis();
|
|
||||||
|
|
||||||
Ok(DownloadLink {
|
|
||||||
url: format!("/api/download/{token}"),
|
|
||||||
expires_at,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn to_version_response(version: DocumentVersion) -> DocumentVersionResponse {
|
pub fn to_version_response(version: DocumentVersion) -> DocumentVersionResponse {
|
||||||
@@ -110,13 +95,13 @@ pub fn to_asset_summary(asset: DocumentAsset) -> DocumentAssetResponse {
|
|||||||
asset_type: asset.asset_type,
|
asset_type: asset.asset_type,
|
||||||
mime_type: asset.mime_type,
|
mime_type: asset.mime_type,
|
||||||
metadata: asset.metadata,
|
metadata: asset.metadata,
|
||||||
download: None,
|
cardinality: asset.cardinality,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn to_asset_detail_response(
|
pub fn to_asset_detail_response(
|
||||||
asset: DocumentAsset,
|
asset: DocumentAsset,
|
||||||
download: Option<DownloadLink>,
|
objects: Vec<DocumentAssetObjectResponse>,
|
||||||
) -> DocumentAssetDetailResponse {
|
) -> DocumentAssetDetailResponse {
|
||||||
DocumentAssetDetailResponse {
|
DocumentAssetDetailResponse {
|
||||||
id: asset.id,
|
id: asset.id,
|
||||||
@@ -124,46 +109,56 @@ pub fn to_asset_detail_response(
|
|||||||
mime_type: asset.mime_type,
|
mime_type: asset.mime_type,
|
||||||
metadata: asset.metadata,
|
metadata: asset.metadata,
|
||||||
created_at: to_iso(asset.created_at),
|
created_at: to_iso(asset.created_at),
|
||||||
download,
|
cardinality: asset.cardinality,
|
||||||
|
objects,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn asset_disposition(asset: &DocumentAsset) -> Option<String> {
|
pub fn to_asset_object_response(
|
||||||
let filename = asset.asset_type.clone();
|
object: DocumentAssetObject,
|
||||||
inline_content_disposition(&filename)
|
url: Option<String>,
|
||||||
|
expires_at: Option<i64>,
|
||||||
|
) -> DocumentAssetObjectResponse {
|
||||||
|
DocumentAssetObjectResponse {
|
||||||
|
id: object.id,
|
||||||
|
ordinal: object.ordinal,
|
||||||
|
metadata: object.metadata,
|
||||||
|
url,
|
||||||
|
expires_at,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete_asset(
|
pub async fn load_asset_responses(
|
||||||
conn: &mut PgPooledConnection,
|
state: &AppState,
|
||||||
tenant_id: Uuid,
|
|
||||||
asset_id: Uuid,
|
|
||||||
) -> AppResult<()> {
|
|
||||||
diesel::delete(
|
|
||||||
document_assets::table
|
|
||||||
.filter(document_assets::id.eq(asset_id))
|
|
||||||
.filter(document_assets::tenant_id.eq(tenant_id)),
|
|
||||||
)
|
|
||||||
.execute(conn)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn load_asset_responses_with_conn(
|
|
||||||
conn: &mut PgPooledConnection,
|
|
||||||
tenant_id: Uuid,
|
tenant_id: Uuid,
|
||||||
version_id: Uuid,
|
version_id: Uuid,
|
||||||
) -> AppResult<Vec<DocumentAssetResponse>> {
|
) -> AppResult<Vec<DocumentAssetResponse>> {
|
||||||
let assets: Vec<DocumentAsset> = document_assets::table
|
let mut conn = state.db_for_tenant(tenant_id)?;
|
||||||
|
let assets: Vec<(DocumentAsset, Option<DocumentAssetObject>)> = document_assets::table
|
||||||
|
.left_outer_join(
|
||||||
|
document_asset_objects::table.on(document_asset_objects::asset_id
|
||||||
|
.eq(document_assets::id)
|
||||||
|
.and(document_asset_objects::ordinal.eq(1))),
|
||||||
|
)
|
||||||
.filter(document_assets::document_version_id.eq(version_id))
|
.filter(document_assets::document_version_id.eq(version_id))
|
||||||
.filter(document_assets::tenant_id.eq(tenant_id))
|
.filter(document_assets::tenant_id.eq(tenant_id))
|
||||||
.order(document_assets::created_at.asc())
|
.order(document_assets::created_at.asc())
|
||||||
.load(conn)?;
|
.select((
|
||||||
|
document_assets::all_columns,
|
||||||
|
document_asset_objects::all_columns.nullable(),
|
||||||
|
))
|
||||||
|
.load(&mut conn)?;
|
||||||
|
drop(conn);
|
||||||
|
|
||||||
Ok(assets.into_iter().map(to_asset_summary).collect())
|
Ok(assets
|
||||||
|
.into_iter()
|
||||||
|
.map(|(asset, _)| to_asset_summary(asset))
|
||||||
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn load_primary_assets(
|
pub fn load_primary_assets(
|
||||||
conn: &mut PgPooledConnection,
|
state: &AppState,
|
||||||
|
tenant_id: Uuid,
|
||||||
documents: &[Document],
|
documents: &[Document],
|
||||||
) -> AppResult<HashMap<Uuid, (DocumentVersionResponse, Vec<DocumentAssetResponse>)>> {
|
) -> AppResult<HashMap<Uuid, (DocumentVersionResponse, Vec<DocumentAssetResponse>)>> {
|
||||||
if documents.is_empty() {
|
if documents.is_empty() {
|
||||||
@@ -180,25 +175,37 @@ pub fn load_primary_assets(
|
|||||||
version_ids.sort();
|
version_ids.sort();
|
||||||
version_ids.dedup();
|
version_ids.dedup();
|
||||||
|
|
||||||
|
let mut conn = state.db_for_tenant(tenant_id)?;
|
||||||
let versions: Vec<DocumentVersion> = document_versions::table
|
let versions: Vec<DocumentVersion> = document_versions::table
|
||||||
.filter(document_versions::id.eq_any(&version_ids))
|
.filter(document_versions::id.eq_any(&version_ids))
|
||||||
.load(conn)?;
|
.load(&mut conn)?;
|
||||||
|
|
||||||
let mut version_map: HashMap<Uuid, DocumentVersion> = HashMap::new();
|
let mut version_map: HashMap<Uuid, DocumentVersion> = HashMap::new();
|
||||||
for version in versions {
|
for version in versions {
|
||||||
version_map.insert(version.id, version);
|
version_map.insert(version.id, version);
|
||||||
}
|
}
|
||||||
|
|
||||||
let assets: Vec<DocumentAsset> = document_assets::table
|
let assets: Vec<(DocumentAsset, Option<DocumentAssetObject>)> = document_assets::table
|
||||||
|
.left_outer_join(
|
||||||
|
document_asset_objects::table.on(document_asset_objects::asset_id
|
||||||
|
.eq(document_assets::id)
|
||||||
|
.and(document_asset_objects::ordinal.eq(1))),
|
||||||
|
)
|
||||||
.filter(document_assets::document_version_id.eq_any(&version_ids))
|
.filter(document_assets::document_version_id.eq_any(&version_ids))
|
||||||
.order((
|
.order((
|
||||||
document_assets::document_version_id.asc(),
|
document_assets::document_version_id.asc(),
|
||||||
document_assets::created_at.asc(),
|
document_assets::created_at.asc(),
|
||||||
))
|
))
|
||||||
.load(conn)?;
|
.select((
|
||||||
|
document_assets::all_columns,
|
||||||
|
document_asset_objects::all_columns.nullable(),
|
||||||
|
))
|
||||||
|
.load(&mut conn)?;
|
||||||
|
|
||||||
|
drop(conn);
|
||||||
|
|
||||||
let mut assets_by_version: HashMap<Uuid, Vec<DocumentAssetResponse>> = HashMap::new();
|
let mut assets_by_version: HashMap<Uuid, Vec<DocumentAssetResponse>> = HashMap::new();
|
||||||
for asset in assets {
|
for (asset, _object) in assets {
|
||||||
let version_id = asset.document_version_id;
|
let version_id = asset.document_version_id;
|
||||||
let response = to_asset_summary(asset);
|
let response = to_asset_summary(asset);
|
||||||
assets_by_version
|
assets_by_version
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ use crate::utils::time::to_iso;
|
|||||||
pub struct DocumentCorrespondentResponse {
|
pub struct DocumentCorrespondentResponse {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
#[schema(value_type = Object)]
|
|
||||||
pub metadata: Value,
|
pub metadata: Value,
|
||||||
pub assigned_at: String,
|
pub assigned_at: String,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,5 @@ pub mod asset;
|
|||||||
pub mod correspondents;
|
pub mod correspondents;
|
||||||
pub mod folders;
|
pub mod folders;
|
||||||
pub mod metadata;
|
pub mod metadata;
|
||||||
pub mod ordering;
|
|
||||||
pub mod relations;
|
|
||||||
pub mod search;
|
pub mod search;
|
||||||
pub mod tags;
|
pub mod tags;
|
||||||
|
|
||||||
pub use ordering::{DocumentSortField, SortDirection};
|
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use utoipa::ToSchema;
|
|
||||||
|
|
||||||
pub const UNICODE_COLLATION_NAME: &str = "unicode_ci";
|
|
||||||
pub const UNICODE_COLLATION_LOCALE: &str = "und-u-ks-level2";
|
|
||||||
|
|
||||||
const TITLE_ASC: &str = "title COLLATE \"unicode_ci\" ASC";
|
|
||||||
const TITLE_DESC: &str = "title COLLATE \"unicode_ci\" DESC";
|
|
||||||
const ISSUED_AT_ASC: &str = "issued_at ASC NULLS LAST";
|
|
||||||
const ISSUED_AT_DESC: &str = "issued_at DESC NULLS LAST";
|
|
||||||
const CREATED_AT_ASC: &str = "created_at ASC";
|
|
||||||
const CREATED_AT_DESC: &str = "created_at DESC";
|
|
||||||
const UPDATED_AT_ASC: &str = "updated_at ASC";
|
|
||||||
const UPDATED_AT_DESC: &str = "updated_at DESC";
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize, ToSchema)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum DocumentSortField {
|
|
||||||
Title,
|
|
||||||
IssuedAt,
|
|
||||||
CreatedAt,
|
|
||||||
UpdatedAt,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for DocumentSortField {
|
|
||||||
fn default() -> Self {
|
|
||||||
DocumentSortField::Title
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize, ToSchema)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum SortDirection {
|
|
||||||
Asc,
|
|
||||||
Desc,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for SortDirection {
|
|
||||||
fn default() -> Self {
|
|
||||||
SortDirection::Asc
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn ordering_clauses(
|
|
||||||
field: DocumentSortField,
|
|
||||||
direction: SortDirection,
|
|
||||||
) -> (&'static str, Option<&'static str>) {
|
|
||||||
match (field, direction) {
|
|
||||||
(DocumentSortField::Title, SortDirection::Asc) => (TITLE_ASC, None),
|
|
||||||
(DocumentSortField::Title, SortDirection::Desc) => (TITLE_DESC, None),
|
|
||||||
(DocumentSortField::IssuedAt, SortDirection::Asc) => (ISSUED_AT_ASC, Some(TITLE_ASC)),
|
|
||||||
(DocumentSortField::IssuedAt, SortDirection::Desc) => (ISSUED_AT_DESC, Some(TITLE_ASC)),
|
|
||||||
(DocumentSortField::CreatedAt, SortDirection::Asc) => (CREATED_AT_ASC, Some(TITLE_ASC)),
|
|
||||||
(DocumentSortField::CreatedAt, SortDirection::Desc) => (CREATED_AT_DESC, Some(TITLE_ASC)),
|
|
||||||
(DocumentSortField::UpdatedAt, SortDirection::Asc) => (UPDATED_AT_ASC, Some(TITLE_ASC)),
|
|
||||||
(DocumentSortField::UpdatedAt, SortDirection::Desc) => (UPDATED_AT_DESC, Some(TITLE_ASC)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::documents::correspondents::{
|
|
||||||
load_correspondents_for_documents, DocumentCorrespondentResponse,
|
|
||||||
};
|
|
||||||
use crate::documents::tags::load_tags_for_documents;
|
|
||||||
use crate::error::AppResult;
|
|
||||||
use crate::models::Tag;
|
|
||||||
use crate::state::PgPooledConnection;
|
|
||||||
|
|
||||||
/// Loads tags and correspondents for the provided documents in a single pass.
|
|
||||||
pub fn load_tags_and_correspondents(
|
|
||||||
conn: &mut PgPooledConnection,
|
|
||||||
document_ids: &[Uuid],
|
|
||||||
) -> AppResult<HashMap<Uuid, (Vec<Tag>, Vec<DocumentCorrespondentResponse>)>> {
|
|
||||||
if document_ids.is_empty() {
|
|
||||||
return Ok(HashMap::new());
|
|
||||||
}
|
|
||||||
|
|
||||||
let tags_map = load_tags_for_documents(conn, document_ids)?;
|
|
||||||
let mut correspondents_map = load_correspondents_for_documents(conn, document_ids)?;
|
|
||||||
|
|
||||||
let mut result = HashMap::with_capacity(document_ids.len());
|
|
||||||
for id in document_ids {
|
|
||||||
let tags = tags_map.get(id).cloned().unwrap_or_default();
|
|
||||||
let correspondents = correspondents_map.remove(id).unwrap_or_default();
|
|
||||||
result.insert(*id, (tags, correspondents));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(result)
|
|
||||||
}
|
|
||||||
@@ -1,14 +1,12 @@
|
|||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
use anyhow::{anyhow, bail, Result};
|
use anyhow::{anyhow, Result};
|
||||||
use reqwest::{Client, StatusCode};
|
use reqwest::Client;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::Deserialize;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use tracing::{debug, error};
|
use tracing::{debug, error};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::models::{Document, DocumentVersion};
|
|
||||||
|
|
||||||
pub const QUICKWIT_MAX_HITS: usize = 200;
|
pub const QUICKWIT_MAX_HITS: usize = 200;
|
||||||
|
|
||||||
pub fn build_quickwit_query(input: &str) -> Option<String> {
|
pub fn build_quickwit_query(input: &str) -> Option<String> {
|
||||||
@@ -109,74 +107,6 @@ pub async fn quickwit_search(
|
|||||||
Ok(doc_ids)
|
Ok(doc_ids)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn quickwit_index_template(index_id: &str) -> Value {
|
|
||||||
json!({
|
|
||||||
"version": "0.8",
|
|
||||||
"index_id": index_id,
|
|
||||||
"doc_mapping": {
|
|
||||||
"tokenizers": [
|
|
||||||
{
|
|
||||||
"name": "substring",
|
|
||||||
"type": "ngram",
|
|
||||||
"min_gram": 2,
|
|
||||||
"max_gram": 20,
|
|
||||||
"prefix_only": false
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"field_mappings": [
|
|
||||||
{ "name": "tenant_id", "type": "text", "stored": true },
|
|
||||||
{ "name": "document_id", "type": "text", "stored": true },
|
|
||||||
{ "name": "version_id", "type": "text", "stored": true },
|
|
||||||
{ "name": "title", "type": "text", "tokenizer": "substring", "stored": true },
|
|
||||||
{ "name": "text", "type": "text", "tokenizer": "substring", "record": "position" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"search_settings": {
|
|
||||||
"default_search_fields": ["title", "text"]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn ensure_quickwit_index(client: &Client, endpoint: &str, index_id: &str) -> Result<()> {
|
|
||||||
let base = endpoint.trim_end_matches('/');
|
|
||||||
let create_url = format!("{}/api/v1/indexes", base);
|
|
||||||
let payload = quickwit_index_template(index_id);
|
|
||||||
|
|
||||||
let response = client.post(&create_url).json(&payload).send().await?;
|
|
||||||
match response.status() {
|
|
||||||
status if status.is_success() => Ok(()),
|
|
||||||
StatusCode::CONFLICT => {
|
|
||||||
let lookup_url = format!("{}/api/v1/indexes/{}", base, index_id);
|
|
||||||
let lookup = client.get(&lookup_url).send().await?;
|
|
||||||
if lookup.status().is_success() {
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
let status = lookup.status();
|
|
||||||
let body = lookup.text().await.unwrap_or_default();
|
|
||||||
bail!("quickwit index lookup failed with status {status}: {body}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
status => {
|
|
||||||
let body = response.text().await.unwrap_or_default();
|
|
||||||
bail!("quickwit create index failed with status {status}: {body}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn delete_quickwit_index(client: &Client, endpoint: &str, index_id: &str) -> Result<()> {
|
|
||||||
let base = endpoint.trim_end_matches('/');
|
|
||||||
let url = format!("{}/api/v1/indexes/{}", base, index_id);
|
|
||||||
let response = client.delete(&url).send().await?;
|
|
||||||
match response.status() {
|
|
||||||
status if status.is_success() => Ok(()),
|
|
||||||
StatusCode::NOT_FOUND => Ok(()),
|
|
||||||
status => {
|
|
||||||
let body = response.text().await.unwrap_or_default();
|
|
||||||
bail!("quickwit delete index failed with status {status}: {body}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn extract_document_id(hit: &Value) -> Option<Uuid> {
|
pub fn extract_document_id(hit: &Value) -> Option<Uuid> {
|
||||||
for key in ["_source", "source", "fields", "stored_fields"] {
|
for key in ["_source", "source", "fields", "stored_fields"] {
|
||||||
if let Some(value) = hit.get(key) {
|
if let Some(value) = hit.get(key) {
|
||||||
@@ -234,71 +164,3 @@ struct QuickwitSearchResponse {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
hits: Vec<Value>,
|
hits: Vec<Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
pub struct QuickwitIngestRecord {
|
|
||||||
pub document_id: Uuid,
|
|
||||||
pub version_id: Uuid,
|
|
||||||
pub tenant_id: Uuid,
|
|
||||||
pub title: String,
|
|
||||||
pub text: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn build_quickwit_ingest_record(
|
|
||||||
document: &Document,
|
|
||||||
version: &DocumentVersion,
|
|
||||||
tenant_id: Uuid,
|
|
||||||
text: &str,
|
|
||||||
) -> QuickwitIngestRecord {
|
|
||||||
QuickwitIngestRecord {
|
|
||||||
document_id: document.id,
|
|
||||||
version_id: version.id,
|
|
||||||
tenant_id,
|
|
||||||
title: document.title.to_lowercase(),
|
|
||||||
text: text.to_lowercase(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn quickwit_ingest(
|
|
||||||
client: &Client,
|
|
||||||
endpoint: &str,
|
|
||||||
index: &str,
|
|
||||||
records: &[QuickwitIngestRecord],
|
|
||||||
) -> Result<()> {
|
|
||||||
if records.is_empty() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let url = format!(
|
|
||||||
"{}/api/v1/{}/ingest?commit=auto",
|
|
||||||
endpoint.trim_end_matches('/'),
|
|
||||||
index
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut body = String::new();
|
|
||||||
for record in records {
|
|
||||||
let line = serde_json::to_string(record)?;
|
|
||||||
body.push_str(&line);
|
|
||||||
body.push('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
debug!(%url, lines = records.len(), "sending quickwit ingest request");
|
|
||||||
let response = client
|
|
||||||
.post(url)
|
|
||||||
.header("content-type", "application/x-ndjson")
|
|
||||||
.body(body)
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if !response.status().is_success() {
|
|
||||||
let status = response.status();
|
|
||||||
let body = response.text().await.unwrap_or_default();
|
|
||||||
error!(%status, %body, "quickwit ingest request failed");
|
|
||||||
return Err(anyhow!(
|
|
||||||
"quickwit ingest failed with status {status}: {body}"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
debug!("quickwit ingest request succeeded");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|||||||
+5
-31
@@ -4,9 +4,7 @@ use axum::{
|
|||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use serde_json::Value;
|
use std::fmt::Display;
|
||||||
use std::fmt::{self, Display};
|
|
||||||
use utoipa::ToSchema;
|
|
||||||
|
|
||||||
pub type AppResult<T> = Result<T, AppError>;
|
pub type AppResult<T> = Result<T, AppError>;
|
||||||
|
|
||||||
@@ -15,7 +13,6 @@ pub struct AppError {
|
|||||||
status: StatusCode,
|
status: StatusCode,
|
||||||
message: String,
|
message: String,
|
||||||
code: Option<String>,
|
code: Option<String>,
|
||||||
details: Option<Value>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppError {
|
impl AppError {
|
||||||
@@ -24,7 +21,6 @@ impl AppError {
|
|||||||
status,
|
status,
|
||||||
message: message.into(),
|
message: message.into(),
|
||||||
code: None,
|
code: None,
|
||||||
details: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,10 +36,6 @@ impl AppError {
|
|||||||
Self::new(StatusCode::UNAUTHORIZED, "unauthorized")
|
Self::new(StatusCode::UNAUTHORIZED, "unauthorized")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn forbidden(message: impl Into<String>) -> Self {
|
|
||||||
Self::new(StatusCode::FORBIDDEN, message)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn not_found() -> Self {
|
pub fn not_found() -> Self {
|
||||||
Self::new(StatusCode::NOT_FOUND, "resource not found")
|
Self::new(StatusCode::NOT_FOUND, "resource not found")
|
||||||
}
|
}
|
||||||
@@ -56,49 +48,31 @@ impl AppError {
|
|||||||
self.code = Some(code.into());
|
self.code = Some(code.into());
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn with_details(mut self, details: Value) -> Self {
|
|
||||||
self.details = Some(details);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for AppError {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
write!(f, "{}: {}", self.status, self.message)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IntoResponse for AppError {
|
impl IntoResponse for AppError {
|
||||||
fn into_response(self) -> Response {
|
fn into_response(self) -> Response {
|
||||||
let status = self.status;
|
let status = self.status;
|
||||||
let body = Json(ApiErrorResponse {
|
let body = Json(ErrorResponse {
|
||||||
error: self.message,
|
error: self.message,
|
||||||
code: self.code,
|
code: self.code,
|
||||||
details: self.details,
|
|
||||||
});
|
});
|
||||||
(status, body).into_response()
|
(status, body).into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize)]
|
||||||
pub struct ApiErrorResponse {
|
struct ErrorResponse {
|
||||||
error: String,
|
error: String,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
code: Option<String>,
|
code: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
details: Option<Value>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<diesel::result::Error> for AppError {
|
impl From<diesel::result::Error> for AppError {
|
||||||
fn from(value: diesel::result::Error) -> Self {
|
fn from(value: diesel::result::Error) -> Self {
|
||||||
match value {
|
match value {
|
||||||
diesel::result::Error::NotFound => AppError::not_found(),
|
diesel::result::Error::NotFound => AppError::not_found(),
|
||||||
other => {
|
_ => AppError::internal(value),
|
||||||
let message = format!("database operation failed: {other}");
|
|
||||||
tracing::error!(error = ?other, message);
|
|
||||||
AppError::internal(message)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
pub mod responders;
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
use axum::{
|
|
||||||
http::StatusCode,
|
|
||||||
response::{IntoResponse, Response},
|
|
||||||
Json,
|
|
||||||
};
|
|
||||||
use serde::Serialize;
|
|
||||||
|
|
||||||
use crate::error::{AppError, AppResult};
|
|
||||||
|
|
||||||
/// Helper trait to convert error-centric results into the application's error type.
|
|
||||||
pub trait IntoAppResult<T> {
|
|
||||||
fn into_app_result(self) -> AppResult<T>;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T, E> IntoAppResult<T> for Result<T, E>
|
|
||||||
where
|
|
||||||
AppError: From<E>,
|
|
||||||
{
|
|
||||||
fn into_app_result(self) -> AppResult<T> {
|
|
||||||
self.map_err(AppError::from)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extension helpers for optional values to map them into `AppResult`.
|
|
||||||
pub trait OptionAppResultExt<T> {
|
|
||||||
fn or_not_found(self) -> AppResult<T>;
|
|
||||||
fn or_bad_request(self, message: impl Into<String>) -> AppResult<T>;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T> OptionAppResultExt<T> for Option<T> {
|
|
||||||
fn or_not_found(self) -> AppResult<T> {
|
|
||||||
self.ok_or_else(AppError::not_found)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn or_bad_request(self, message: impl Into<String>) -> AppResult<T> {
|
|
||||||
self.ok_or_else(|| AppError::bad_request(message))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Provides helpers for statements returning number of affected rows.
|
|
||||||
pub trait RowsAffectedExt: Sized {
|
|
||||||
fn or_error(self, error: AppError) -> AppResult<usize>;
|
|
||||||
fn or_not_found(self) -> AppResult<usize> {
|
|
||||||
self.or_error(AppError::not_found())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RowsAffectedExt for usize {
|
|
||||||
fn or_error(self, error: AppError) -> AppResult<usize> {
|
|
||||||
if self == 0 {
|
|
||||||
Err(error)
|
|
||||||
} else {
|
|
||||||
Ok(self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Wrapper providing a consistent JSON response with a status code.
|
|
||||||
pub struct JsonResponse<T> {
|
|
||||||
status: StatusCode,
|
|
||||||
payload: T,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T> JsonResponse<T> {
|
|
||||||
pub fn new(status: StatusCode, payload: T) -> Self {
|
|
||||||
Self { status, payload }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn ok(payload: T) -> Self {
|
|
||||||
Self::new(StatusCode::OK, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn created(payload: T) -> Self {
|
|
||||||
Self::new(StatusCode::CREATED, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn accepted(payload: T) -> Self {
|
|
||||||
Self::new(StatusCode::ACCEPTED, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn into_inner(self) -> T {
|
|
||||||
self.payload
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn as_inner(&self) -> &T {
|
|
||||||
&self.payload
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T> From<T> for JsonResponse<T> {
|
|
||||||
fn from(value: T) -> Self {
|
|
||||||
Self::ok(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T> IntoResponse for JsonResponse<T>
|
|
||||||
where
|
|
||||||
T: Serialize,
|
|
||||||
{
|
|
||||||
fn into_response(self) -> Response {
|
|
||||||
(self.status, Json(self.payload)).into_response()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Helper for returning empty responses with a status code.
|
|
||||||
pub fn empty(status: StatusCode) -> AppResult<StatusCode> {
|
|
||||||
Ok(status)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Helper for returning `204 No Content`.
|
|
||||||
pub fn no_content() -> AppResult<StatusCode> {
|
|
||||||
empty(StatusCode::NO_CONTENT)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Helper for returning JSON payloads with `200 OK`.
|
|
||||||
pub fn ok_json<T>(value: T) -> AppResult<JsonResponse<T>>
|
|
||||||
where
|
|
||||||
T: Serialize,
|
|
||||||
{
|
|
||||||
Ok(JsonResponse::ok(value))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Helper for returning JSON payloads with `201 Created`.
|
|
||||||
pub fn created_json<T>(value: T) -> AppResult<JsonResponse<T>>
|
|
||||||
where
|
|
||||||
T: Serialize,
|
|
||||||
{
|
|
||||||
Ok(JsonResponse::created(value))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Helper for returning JSON payloads with `202 Accepted`.
|
|
||||||
pub fn accepted_json<T>(value: T) -> AppResult<JsonResponse<T>>
|
|
||||||
where
|
|
||||||
T: Serialize,
|
|
||||||
{
|
|
||||||
Ok(JsonResponse::accepted(value))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Standard wrapper for paginated responses.
|
|
||||||
#[derive(Serialize)]
|
|
||||||
pub struct PaginatedResponse<T, M>
|
|
||||||
where
|
|
||||||
T: Serialize,
|
|
||||||
M: Serialize,
|
|
||||||
{
|
|
||||||
pub data: T,
|
|
||||||
pub meta: M,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn paginated_json<T, M>(data: T, meta: M) -> AppResult<JsonResponse<PaginatedResponse<T, M>>>
|
|
||||||
where
|
|
||||||
T: Serialize,
|
|
||||||
M: Serialize,
|
|
||||||
{
|
|
||||||
let payload = PaginatedResponse { data, meta };
|
|
||||||
Ok(JsonResponse::ok(payload))
|
|
||||||
}
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
use std::collections::HashSet;
|
|
||||||
|
|
||||||
use chrono::{DateTime, Datelike, NaiveDate, TimeZone, Utc};
|
|
||||||
use chrono_tz::Tz;
|
|
||||||
use once_cell::sync::Lazy;
|
|
||||||
use tracing::warn;
|
|
||||||
|
|
||||||
use crate::config::AppConfig;
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug)]
|
|
||||||
pub enum DateOrder {
|
|
||||||
Dmy,
|
|
||||||
Mdy,
|
|
||||||
Ymd,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DateOrder {
|
|
||||||
pub fn parse(value: &str) -> Self {
|
|
||||||
Self::try_parse(value).unwrap_or(DateOrder::Dmy)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn try_parse(value: &str) -> Option<Self> {
|
|
||||||
match value.trim().to_ascii_uppercase().as_str() {
|
|
||||||
"YMD" => Some(DateOrder::Ymd),
|
|
||||||
"MDY" => Some(DateOrder::Mdy),
|
|
||||||
"DMY" => Some(DateOrder::Dmy),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static MIN_ISSUED_AT_DATE: Lazy<NaiveDate> =
|
|
||||||
Lazy::new(|| NaiveDate::from_ymd_opt(1901, 1, 1).expect("valid minimum issued_at date"));
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct IssuedAtSettings {
|
|
||||||
pub timezone: Tz,
|
|
||||||
pub date_order: DateOrder,
|
|
||||||
pub filename_date_order: Option<DateOrder>,
|
|
||||||
pub locales: HashSet<String>,
|
|
||||||
pub ignore_dates: HashSet<NaiveDate>,
|
|
||||||
pub min_date: NaiveDate,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl IssuedAtSettings {
|
|
||||||
pub fn from_config(config: &AppConfig) -> Self {
|
|
||||||
let timezone = config.service_timezone.parse::<Tz>().unwrap_or_else(|_| {
|
|
||||||
warn!(
|
|
||||||
timezone = %config.service_timezone,
|
|
||||||
"invalid service timezone configured; falling back to UTC"
|
|
||||||
);
|
|
||||||
chrono_tz::UTC
|
|
||||||
});
|
|
||||||
|
|
||||||
let date_order = DateOrder::parse(&config.issued_at_date_order);
|
|
||||||
let filename_date_order =
|
|
||||||
config
|
|
||||||
.issued_at_filename_date_order
|
|
||||||
.as_deref()
|
|
||||||
.and_then(|value| {
|
|
||||||
DateOrder::try_parse(value).or_else(|| {
|
|
||||||
warn!(value, "invalid issued_at filename date order; ignoring");
|
|
||||||
None
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
let locales = config
|
|
||||||
.issued_at_date_parser_locales
|
|
||||||
.iter()
|
|
||||||
.filter_map(|value| {
|
|
||||||
let trimmed = value.trim();
|
|
||||||
if trimmed.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(trimmed.to_ascii_lowercase())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect::<HashSet<_>>();
|
|
||||||
|
|
||||||
// Ignore dates are evaluated after normalizing candidate timestamps to
|
|
||||||
// the configured service timezone, so administrators should provide
|
|
||||||
// local calendar dates rather than UTC midnights.
|
|
||||||
let ignore_dates = config
|
|
||||||
.issued_at_ignore_dates
|
|
||||||
.iter()
|
|
||||||
.filter_map(|value| {
|
|
||||||
let trimmed = value.trim();
|
|
||||||
if trimmed.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
match NaiveDate::parse_from_str(trimmed, "%Y-%m-%d") {
|
|
||||||
Ok(date) => Some(date),
|
|
||||||
Err(err) => {
|
|
||||||
warn!(value = trimmed, error = %err, "invalid issued_at ignore date");
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Self {
|
|
||||||
timezone,
|
|
||||||
date_order,
|
|
||||||
filename_date_order,
|
|
||||||
locales,
|
|
||||||
ignore_dates,
|
|
||||||
min_date: *MIN_ISSUED_AT_DATE,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the immutable (lowercase) locale allowlist supplied via config.
|
|
||||||
pub fn locales(&self) -> &HashSet<String> {
|
|
||||||
&self.locales
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the immutable set of local-calendar dates that should be ignored.
|
|
||||||
pub fn ignore_dates(&self) -> &HashSet<NaiveDate> {
|
|
||||||
&self.ignore_dates
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the configured service timezone (copy type).
|
|
||||||
pub fn timezone(&self) -> Tz {
|
|
||||||
self.timezone
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn date_order(&self) -> DateOrder {
|
|
||||||
self.date_order
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn filename_date_order(&self) -> Option<DateOrder> {
|
|
||||||
self.filename_date_order
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn normalize_naive(
|
|
||||||
&self,
|
|
||||||
date: NaiveDate,
|
|
||||||
now_utc: chrono::DateTime<Utc>,
|
|
||||||
) -> Option<DateTime<Utc>> {
|
|
||||||
if !self.is_valid_with_now(date, now_utc) {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
self.timezone
|
|
||||||
.with_ymd_and_hms(date.year(), date.month(), date.day(), 0, 0, 0)
|
|
||||||
.earliest()
|
|
||||||
.map(|dt| dt.with_timezone(&Utc))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn normalize_datetime(
|
|
||||||
&self,
|
|
||||||
dt: chrono::DateTime<Utc>,
|
|
||||||
now_utc: chrono::DateTime<Utc>,
|
|
||||||
) -> Option<DateTime<Utc>> {
|
|
||||||
let local_date = dt.with_timezone(&self.timezone).date_naive();
|
|
||||||
self.normalize_naive(local_date, now_utc)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_valid_with_now(&self, date: NaiveDate, now_utc: chrono::DateTime<Utc>) -> bool {
|
|
||||||
if date < self.min_date {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
let now_local = now_utc.with_timezone(&self.timezone).date_naive();
|
|
||||||
if date > now_local {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
!self.ignore_dates.contains(&date)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1
-3
@@ -18,9 +18,7 @@ pub const STATUS_FAILED: &str = "failed";
|
|||||||
pub const JOB_ANALYZE_DOCUMENT: &str = "analyze-document";
|
pub const JOB_ANALYZE_DOCUMENT: &str = "analyze-document";
|
||||||
pub const JOB_GENERATE_THUMBNAILS: &str = "generate-thumbnails";
|
pub const JOB_GENERATE_THUMBNAILS: &str = "generate-thumbnails";
|
||||||
pub const JOB_GENERATE_OCR_TEXT: &str = "generate-ocr-text";
|
pub const JOB_GENERATE_OCR_TEXT: &str = "generate-ocr-text";
|
||||||
pub const JOB_PROVISION_TENANT: &str = "provision-tenant";
|
pub const JOB_INDEX_DOCUMENT_TEXT: &str = "index-document-text";
|
||||||
pub const JOB_PURGE_DOCUMENT: &str = "purge-document";
|
|
||||||
pub const JOB_DELETE_TENANT: &str = "delete-tenant";
|
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum JobQueueError {
|
pub enum JobQueueError {
|
||||||
|
|||||||
@@ -3,20 +3,15 @@ pub mod config;
|
|||||||
pub mod db;
|
pub mod db;
|
||||||
pub mod documents;
|
pub mod documents;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod http;
|
|
||||||
pub mod issued_at;
|
|
||||||
pub mod jobs;
|
pub mod jobs;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
pub mod openapi;
|
pub mod openapi;
|
||||||
pub mod routes;
|
pub mod routes;
|
||||||
pub mod s3;
|
pub mod s3;
|
||||||
pub mod schema;
|
pub mod schema;
|
||||||
pub mod services;
|
|
||||||
pub mod state;
|
pub mod state;
|
||||||
pub mod storage;
|
pub mod storage;
|
||||||
pub mod tenants;
|
pub mod tenants;
|
||||||
pub mod utils;
|
pub mod utils;
|
||||||
pub mod workers;
|
pub mod workers;
|
||||||
pub use workers::{default_handlers, Worker};
|
pub use workers::{default_handlers, Worker};
|
||||||
pub mod migrations;
|
|
||||||
pub mod test_support;
|
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ use std::net::SocketAddr;
|
|||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use tower::make::Shared;
|
use tower::make::Shared;
|
||||||
|
|
||||||
use papercrate::{routes, utils::bootstrap::init_component};
|
use backend::{routes, utils::bootstrap::init_component};
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> anyhow::Result<()> {
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
use diesel_migrations::{embed_migrations, EmbeddedMigrations};
|
|
||||||
|
|
||||||
pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
|
|
||||||
+39
-488
@@ -1,22 +1,7 @@
|
|||||||
use chrono::NaiveDateTime;
|
use chrono::NaiveDateTime;
|
||||||
use diesel::deserialize::FromSql;
|
|
||||||
use diesel::pg::{Pg, PgValue};
|
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
use diesel::serialize::{IsNull, Output, ToSql};
|
|
||||||
use diesel::{deserialize, serialize, AsExpression, FromSqlRow};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use serde_json::Value;
|
|
||||||
use std::fmt;
|
|
||||||
use std::io::Write;
|
|
||||||
use std::str;
|
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use utoipa::ToSchema;
|
|
||||||
|
|
||||||
use crate::schema::sql_types::{
|
|
||||||
ApiCapability as ApiCapabilitySql, MagicTokenKind as MagicTokenKindSql,
|
|
||||||
TenantStatus as TenantStatusSql,
|
|
||||||
};
|
|
||||||
use crate::schema::*;
|
use crate::schema::*;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
||||||
@@ -27,9 +12,9 @@ pub struct UserMembership {
|
|||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub user_id: Uuid,
|
pub user_id: Uuid,
|
||||||
pub tenant_id: Uuid,
|
pub tenant_id: Uuid,
|
||||||
|
pub role: String,
|
||||||
pub created_at: NaiveDateTime,
|
pub created_at: NaiveDateTime,
|
||||||
pub updated_at: NaiveDateTime,
|
pub updated_at: NaiveDateTime,
|
||||||
pub capability_set_id: Option<Uuid>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Insertable)]
|
#[derive(Debug, Insertable)]
|
||||||
@@ -38,319 +23,7 @@ pub struct NewUserMembership {
|
|||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub user_id: Uuid,
|
pub user_id: Uuid,
|
||||||
pub tenant_id: Uuid,
|
pub tenant_id: Uuid,
|
||||||
pub capability_set_id: Option<Uuid>,
|
pub role: String,
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, AsExpression, FromSqlRow)]
|
|
||||||
#[diesel(sql_type = TenantStatusSql)]
|
|
||||||
pub enum TenantStatus {
|
|
||||||
Creating,
|
|
||||||
Active,
|
|
||||||
Suspended,
|
|
||||||
Deleting,
|
|
||||||
Error,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, AsExpression, FromSqlRow)]
|
|
||||||
#[diesel(sql_type = MagicTokenKindSql)]
|
|
||||||
pub enum MagicTokenKind {
|
|
||||||
EmailLogin,
|
|
||||||
DemoLogin,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(
|
|
||||||
Debug,
|
|
||||||
Clone,
|
|
||||||
Copy,
|
|
||||||
PartialEq,
|
|
||||||
Eq,
|
|
||||||
Hash,
|
|
||||||
AsExpression,
|
|
||||||
FromSqlRow,
|
|
||||||
Serialize,
|
|
||||||
Deserialize,
|
|
||||||
ToSchema,
|
|
||||||
)]
|
|
||||||
#[diesel(sql_type = ApiCapabilitySql)]
|
|
||||||
pub enum ApiCapability {
|
|
||||||
#[serde(rename = "documents:read")]
|
|
||||||
DocumentsRead,
|
|
||||||
#[serde(rename = "documents:edit")]
|
|
||||||
DocumentsEdit,
|
|
||||||
#[serde(rename = "documents:write")]
|
|
||||||
DocumentsWrite,
|
|
||||||
#[serde(rename = "documents:upload")]
|
|
||||||
DocumentsUpload,
|
|
||||||
#[serde(rename = "folders:read")]
|
|
||||||
FoldersRead,
|
|
||||||
#[serde(rename = "folders:edit")]
|
|
||||||
FoldersEdit,
|
|
||||||
#[serde(rename = "folders:write")]
|
|
||||||
FoldersWrite,
|
|
||||||
#[serde(rename = "tags:read")]
|
|
||||||
TagsRead,
|
|
||||||
#[serde(rename = "tags:edit")]
|
|
||||||
TagsEdit,
|
|
||||||
#[serde(rename = "tags:write")]
|
|
||||||
TagsWrite,
|
|
||||||
#[serde(rename = "correspondents:read")]
|
|
||||||
CorrespondentsRead,
|
|
||||||
#[serde(rename = "correspondents:edit")]
|
|
||||||
CorrespondentsEdit,
|
|
||||||
#[serde(rename = "correspondents:write")]
|
|
||||||
CorrespondentsWrite,
|
|
||||||
#[serde(rename = "profile:read")]
|
|
||||||
ProfileRead,
|
|
||||||
#[serde(rename = "profile:write")]
|
|
||||||
ProfileWrite,
|
|
||||||
#[serde(rename = "webdav:read")]
|
|
||||||
WebdavRead,
|
|
||||||
#[serde(rename = "webdav:write")]
|
|
||||||
WebdavWrite,
|
|
||||||
#[serde(rename = "capability_sets:read")]
|
|
||||||
CapabilitySetsRead,
|
|
||||||
#[serde(rename = "capability_sets:write")]
|
|
||||||
CapabilitySetsWrite,
|
|
||||||
#[serde(rename = "tenants:write")]
|
|
||||||
TenantsWrite,
|
|
||||||
#[serde(rename = "tenants:reset")]
|
|
||||||
TenantsReset,
|
|
||||||
#[serde(rename = "tenants:delete")]
|
|
||||||
TenantsDelete,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MagicTokenKind {
|
|
||||||
pub fn as_str(&self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
MagicTokenKind::EmailLogin => "email_login",
|
|
||||||
MagicTokenKind::DemoLogin => "demo_login",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn variants() -> &'static [&'static str] {
|
|
||||||
&["email_login", "demo_login"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ApiCapability {
|
|
||||||
pub fn as_str(&self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
ApiCapability::DocumentsRead => "documents:read",
|
|
||||||
ApiCapability::DocumentsEdit => "documents:edit",
|
|
||||||
ApiCapability::DocumentsWrite => "documents:write",
|
|
||||||
ApiCapability::DocumentsUpload => "documents:upload",
|
|
||||||
ApiCapability::FoldersRead => "folders:read",
|
|
||||||
ApiCapability::FoldersEdit => "folders:edit",
|
|
||||||
ApiCapability::FoldersWrite => "folders:write",
|
|
||||||
ApiCapability::TagsRead => "tags:read",
|
|
||||||
ApiCapability::TagsEdit => "tags:edit",
|
|
||||||
ApiCapability::TagsWrite => "tags:write",
|
|
||||||
ApiCapability::CorrespondentsRead => "correspondents:read",
|
|
||||||
ApiCapability::CorrespondentsEdit => "correspondents:edit",
|
|
||||||
ApiCapability::CorrespondentsWrite => "correspondents:write",
|
|
||||||
ApiCapability::ProfileRead => "profile:read",
|
|
||||||
ApiCapability::ProfileWrite => "profile:write",
|
|
||||||
ApiCapability::WebdavRead => "webdav:read",
|
|
||||||
ApiCapability::WebdavWrite => "webdav:write",
|
|
||||||
ApiCapability::CapabilitySetsRead => "capability_sets:read",
|
|
||||||
ApiCapability::CapabilitySetsWrite => "capability_sets:write",
|
|
||||||
ApiCapability::TenantsWrite => "tenants:write",
|
|
||||||
ApiCapability::TenantsReset => "tenants:reset",
|
|
||||||
ApiCapability::TenantsDelete => "tenants:delete",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn variants() -> &'static [&'static str] {
|
|
||||||
&[
|
|
||||||
"documents:read",
|
|
||||||
"documents:edit",
|
|
||||||
"documents:write",
|
|
||||||
"documents:upload",
|
|
||||||
"folders:read",
|
|
||||||
"folders:edit",
|
|
||||||
"folders:write",
|
|
||||||
"tags:read",
|
|
||||||
"tags:edit",
|
|
||||||
"tags:write",
|
|
||||||
"correspondents:read",
|
|
||||||
"correspondents:edit",
|
|
||||||
"correspondents:write",
|
|
||||||
"profile:read",
|
|
||||||
"profile:write",
|
|
||||||
"webdav:read",
|
|
||||||
"webdav:write",
|
|
||||||
"capability_sets:read",
|
|
||||||
"capability_sets:write",
|
|
||||||
"tenants:write",
|
|
||||||
"tenants:reset",
|
|
||||||
"tenants:delete",
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for MagicTokenKind {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
write!(f, "{}", self.as_str())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for ApiCapability {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
write!(f, "{}", self.as_str())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ToSql<MagicTokenKindSql, Pg> for MagicTokenKind {
|
|
||||||
fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result {
|
|
||||||
out.write_all(self.as_str().as_bytes())?;
|
|
||||||
Ok(IsNull::No)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ToSql<ApiCapabilitySql, Pg> for ApiCapability {
|
|
||||||
fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result {
|
|
||||||
out.write_all(self.as_str().as_bytes())?;
|
|
||||||
Ok(IsNull::No)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FromSql<MagicTokenKindSql, Pg> for MagicTokenKind {
|
|
||||||
fn from_sql(bytes: PgValue<'_>) -> deserialize::Result<Self> {
|
|
||||||
match std::str::from_utf8(bytes.as_bytes())? {
|
|
||||||
"email_login" => Ok(MagicTokenKind::EmailLogin),
|
|
||||||
"demo_login" => Ok(MagicTokenKind::DemoLogin),
|
|
||||||
other => Err(Box::new(std::io::Error::new(
|
|
||||||
std::io::ErrorKind::InvalidData,
|
|
||||||
format!("invalid magic_token_kind '{other}'"),
|
|
||||||
))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FromSql<ApiCapabilitySql, Pg> for ApiCapability {
|
|
||||||
fn from_sql(bytes: PgValue<'_>) -> deserialize::Result<Self> {
|
|
||||||
match std::str::from_utf8(bytes.as_bytes())? {
|
|
||||||
"documents:read" => Ok(ApiCapability::DocumentsRead),
|
|
||||||
"documents:edit" => Ok(ApiCapability::DocumentsEdit),
|
|
||||||
"documents:write" => Ok(ApiCapability::DocumentsWrite),
|
|
||||||
"documents:upload" => Ok(ApiCapability::DocumentsUpload),
|
|
||||||
"folders:read" => Ok(ApiCapability::FoldersRead),
|
|
||||||
"folders:edit" => Ok(ApiCapability::FoldersEdit),
|
|
||||||
"folders:write" => Ok(ApiCapability::FoldersWrite),
|
|
||||||
"tags:read" => Ok(ApiCapability::TagsRead),
|
|
||||||
"tags:edit" => Ok(ApiCapability::TagsEdit),
|
|
||||||
"tags:write" => Ok(ApiCapability::TagsWrite),
|
|
||||||
"correspondents:read" => Ok(ApiCapability::CorrespondentsRead),
|
|
||||||
"correspondents:edit" => Ok(ApiCapability::CorrespondentsEdit),
|
|
||||||
"correspondents:write" => Ok(ApiCapability::CorrespondentsWrite),
|
|
||||||
"profile:read" => Ok(ApiCapability::ProfileRead),
|
|
||||||
"profile:write" => Ok(ApiCapability::ProfileWrite),
|
|
||||||
"webdav:read" => Ok(ApiCapability::WebdavRead),
|
|
||||||
"webdav:write" => Ok(ApiCapability::WebdavWrite),
|
|
||||||
"capability_sets:read" => Ok(ApiCapability::CapabilitySetsRead),
|
|
||||||
"capability_sets:write" => Ok(ApiCapability::CapabilitySetsWrite),
|
|
||||||
"tenants:write" => Ok(ApiCapability::TenantsWrite),
|
|
||||||
"tenants:reset" => Ok(ApiCapability::TenantsReset),
|
|
||||||
"tenants:delete" => Ok(ApiCapability::TenantsDelete),
|
|
||||||
other => Err(Box::new(std::io::Error::new(
|
|
||||||
std::io::ErrorKind::InvalidData,
|
|
||||||
format!("invalid api_capability '{other}'"),
|
|
||||||
))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl str::FromStr for MagicTokenKind {
|
|
||||||
type Err = &'static str;
|
|
||||||
|
|
||||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
|
||||||
match value {
|
|
||||||
"email_login" => Ok(MagicTokenKind::EmailLogin),
|
|
||||||
"demo_login" => Ok(MagicTokenKind::DemoLogin),
|
|
||||||
_ => Err("unsupported magic token kind"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl str::FromStr for ApiCapability {
|
|
||||||
type Err = &'static str;
|
|
||||||
|
|
||||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
|
||||||
match value {
|
|
||||||
"documents:read" => Ok(ApiCapability::DocumentsRead),
|
|
||||||
"documents:edit" => Ok(ApiCapability::DocumentsEdit),
|
|
||||||
"documents:write" => Ok(ApiCapability::DocumentsWrite),
|
|
||||||
"documents:upload" => Ok(ApiCapability::DocumentsUpload),
|
|
||||||
"folders:read" => Ok(ApiCapability::FoldersRead),
|
|
||||||
"folders:edit" => Ok(ApiCapability::FoldersEdit),
|
|
||||||
"folders:write" => Ok(ApiCapability::FoldersWrite),
|
|
||||||
"tags:read" => Ok(ApiCapability::TagsRead),
|
|
||||||
"tags:edit" => Ok(ApiCapability::TagsEdit),
|
|
||||||
"tags:write" => Ok(ApiCapability::TagsWrite),
|
|
||||||
"correspondents:read" => Ok(ApiCapability::CorrespondentsRead),
|
|
||||||
"correspondents:edit" => Ok(ApiCapability::CorrespondentsEdit),
|
|
||||||
"correspondents:write" => Ok(ApiCapability::CorrespondentsWrite),
|
|
||||||
"profile:read" => Ok(ApiCapability::ProfileRead),
|
|
||||||
"profile:write" => Ok(ApiCapability::ProfileWrite),
|
|
||||||
"webdav:read" => Ok(ApiCapability::WebdavRead),
|
|
||||||
"webdav:write" => Ok(ApiCapability::WebdavWrite),
|
|
||||||
"capability_sets:read" => Ok(ApiCapability::CapabilitySetsRead),
|
|
||||||
"capability_sets:write" => Ok(ApiCapability::CapabilitySetsWrite),
|
|
||||||
"tenants:write" => Ok(ApiCapability::TenantsWrite),
|
|
||||||
"tenants:reset" => Ok(ApiCapability::TenantsReset),
|
|
||||||
"tenants:delete" => Ok(ApiCapability::TenantsDelete),
|
|
||||||
_ => Err("unsupported api capability"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TenantStatus {
|
|
||||||
pub fn as_str(&self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
TenantStatus::Creating => "creating",
|
|
||||||
TenantStatus::Active => "active",
|
|
||||||
TenantStatus::Suspended => "suspended",
|
|
||||||
TenantStatus::Deleting => "deleting",
|
|
||||||
TenantStatus::Error => "error",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn from_str(value: &str) -> Option<Self> {
|
|
||||||
match value {
|
|
||||||
"creating" => Some(TenantStatus::Creating),
|
|
||||||
"active" => Some(TenantStatus::Active),
|
|
||||||
"suspended" => Some(TenantStatus::Suspended),
|
|
||||||
"deleting" => Some(TenantStatus::Deleting),
|
|
||||||
"error" => Some(TenantStatus::Error),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for TenantStatus {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
write!(f, "{}", self.as_str())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ToSql<TenantStatusSql, Pg> for TenantStatus {
|
|
||||||
fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result {
|
|
||||||
out.write_all(self.as_str().as_bytes())?;
|
|
||||||
Ok(IsNull::No)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FromSql<TenantStatusSql, Pg> for TenantStatus {
|
|
||||||
fn from_sql(bytes: PgValue<'_>) -> deserialize::Result<Self> {
|
|
||||||
let value = str::from_utf8(bytes.as_bytes())
|
|
||||||
.map_err(|err| Box::<dyn std::error::Error + Send + Sync>::from(err))?;
|
|
||||||
TenantStatus::from_str(value).ok_or_else(|| {
|
|
||||||
Box::<dyn std::error::Error + Send + Sync>::from(std::io::Error::new(
|
|
||||||
std::io::ErrorKind::InvalidData,
|
|
||||||
format!("invalid tenant status '{value}'"),
|
|
||||||
))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Queryable, Identifiable)]
|
#[derive(Debug, Clone, Queryable, Identifiable)]
|
||||||
@@ -358,14 +31,13 @@ impl FromSql<TenantStatusSql, Pg> for TenantStatus {
|
|||||||
#[diesel(primary_key(id))]
|
#[diesel(primary_key(id))]
|
||||||
pub struct Tenant {
|
pub struct Tenant {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub name: String,
|
pub slug: String,
|
||||||
pub storage_root: Option<String>,
|
pub storage_root: Option<String>,
|
||||||
pub quickwit_index: Option<String>,
|
pub quickwit_index: Option<String>,
|
||||||
pub config: Value,
|
pub status: String,
|
||||||
|
pub config: serde_json::Value,
|
||||||
pub created_at: NaiveDateTime,
|
pub created_at: NaiveDateTime,
|
||||||
pub updated_at: NaiveDateTime,
|
pub updated_at: NaiveDateTime,
|
||||||
pub status: TenantStatus,
|
|
||||||
pub created_by: Option<Uuid>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Queryable, Identifiable)]
|
#[derive(Debug, Clone, Queryable, Identifiable)]
|
||||||
@@ -373,6 +45,7 @@ pub struct Tenant {
|
|||||||
pub struct User {
|
pub struct User {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
|
pub password_hash: String,
|
||||||
pub created_at: NaiveDateTime,
|
pub created_at: NaiveDateTime,
|
||||||
pub updated_at: NaiveDateTime,
|
pub updated_at: NaiveDateTime,
|
||||||
}
|
}
|
||||||
@@ -382,135 +55,7 @@ pub struct User {
|
|||||||
pub struct NewUser {
|
pub struct NewUser {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
}
|
pub password_hash: String,
|
||||||
|
|
||||||
#[derive(Debug, Clone, Queryable, Identifiable, Associations, Selectable)]
|
|
||||||
#[diesel(table_name = user_passkeys)]
|
|
||||||
#[diesel(belongs_to(User))]
|
|
||||||
pub struct UserPasskey {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub user_id: Uuid,
|
|
||||||
pub credential_id: Vec<u8>,
|
|
||||||
pub public_key: Vec<u8>,
|
|
||||||
pub credential: serde_json::Value,
|
|
||||||
pub sign_count: i64,
|
|
||||||
pub transports: Vec<Option<String>>,
|
|
||||||
pub aaguid: Option<Uuid>,
|
|
||||||
pub nickname: Option<String>,
|
|
||||||
pub created_at: NaiveDateTime,
|
|
||||||
pub updated_at: NaiveDateTime,
|
|
||||||
pub last_used_at: Option<NaiveDateTime>,
|
|
||||||
pub revoked_at: Option<NaiveDateTime>,
|
|
||||||
pub revoked_by: Option<Uuid>,
|
|
||||||
pub revoked_reason: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Insertable)]
|
|
||||||
#[diesel(table_name = user_passkeys)]
|
|
||||||
pub struct NewUserPasskey {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub user_id: Uuid,
|
|
||||||
pub credential_id: Vec<u8>,
|
|
||||||
pub public_key: Vec<u8>,
|
|
||||||
pub credential: serde_json::Value,
|
|
||||||
pub sign_count: i64,
|
|
||||||
pub transports: Vec<Option<String>>,
|
|
||||||
pub aaguid: Option<Uuid>,
|
|
||||||
pub nickname: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
|
||||||
#[diesel(table_name = webauthn_challenges)]
|
|
||||||
#[diesel(belongs_to(User))]
|
|
||||||
pub struct WebauthnChallenge {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub user_id: Option<Uuid>,
|
|
||||||
pub purpose: String,
|
|
||||||
pub challenge: Vec<u8>,
|
|
||||||
pub state: Vec<u8>,
|
|
||||||
pub created_at: NaiveDateTime,
|
|
||||||
pub expires_at: NaiveDateTime,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Insertable)]
|
|
||||||
#[diesel(table_name = webauthn_challenges)]
|
|
||||||
pub struct NewWebauthnChallenge {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub user_id: Option<Uuid>,
|
|
||||||
pub purpose: String,
|
|
||||||
pub challenge: Vec<u8>,
|
|
||||||
pub state: Vec<u8>,
|
|
||||||
pub expires_at: NaiveDateTime,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
|
||||||
#[diesel(table_name = api_tokens)]
|
|
||||||
#[diesel(belongs_to(User))]
|
|
||||||
#[diesel(belongs_to(Tenant))]
|
|
||||||
pub struct ApiToken {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub user_id: Uuid,
|
|
||||||
pub tenant_id: Uuid,
|
|
||||||
pub token_prefix: String,
|
|
||||||
pub token_hash: String,
|
|
||||||
pub label: Option<String>,
|
|
||||||
pub created_at: NaiveDateTime,
|
|
||||||
pub last_used_at: Option<NaiveDateTime>,
|
|
||||||
pub expires_at: Option<NaiveDateTime>,
|
|
||||||
pub revoked_at: Option<NaiveDateTime>,
|
|
||||||
pub capability_set_id: Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
|
||||||
#[diesel(table_name = capability_sets)]
|
|
||||||
#[diesel(belongs_to(Tenant))]
|
|
||||||
pub struct CapabilitySet {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub tenant_id: Uuid,
|
|
||||||
pub slug: String,
|
|
||||||
pub cap_version: i32,
|
|
||||||
pub is_system: bool,
|
|
||||||
pub created_at: NaiveDateTime,
|
|
||||||
pub updated_at: NaiveDateTime,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Insertable)]
|
|
||||||
#[diesel(table_name = capability_sets)]
|
|
||||||
pub struct NewCapabilitySet {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub tenant_id: Uuid,
|
|
||||||
pub slug: String,
|
|
||||||
pub cap_version: i32,
|
|
||||||
pub is_system: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
|
||||||
#[diesel(table_name = capability_set_capabilities)]
|
|
||||||
#[diesel(primary_key(capability_set_id, capability))]
|
|
||||||
#[diesel(belongs_to(CapabilitySet, foreign_key = capability_set_id))]
|
|
||||||
pub struct CapabilitySetCapability {
|
|
||||||
pub capability_set_id: Uuid,
|
|
||||||
pub capability: ApiCapability,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Insertable)]
|
|
||||||
#[diesel(table_name = capability_set_capabilities)]
|
|
||||||
pub struct NewCapabilitySetCapability {
|
|
||||||
pub capability_set_id: Uuid,
|
|
||||||
pub capability: ApiCapability,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Insertable)]
|
|
||||||
#[diesel(table_name = api_tokens)]
|
|
||||||
pub struct NewApiToken {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub user_id: Uuid,
|
|
||||||
pub tenant_id: Uuid,
|
|
||||||
pub token_prefix: String,
|
|
||||||
pub token_hash: String,
|
|
||||||
pub label: Option<String>,
|
|
||||||
pub expires_at: Option<NaiveDateTime>,
|
|
||||||
pub capability_set_id: Uuid,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Queryable, Identifiable)]
|
#[derive(Debug, Clone, Queryable, Identifiable)]
|
||||||
@@ -540,9 +85,9 @@ pub struct Document {
|
|||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub filename: String,
|
pub filename: String,
|
||||||
pub original_name: String,
|
pub original_name: String,
|
||||||
pub mime_type: Option<String>,
|
pub content_type: Option<String>,
|
||||||
pub folder_id: Option<Uuid>,
|
pub folder_id: Option<Uuid>,
|
||||||
pub created_at: NaiveDateTime,
|
pub uploaded_at: NaiveDateTime,
|
||||||
pub updated_at: NaiveDateTime,
|
pub updated_at: NaiveDateTime,
|
||||||
pub deleted_at: Option<NaiveDateTime>,
|
pub deleted_at: Option<NaiveDateTime>,
|
||||||
pub metadata: serde_json::Value,
|
pub metadata: serde_json::Value,
|
||||||
@@ -558,7 +103,7 @@ pub struct NewDocument {
|
|||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub filename: String,
|
pub filename: String,
|
||||||
pub original_name: String,
|
pub original_name: String,
|
||||||
pub mime_type: Option<String>,
|
pub content_type: Option<String>,
|
||||||
pub folder_id: Option<Uuid>,
|
pub folder_id: Option<Uuid>,
|
||||||
pub current_version_id: Uuid,
|
pub current_version_id: Uuid,
|
||||||
pub metadata: serde_json::Value,
|
pub metadata: serde_json::Value,
|
||||||
@@ -567,22 +112,6 @@ pub struct NewDocument {
|
|||||||
pub tenant_id: Uuid,
|
pub tenant_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Queryable, Identifiable, Insertable)]
|
|
||||||
#[diesel(table_name = magic_tokens)]
|
|
||||||
pub struct MagicToken {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub user_id: Uuid,
|
|
||||||
pub kind: MagicTokenKind,
|
|
||||||
pub token_hash: String,
|
|
||||||
pub metadata: serde_json::Value,
|
|
||||||
pub expires_at: NaiveDateTime,
|
|
||||||
pub max_uses: Option<i32>,
|
|
||||||
pub used_count: i32,
|
|
||||||
pub created_at: NaiveDateTime,
|
|
||||||
pub created_by: Option<Uuid>,
|
|
||||||
pub last_used_at: Option<NaiveDateTime>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
||||||
#[diesel(table_name = document_versions)]
|
#[diesel(table_name = document_versions)]
|
||||||
#[diesel(belongs_to(Document))]
|
#[diesel(belongs_to(Document))]
|
||||||
@@ -621,7 +150,7 @@ pub struct DocumentAsset {
|
|||||||
pub mime_type: String,
|
pub mime_type: String,
|
||||||
pub metadata: serde_json::Value,
|
pub metadata: serde_json::Value,
|
||||||
pub created_at: NaiveDateTime,
|
pub created_at: NaiveDateTime,
|
||||||
pub s3_key: String,
|
pub cardinality: Option<i32>,
|
||||||
pub tenant_id: Uuid,
|
pub tenant_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -633,7 +162,30 @@ pub struct NewDocumentAsset {
|
|||||||
pub asset_type: String,
|
pub asset_type: String,
|
||||||
pub mime_type: String,
|
pub mime_type: String,
|
||||||
pub metadata: serde_json::Value,
|
pub metadata: serde_json::Value,
|
||||||
|
pub cardinality: Option<i32>,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
||||||
|
#[diesel(table_name = document_asset_objects)]
|
||||||
|
#[diesel(belongs_to(DocumentAsset, foreign_key = asset_id))]
|
||||||
|
pub struct DocumentAssetObject {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub asset_id: Uuid,
|
||||||
|
pub ordinal: i32,
|
||||||
pub s3_key: String,
|
pub s3_key: String,
|
||||||
|
pub metadata: serde_json::Value,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Insertable)]
|
||||||
|
#[diesel(table_name = document_asset_objects)]
|
||||||
|
pub struct NewDocumentAssetObject {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub asset_id: Uuid,
|
||||||
|
pub ordinal: i32,
|
||||||
|
pub s3_key: String,
|
||||||
|
pub metadata: serde_json::Value,
|
||||||
pub tenant_id: Uuid,
|
pub tenant_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -649,8 +201,7 @@ pub struct Job {
|
|||||||
pub last_error: Option<String>,
|
pub last_error: Option<String>,
|
||||||
pub created_at: NaiveDateTime,
|
pub created_at: NaiveDateTime,
|
||||||
pub updated_at: NaiveDateTime,
|
pub updated_at: NaiveDateTime,
|
||||||
pub tenant_id: Option<Uuid>,
|
pub tenant_id: Uuid,
|
||||||
pub result: Option<serde_json::Value>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Insertable)]
|
#[derive(Debug, Insertable)]
|
||||||
@@ -749,9 +300,9 @@ pub struct NewDocumentCorrespondent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
||||||
#[diesel(table_name = user_sessions)]
|
#[diesel(table_name = refresh_tokens)]
|
||||||
#[diesel(belongs_to(User))]
|
#[diesel(belongs_to(User))]
|
||||||
pub struct UserSession {
|
pub struct RefreshToken {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub user_id: Uuid,
|
pub user_id: Uuid,
|
||||||
pub token_hash: String,
|
pub token_hash: String,
|
||||||
@@ -764,8 +315,8 @@ pub struct UserSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Insertable)]
|
#[derive(Debug, Insertable)]
|
||||||
#[diesel(table_name = user_sessions)]
|
#[diesel(table_name = refresh_tokens)]
|
||||||
pub struct NewUserSession {
|
pub struct NewRefreshToken {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub user_id: Uuid,
|
pub user_id: Uuid,
|
||||||
pub token_hash: String,
|
pub token_hash: String,
|
||||||
|
|||||||
+985
-116
File diff suppressed because it is too large
Load Diff
+357
-206
@@ -1,260 +1,411 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
extract::State,
|
extract::State,
|
||||||
http::{HeaderMap, StatusCode},
|
http::{header::SET_COOKIE, HeaderMap, HeaderValue, StatusCode},
|
||||||
response::Response,
|
response::{IntoResponse, Response},
|
||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
use axum_extra::{
|
use axum_extra::{
|
||||||
headers::{authorization::Bearer, Authorization, Cookie},
|
headers::{authorization::Bearer, Authorization, Cookie},
|
||||||
typed_header::TypedHeader,
|
typed_header::TypedHeader,
|
||||||
};
|
};
|
||||||
use utoipa::OpenApi;
|
use chrono::{Duration as ChronoDuration, Utc};
|
||||||
|
use diesel::{pg::PgConnection, prelude::*};
|
||||||
|
use rand::{rngs::OsRng, RngCore};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
auth::{
|
auth::{password, AuthenticatedUser},
|
||||||
passkeys::{
|
|
||||||
AuthenticationChallengeResponse, PasskeyLoginFinishPayload, PasskeyLoginStartPayload,
|
|
||||||
PasskeyRegistrationFinishPayload, PasskeySummary, RegistrationChallengeResponse,
|
|
||||||
},
|
|
||||||
AuthenticatedUser, TenantScopedConn,
|
|
||||||
},
|
|
||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
http::responders::JsonResponse,
|
models::{NewRefreshToken, RefreshToken, Tenant, User, UserMembership},
|
||||||
services::auth::{
|
schema::{
|
||||||
ApiTokenExchangeRequest, AuthService, LoginRequest, LoginResponse, LoginResponseVariants,
|
refresh_tokens, tenants::dsl as tenant_dsl, user_memberships::dsl as memberships_dsl,
|
||||||
SignupFinishRequest, SignupStartRequest, SignupStartResponse, TenantListResponse,
|
users::dsl,
|
||||||
TenantSelectionRequest, TenantSelectionResponse, TenantSnippet, SESSION_COOKIE_NAME,
|
|
||||||
},
|
},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(OpenApi)]
|
use crate::schema::refresh_tokens::dsl as refresh_dsl;
|
||||||
#[openapi(
|
|
||||||
paths(
|
const REFRESH_COOKIE_NAME: &str = "refresh_token";
|
||||||
login,
|
|
||||||
api_token_exchange,
|
#[derive(Deserialize)]
|
||||||
signup_start,
|
pub struct LoginRequest {
|
||||||
signup_finish,
|
pub username: String,
|
||||||
refresh,
|
pub password: String,
|
||||||
logout,
|
#[serde(default)]
|
||||||
me,
|
pub preferred_tenant_slug: Option<String>,
|
||||||
select_tenant,
|
}
|
||||||
passkey_register_start,
|
|
||||||
passkey_register_finish,
|
#[derive(Serialize)]
|
||||||
passkey_login_start,
|
pub struct LoginResponse {
|
||||||
passkey_login_finish,
|
pub access_token: String,
|
||||||
),
|
pub token_type: String,
|
||||||
components(schemas(
|
pub expires_in: i64,
|
||||||
LoginRequest,
|
pub tenant: TenantSnippet,
|
||||||
ApiTokenExchangeRequest,
|
}
|
||||||
SignupStartRequest,
|
|
||||||
SignupStartResponse,
|
#[derive(Serialize)]
|
||||||
SignupFinishRequest,
|
pub struct TenantSummary {
|
||||||
LoginResponse,
|
pub tenant_id: Uuid,
|
||||||
LoginResponseVariants,
|
pub slug: String,
|
||||||
TenantSnippet,
|
}
|
||||||
TenantSelectionResponse,
|
|
||||||
TenantSelectionRequest,
|
#[derive(Serialize)]
|
||||||
TenantListResponse,
|
pub struct TenantSnippet {
|
||||||
crate::auth::AuthenticatedUser,
|
pub id: Uuid,
|
||||||
crate::auth::passkeys::RegistrationChallengeResponse,
|
pub slug: String,
|
||||||
crate::auth::passkeys::AuthenticationChallengeResponse,
|
}
|
||||||
crate::auth::passkeys::PasskeySummary,
|
|
||||||
crate::auth::passkeys::PasskeyRegistrationFinishPayload,
|
#[derive(Serialize)]
|
||||||
crate::auth::passkeys::PasskeyLoginStartPayload,
|
pub struct TenantSelectionResponse {
|
||||||
crate::auth::passkeys::PasskeyLoginFinishPayload,
|
pub access_token: String,
|
||||||
crate::models::ApiCapability,
|
pub tenants: Vec<TenantSummary>,
|
||||||
))
|
}
|
||||||
)]
|
|
||||||
pub struct AuthApiDoc;
|
#[derive(Serialize)]
|
||||||
|
pub struct TenantListResponse {
|
||||||
|
pub tenants: Vec<TenantSnippet>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct TenantSelectionRequest {
|
||||||
|
pub tenant_id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/auth/login",
|
|
||||||
request_body = LoginRequest,
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Login succeeded", body = LoginResponseVariants),
|
|
||||||
(status = 401, description = "Invalid credentials")
|
|
||||||
),
|
|
||||||
tag = "Auth"
|
|
||||||
)]
|
|
||||||
pub async fn login(
|
pub async fn login(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(payload): Json<LoginRequest>,
|
Json(payload): Json<LoginRequest>,
|
||||||
) -> AppResult<Response> {
|
) -> AppResult<Response> {
|
||||||
AuthService::new(&state).login(payload)
|
let mut conn = state.db_unscoped()?;
|
||||||
|
|
||||||
|
let user: Option<User> = dsl::users
|
||||||
|
.filter(dsl::username.eq(&payload.username))
|
||||||
|
.first(&mut conn)
|
||||||
|
.optional()?;
|
||||||
|
|
||||||
|
let user = match user {
|
||||||
|
Some(user) => user,
|
||||||
|
None => return Err(AppError::unauthorized()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let valid = password::verify_password(&payload.password, &user.password_hash)
|
||||||
|
.map_err(|_| AppError::unauthorized())?;
|
||||||
|
|
||||||
|
if !valid {
|
||||||
|
return Err(AppError::unauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
let memberships: Vec<(UserMembership, Tenant)> = memberships_dsl::user_memberships
|
||||||
|
.inner_join(tenant_dsl::tenants)
|
||||||
|
.filter(memberships_dsl::user_id.eq(user.id))
|
||||||
|
.load(&mut conn)?;
|
||||||
|
|
||||||
|
if memberships.is_empty() {
|
||||||
|
return Err(AppError::unauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
let preferred_slug = payload
|
||||||
|
.preferred_tenant_slug
|
||||||
|
.as_ref()
|
||||||
|
.map(|slug| slug.trim().to_string())
|
||||||
|
.filter(|slug| !slug.is_empty());
|
||||||
|
|
||||||
|
if let Some(tenant) = preferred_slug.as_ref().and_then(|slug| {
|
||||||
|
memberships
|
||||||
|
.iter()
|
||||||
|
.find(|(_, tenant)| tenant.slug.eq_ignore_ascii_case(slug))
|
||||||
|
}) {
|
||||||
|
return issue_session(&state, &mut conn, &user, tenant.1.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if memberships.len() == 1 {
|
||||||
|
let tenant_id = memberships[0].1.id;
|
||||||
|
return issue_session(&state, &mut conn, &user, tenant_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
let selection_token = state
|
||||||
|
.jwt
|
||||||
|
.generate_tenant_selector_token(user.id)
|
||||||
|
.map_err(AppError::from)?;
|
||||||
|
|
||||||
|
let tenants = memberships
|
||||||
|
.into_iter()
|
||||||
|
.map(|(_, tenant)| TenantSummary {
|
||||||
|
tenant_id: tenant.id,
|
||||||
|
slug: tenant.slug,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let response = Json(TenantSelectionResponse {
|
||||||
|
access_token: selection_token,
|
||||||
|
tenants,
|
||||||
|
})
|
||||||
|
.into_response();
|
||||||
|
|
||||||
|
Ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/auth/exchange-api-token",
|
|
||||||
request_body = ApiTokenExchangeRequest,
|
|
||||||
responses((status = 200, description = "Access token issued", body = LoginResponse)),
|
|
||||||
tag = "Auth"
|
|
||||||
)]
|
|
||||||
pub async fn api_token_exchange(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Json(payload): Json<ApiTokenExchangeRequest>,
|
|
||||||
) -> AppResult<JsonResponse<LoginResponse>> {
|
|
||||||
AuthService::new(&state).exchange_api_token(payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/auth/signup/start",
|
|
||||||
request_body = SignupStartRequest,
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Signup challenge created", body = SignupStartResponse),
|
|
||||||
(status = 400, description = "Invalid signup request"),
|
|
||||||
(status = 409, description = "Username already exists")
|
|
||||||
),
|
|
||||||
tag = "Auth"
|
|
||||||
)]
|
|
||||||
pub async fn signup_start(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Json(payload): Json<SignupStartRequest>,
|
|
||||||
) -> AppResult<JsonResponse<SignupStartResponse>> {
|
|
||||||
AuthService::new(&state).signup_start(payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/auth/signup/finish",
|
|
||||||
request_body = SignupFinishRequest,
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Signup completed", body = LoginResponseVariants),
|
|
||||||
(status = 400, description = "Invalid signup completion"),
|
|
||||||
(status = 409, description = "Username already exists")
|
|
||||||
),
|
|
||||||
tag = "Auth"
|
|
||||||
)]
|
|
||||||
pub async fn signup_finish(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Json(payload): Json<SignupFinishRequest>,
|
|
||||||
) -> AppResult<Response> {
|
|
||||||
AuthService::new(&state).signup_finish(payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/auth/refresh",
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Refreshed access token", body = LoginResponse),
|
|
||||||
(status = 401, description = "Missing or invalid refresh token")
|
|
||||||
),
|
|
||||||
tag = "Auth"
|
|
||||||
)]
|
|
||||||
pub async fn refresh(
|
pub async fn refresh(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
jar: Option<TypedHeader<Cookie>>,
|
jar: Option<TypedHeader<Cookie>>,
|
||||||
) -> AppResult<Response> {
|
) -> AppResult<Response> {
|
||||||
let cookies = jar.ok_or_else(AppError::unauthorized)?;
|
let cookies = jar.ok_or_else(AppError::unauthorized)?;
|
||||||
let refresh_value = cookies
|
let refresh_value = cookies
|
||||||
.get(SESSION_COOKIE_NAME)
|
.get(REFRESH_COOKIE_NAME)
|
||||||
.ok_or_else(AppError::unauthorized)?;
|
.ok_or_else(AppError::unauthorized)?;
|
||||||
|
|
||||||
AuthService::new(&state).refresh(refresh_value)
|
let hashed = hash_refresh_token(refresh_value);
|
||||||
|
let mut conn = state.db_unscoped()?;
|
||||||
|
let now = Utc::now();
|
||||||
|
let now_naive = now.naive_utc();
|
||||||
|
|
||||||
|
let token = match refresh_dsl::refresh_tokens
|
||||||
|
.filter(refresh_dsl::token_hash.eq(&hashed))
|
||||||
|
.filter(refresh_dsl::revoked_at.is_null())
|
||||||
|
.filter(refresh_dsl::expires_at.gt(now_naive))
|
||||||
|
.first::<RefreshToken>(&mut conn)
|
||||||
|
{
|
||||||
|
Ok(token) => token,
|
||||||
|
Err(diesel::result::Error::NotFound) => return Err(AppError::unauthorized()),
|
||||||
|
Err(err) => return Err(AppError::from(err)),
|
||||||
|
};
|
||||||
|
|
||||||
|
diesel::update(refresh_dsl::refresh_tokens.filter(refresh_dsl::id.eq(token.id)))
|
||||||
|
.set((
|
||||||
|
refresh_dsl::revoked_at.eq(now_naive),
|
||||||
|
refresh_dsl::updated_at.eq(now_naive),
|
||||||
|
))
|
||||||
|
.execute(&mut conn)?;
|
||||||
|
|
||||||
|
let user: User = dsl::users
|
||||||
|
.find(token.user_id)
|
||||||
|
.first(&mut conn)
|
||||||
|
.map_err(AppError::from)?;
|
||||||
|
|
||||||
|
issue_session(&state, &mut conn, &user, token.tenant_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/auth/select-tenant",
|
|
||||||
request_body = TenantSelectionRequest,
|
|
||||||
responses((status = 200, description = "Tenant selected", body = LoginResponse)),
|
|
||||||
tag = "Auth"
|
|
||||||
)]
|
|
||||||
pub async fn select_tenant(
|
pub async fn select_tenant(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
TypedHeader(Authorization(bearer)): TypedHeader<Authorization<Bearer>>,
|
TypedHeader(Authorization(bearer)): TypedHeader<Authorization<Bearer>>,
|
||||||
Json(payload): Json<TenantSelectionRequest>,
|
Json(payload): Json<TenantSelectionRequest>,
|
||||||
) -> AppResult<Response> {
|
) -> AppResult<Response> {
|
||||||
AuthService::new(&state).select_tenant(bearer.token(), payload.tenant_id)
|
let user_id = match state.jwt.verify_tenant_selector_token(bearer.token()) {
|
||||||
|
Ok(claims) => claims.sub,
|
||||||
|
Err(_) => state
|
||||||
|
.jwt
|
||||||
|
.verify_token(bearer.token())
|
||||||
|
.map(|claims| claims.sub)
|
||||||
|
.map_err(|_| AppError::unauthorized())?,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut conn = state.db_unscoped()?;
|
||||||
|
|
||||||
|
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)
|
||||||
|
.first::<Uuid>(&mut conn)
|
||||||
|
.optional()?;
|
||||||
|
|
||||||
|
if membership_exists.is_none() {
|
||||||
|
return Err(AppError::unauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
let user: User = dsl::users
|
||||||
|
.find(user_id)
|
||||||
|
.first(&mut conn)
|
||||||
|
.map_err(AppError::from)?;
|
||||||
|
|
||||||
|
issue_session(&state, &mut conn, &user, payload.tenant_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/auth/logout",
|
|
||||||
responses((status = 204, description = "Session revoked")),
|
|
||||||
tag = "Auth"
|
|
||||||
)]
|
|
||||||
pub async fn logout(
|
pub async fn logout(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
TenantScopedConn { mut conn, user, .. }: TenantScopedConn,
|
user: AuthenticatedUser,
|
||||||
jar: Option<TypedHeader<Cookie>>,
|
jar: Option<TypedHeader<Cookie>>,
|
||||||
) -> AppResult<(HeaderMap, StatusCode)> {
|
) -> AppResult<(HeaderMap, StatusCode)> {
|
||||||
let refresh_cookie = jar.as_ref().and_then(|cookies| {
|
let mut conn = state.db_unscoped()?;
|
||||||
cookies
|
let now = Utc::now().naive_utc();
|
||||||
.get(SESSION_COOKIE_NAME)
|
let mut rows_affected = 0;
|
||||||
.map(|value| value.to_owned())
|
|
||||||
});
|
if let Some(cookies) = jar {
|
||||||
AuthService::new(&state).logout(&mut conn, &user, refresh_cookie.as_deref())
|
if let Some(value) = cookies.get(REFRESH_COOKIE_NAME) {
|
||||||
|
let hashed = hash_refresh_token(value);
|
||||||
|
rows_affected = diesel::update(
|
||||||
|
refresh_dsl::refresh_tokens
|
||||||
|
.filter(refresh_dsl::token_hash.eq(hashed))
|
||||||
|
.filter(refresh_dsl::user_id.eq(user.user_id))
|
||||||
|
.filter(refresh_dsl::revoked_at.is_null()),
|
||||||
|
)
|
||||||
|
.set((
|
||||||
|
refresh_dsl::revoked_at.eq(now),
|
||||||
|
refresh_dsl::updated_at.eq(now),
|
||||||
|
))
|
||||||
|
.execute(&mut conn)
|
||||||
|
.unwrap_or(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if rows_affected == 0 {
|
||||||
|
let _ = diesel::update(
|
||||||
|
refresh_dsl::refresh_tokens
|
||||||
|
.filter(refresh_dsl::user_id.eq(user.user_id))
|
||||||
|
.filter(refresh_dsl::revoked_at.is_null()),
|
||||||
|
)
|
||||||
|
.set((
|
||||||
|
refresh_dsl::revoked_at.eq(now),
|
||||||
|
refresh_dsl::updated_at.eq(now),
|
||||||
|
))
|
||||||
|
.execute(&mut conn);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(SET_COOKIE, build_clear_refresh_cookie(&state));
|
||||||
|
Ok((headers, StatusCode::NO_CONTENT))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/api/auth/me",
|
|
||||||
responses((status = 200, description = "Current session", body = crate::auth::AuthenticatedUser)),
|
|
||||||
tag = "Auth"
|
|
||||||
)]
|
|
||||||
pub async fn me(user: AuthenticatedUser) -> Json<AuthenticatedUser> {
|
pub async fn me(user: AuthenticatedUser) -> Json<AuthenticatedUser> {
|
||||||
Json(user)
|
Json(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
pub async fn list_tenants(
|
||||||
post,
|
|
||||||
path = "/api/auth/passkeys/register/start",
|
|
||||||
responses((status = 200, body = crate::auth::passkeys::RegistrationChallengeResponse)),
|
|
||||||
tag = "Auth"
|
|
||||||
)]
|
|
||||||
pub async fn passkey_register_start(
|
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
user: AuthenticatedUser,
|
auth: Option<TypedHeader<Authorization<Bearer>>>,
|
||||||
) -> AppResult<JsonResponse<RegistrationChallengeResponse>> {
|
) -> AppResult<Json<TenantListResponse>> {
|
||||||
AuthService::new(&state).passkey_register_start(user)
|
let bearer = auth.ok_or_else(AppError::unauthorized)?;
|
||||||
|
let token = bearer.token();
|
||||||
|
|
||||||
|
let user_id = match state.jwt.verify_token(token) {
|
||||||
|
Ok(claims) => claims.sub,
|
||||||
|
Err(_) => {
|
||||||
|
let claims = state
|
||||||
|
.jwt
|
||||||
|
.verify_tenant_selector_token(token)
|
||||||
|
.map_err(|_| AppError::unauthorized())?;
|
||||||
|
claims.sub
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut conn = state.db_unscoped()?;
|
||||||
|
|
||||||
|
let tenants = memberships_dsl::user_memberships
|
||||||
|
.inner_join(tenant_dsl::tenants)
|
||||||
|
.filter(memberships_dsl::user_id.eq(user_id))
|
||||||
|
.select((tenant_dsl::id, tenant_dsl::slug))
|
||||||
|
.load::<(Uuid, String)>(&mut conn)?
|
||||||
|
.into_iter()
|
||||||
|
.map(|(id, slug)| TenantSnippet { id, slug })
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(TenantListResponse { tenants }))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
fn issue_session(
|
||||||
post,
|
state: &AppState,
|
||||||
path = "/api/auth/passkeys/register/finish",
|
conn: &mut PgConnection,
|
||||||
request_body = crate::auth::passkeys::PasskeyRegistrationFinishPayload,
|
user: &User,
|
||||||
responses((status = 201, body = crate::auth::passkeys::PasskeySummary)),
|
tenant_id: Uuid,
|
||||||
tag = "Auth"
|
|
||||||
)]
|
|
||||||
pub async fn passkey_register_finish(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
user: AuthenticatedUser,
|
|
||||||
Json(payload): Json<PasskeyRegistrationFinishPayload>,
|
|
||||||
) -> AppResult<JsonResponse<PasskeySummary>> {
|
|
||||||
AuthService::new(&state).passkey_register_finish(user, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/auth/passkeys/login/start",
|
|
||||||
request_body = crate::auth::passkeys::PasskeyLoginStartPayload,
|
|
||||||
responses((status = 200, body = crate::auth::passkeys::AuthenticationChallengeResponse)),
|
|
||||||
tag = "Auth"
|
|
||||||
)]
|
|
||||||
pub async fn passkey_login_start(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Json(payload): Json<PasskeyLoginStartPayload>,
|
|
||||||
) -> AppResult<JsonResponse<AuthenticationChallengeResponse>> {
|
|
||||||
AuthService::new(&state).passkey_login_start(&payload.username)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/auth/passkeys/login/finish",
|
|
||||||
request_body = crate::auth::passkeys::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>,
|
|
||||||
) -> AppResult<Response> {
|
) -> AppResult<Response> {
|
||||||
AuthService::new(&state).passkey_login_finish(payload)
|
let now = Utc::now();
|
||||||
|
let access_token = state
|
||||||
|
.jwt
|
||||||
|
.generate_token(user.id, tenant_id, &user.username)
|
||||||
|
.map_err(AppError::from)?;
|
||||||
|
|
||||||
|
let tenant_slug: String = tenant_dsl::tenants
|
||||||
|
.find(tenant_id)
|
||||||
|
.select(tenant_dsl::slug)
|
||||||
|
.first(conn)
|
||||||
|
.map_err(AppError::from)?;
|
||||||
|
|
||||||
|
let refresh_value = generate_refresh_token();
|
||||||
|
let refresh_hash = hash_refresh_token(&refresh_value);
|
||||||
|
let refresh_expires_at = now + ChronoDuration::days(state.config.refresh_token_expiry_days);
|
||||||
|
|
||||||
|
let new_refresh = NewRefreshToken {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
user_id: user.id,
|
||||||
|
token_hash: refresh_hash,
|
||||||
|
issued_at: now.naive_utc(),
|
||||||
|
expires_at: refresh_expires_at.naive_utc(),
|
||||||
|
tenant_id,
|
||||||
|
};
|
||||||
|
|
||||||
|
diesel::insert_into(refresh_tokens::table)
|
||||||
|
.values(&new_refresh)
|
||||||
|
.execute(conn)?;
|
||||||
|
|
||||||
|
let mut response = Json(LoginResponse {
|
||||||
|
access_token,
|
||||||
|
token_type: "Bearer".to_string(),
|
||||||
|
expires_in: state.config.jwt_expiry_minutes * 60,
|
||||||
|
tenant: TenantSnippet {
|
||||||
|
id: tenant_id,
|
||||||
|
slug: tenant_slug,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.into_response();
|
||||||
|
|
||||||
|
response.headers_mut().insert(
|
||||||
|
SET_COOKIE,
|
||||||
|
build_refresh_cookie(state, &refresh_value, refresh_expires_at),
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hash_refresh_token(token: &str) -> String {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(token.as_bytes());
|
||||||
|
hex::encode(hasher.finalize())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_refresh_token() -> String {
|
||||||
|
let mut bytes = [0u8; 32];
|
||||||
|
OsRng.fill_bytes(&mut bytes);
|
||||||
|
hex::encode(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_refresh_cookie(
|
||||||
|
state: &AppState,
|
||||||
|
token: &str,
|
||||||
|
expires_at: chrono::DateTime<Utc>,
|
||||||
|
) -> HeaderValue {
|
||||||
|
let max_age = ChronoDuration::days(state.config.refresh_token_expiry_days).num_seconds();
|
||||||
|
|
||||||
|
let mut parts = vec![format!("{}={}", REFRESH_COOKIE_NAME, token)];
|
||||||
|
parts.push("Path=/".into());
|
||||||
|
parts.push("HttpOnly".into());
|
||||||
|
parts.push("SameSite=Strict".into());
|
||||||
|
parts.push(format!("Max-Age={}", max_age));
|
||||||
|
parts.push(format!("Expires={}", expires_at.to_rfc2822()));
|
||||||
|
if state.config.refresh_cookie_secure {
|
||||||
|
parts.push("Secure".into());
|
||||||
|
}
|
||||||
|
if let Some(domain) = &state.config.refresh_cookie_domain {
|
||||||
|
parts.push(format!("Domain={}", domain));
|
||||||
|
}
|
||||||
|
|
||||||
|
HeaderValue::from_str(&parts.join("; ")).expect("valid refresh cookie")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_clear_refresh_cookie(state: &AppState) -> HeaderValue {
|
||||||
|
let mut parts = vec![format!("{}=", REFRESH_COOKIE_NAME)];
|
||||||
|
parts.push("Path=/".into());
|
||||||
|
parts.push("HttpOnly".into());
|
||||||
|
parts.push("SameSite=Strict".into());
|
||||||
|
parts.push("Max-Age=0".into());
|
||||||
|
parts.push("Expires=Thu, 01 Jan 1970 00:00:00 GMT".into());
|
||||||
|
if state.config.refresh_cookie_secure {
|
||||||
|
parts.push("Secure".into());
|
||||||
|
}
|
||||||
|
if let Some(domain) = &state.config.refresh_cookie_domain {
|
||||||
|
parts.push(format!("Domain={}", domain));
|
||||||
|
}
|
||||||
|
|
||||||
|
HeaderValue::from_str(&parts.join("; ")).expect("valid refresh cookie")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,134 +0,0 @@
|
|||||||
use axum::{extract::Path, http::StatusCode, Json};
|
|
||||||
use utoipa::OpenApi;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
auth::TenantScopedConn,
|
|
||||||
error::AppResult,
|
|
||||||
http::responders::JsonResponse,
|
|
||||||
services::capability_sets::{
|
|
||||||
CapabilitySetResponse, CapabilitySetService, CreateCapabilitySetRequest,
|
|
||||||
UpdateCapabilitySetRequest,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/api/capability-sets",
|
|
||||||
responses((status = 200, body = [CapabilitySetResponse])),
|
|
||||||
tag = "Capability Sets"
|
|
||||||
)]
|
|
||||||
pub async fn list_capability_sets(
|
|
||||||
TenantScopedConn {
|
|
||||||
mut conn,
|
|
||||||
tenant_id,
|
|
||||||
..
|
|
||||||
}: TenantScopedConn,
|
|
||||||
) -> AppResult<JsonResponse<Vec<CapabilitySetResponse>>> {
|
|
||||||
CapabilitySetService::new().list(&mut conn, tenant_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/api/capabilities",
|
|
||||||
responses((status = 200, body = [crate::models::ApiCapability])),
|
|
||||||
tag = "Capability Sets"
|
|
||||||
)]
|
|
||||||
pub async fn list_capabilities(
|
|
||||||
TenantScopedConn { .. }: TenantScopedConn,
|
|
||||||
) -> AppResult<JsonResponse<Vec<crate::models::ApiCapability>>> {
|
|
||||||
CapabilitySetService::new().list_capabilities()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/api/capability-sets/{id}",
|
|
||||||
params(("id" = Uuid, Path, description = "Capability set ID")),
|
|
||||||
responses((status = 200, body = CapabilitySetResponse), (status = 404, description = "Not found")),
|
|
||||||
tag = "Capability Sets"
|
|
||||||
)]
|
|
||||||
pub async fn get_capability_set(
|
|
||||||
TenantScopedConn {
|
|
||||||
mut conn,
|
|
||||||
tenant_id,
|
|
||||||
..
|
|
||||||
}: TenantScopedConn,
|
|
||||||
Path(id): Path<Uuid>,
|
|
||||||
) -> AppResult<JsonResponse<CapabilitySetResponse>> {
|
|
||||||
CapabilitySetService::new().get(&mut conn, tenant_id, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/capability-sets",
|
|
||||||
request_body = CreateCapabilitySetRequest,
|
|
||||||
responses((status = 201, body = CapabilitySetResponse)),
|
|
||||||
tag = "Capability Sets"
|
|
||||||
)]
|
|
||||||
pub async fn create_capability_set(
|
|
||||||
TenantScopedConn {
|
|
||||||
mut conn,
|
|
||||||
tenant_id,
|
|
||||||
..
|
|
||||||
}: TenantScopedConn,
|
|
||||||
Json(payload): Json<CreateCapabilitySetRequest>,
|
|
||||||
) -> AppResult<JsonResponse<CapabilitySetResponse>> {
|
|
||||||
CapabilitySetService::new().create(&mut conn, tenant_id, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
patch,
|
|
||||||
path = "/api/capability-sets/{id}",
|
|
||||||
params(("id" = Uuid, Path, description = "Capability set ID")),
|
|
||||||
request_body = UpdateCapabilitySetRequest,
|
|
||||||
responses((status = 200, body = CapabilitySetResponse)),
|
|
||||||
tag = "Capability Sets"
|
|
||||||
)]
|
|
||||||
pub async fn update_capability_set(
|
|
||||||
TenantScopedConn {
|
|
||||||
mut conn,
|
|
||||||
tenant_id,
|
|
||||||
..
|
|
||||||
}: TenantScopedConn,
|
|
||||||
Path(id): Path<Uuid>,
|
|
||||||
Json(payload): Json<UpdateCapabilitySetRequest>,
|
|
||||||
) -> AppResult<JsonResponse<CapabilitySetResponse>> {
|
|
||||||
CapabilitySetService::new().update(&mut conn, tenant_id, id, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
delete,
|
|
||||||
path = "/api/capability-sets/{id}",
|
|
||||||
params(("id" = Uuid, Path, description = "Capability set ID")),
|
|
||||||
responses((status = 204), (status = 409, description = "Set in use")),
|
|
||||||
tag = "Capability Sets"
|
|
||||||
)]
|
|
||||||
pub async fn delete_capability_set(
|
|
||||||
TenantScopedConn {
|
|
||||||
mut conn,
|
|
||||||
tenant_id,
|
|
||||||
..
|
|
||||||
}: TenantScopedConn,
|
|
||||||
Path(id): Path<Uuid>,
|
|
||||||
) -> AppResult<StatusCode> {
|
|
||||||
CapabilitySetService::new().delete(&mut conn, tenant_id, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(OpenApi)]
|
|
||||||
#[openapi(
|
|
||||||
paths(
|
|
||||||
crate::routes::capability_sets::list_capability_sets,
|
|
||||||
crate::routes::capability_sets::list_capabilities,
|
|
||||||
crate::routes::capability_sets::get_capability_set,
|
|
||||||
crate::routes::capability_sets::create_capability_set,
|
|
||||||
crate::routes::capability_sets::update_capability_set,
|
|
||||||
crate::routes::capability_sets::delete_capability_set,
|
|
||||||
),
|
|
||||||
components(schemas(
|
|
||||||
crate::models::ApiCapability,
|
|
||||||
crate::services::capability_sets::CapabilitySetResponse,
|
|
||||||
crate::services::capability_sets::CreateCapabilitySetRequest,
|
|
||||||
crate::services::capability_sets::UpdateCapabilitySetRequest,
|
|
||||||
))
|
|
||||||
)]
|
|
||||||
pub struct CapabilitySetsApiDoc;
|
|
||||||
@@ -5,45 +5,44 @@ use chrono::Utc;
|
|||||||
use diesel::{dsl::count_star, prelude::*, result::DatabaseErrorKind, PgConnection};
|
use diesel::{dsl::count_star, prelude::*, result::DatabaseErrorKind, PgConnection};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use utoipa::ToSchema;
|
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
auth::TenantScopedConn,
|
auth::TenantScopedConn,
|
||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
http::responders::{no_content, ok_json, IntoAppResult, JsonResponse, RowsAffectedExt},
|
|
||||||
models::{Correspondent, NewCorrespondent},
|
models::{Correspondent, NewCorrespondent},
|
||||||
schema::{correspondents, document_correspondents},
|
schema::{correspondents, document_correspondents},
|
||||||
utils::{
|
utils::{
|
||||||
named_entity::{ensure_name_available, normalize_name},
|
db::{no_content, EnsureEntity, IntoJsonResponse},
|
||||||
time::to_iso,
|
time::to_iso,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize)]
|
||||||
|
pub struct CorrespondentUsage {
|
||||||
|
pub total: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
pub struct CorrespondentSummary {
|
pub struct CorrespondentSummary {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
#[schema(value_type = Object)]
|
|
||||||
pub metadata: Value,
|
pub metadata: Value,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
pub usage_count: i64,
|
pub usage: CorrespondentUsage,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, ToSchema)]
|
#[derive(Deserialize)]
|
||||||
pub struct CreateCorrespondentRequest {
|
pub struct CreateCorrespondentRequest {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
#[schema(nullable, value_type = Object)]
|
|
||||||
pub metadata: Option<Value>,
|
pub metadata: Option<Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, ToSchema)]
|
#[derive(Deserialize)]
|
||||||
pub struct UpdateCorrespondentRequest {
|
pub struct UpdateCorrespondentRequest {
|
||||||
#[schema(nullable)]
|
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
#[schema(nullable, value_type = Object)]
|
|
||||||
pub metadata: Option<Value>,
|
pub metadata: Option<Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,19 +53,13 @@ struct CorrespondentChangeset<'a> {
|
|||||||
metadata: Option<&'a Value>,
|
metadata: Option<&'a Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/api/correspondents",
|
|
||||||
responses((status = 200, description = "Correspondents", body = [CorrespondentSummary])),
|
|
||||||
tag = "Correspondents"
|
|
||||||
)]
|
|
||||||
pub async fn list_correspondents(
|
pub async fn list_correspondents(
|
||||||
TenantScopedConn {
|
TenantScopedConn {
|
||||||
mut conn,
|
mut conn,
|
||||||
tenant_id,
|
tenant_id,
|
||||||
..
|
..
|
||||||
}: TenantScopedConn,
|
}: TenantScopedConn,
|
||||||
) -> AppResult<JsonResponse<Vec<CorrespondentSummary>>> {
|
) -> AppResult<Json<Vec<CorrespondentSummary>>> {
|
||||||
let correspondents_list: Vec<Correspondent> = correspondents::table
|
let correspondents_list: Vec<Correspondent> = correspondents::table
|
||||||
.filter(correspondents::tenant_id.eq(tenant_id))
|
.filter(correspondents::tenant_id.eq(tenant_id))
|
||||||
.order(correspondents::name.asc())
|
.order(correspondents::name.asc())
|
||||||
@@ -89,16 +82,9 @@ pub async fn list_correspondents(
|
|||||||
response.push(build_summary(correspondent, total));
|
response.push(build_summary(correspondent, total));
|
||||||
}
|
}
|
||||||
|
|
||||||
ok_json(response)
|
response.into_json()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/correspondents",
|
|
||||||
request_body = CreateCorrespondentRequest,
|
|
||||||
responses((status = 200, description = "Correspondent created", body = CorrespondentSummary)),
|
|
||||||
tag = "Correspondents"
|
|
||||||
)]
|
|
||||||
pub async fn create_correspondent(
|
pub async fn create_correspondent(
|
||||||
TenantScopedConn {
|
TenantScopedConn {
|
||||||
mut conn,
|
mut conn,
|
||||||
@@ -106,16 +92,17 @@ pub async fn create_correspondent(
|
|||||||
..
|
..
|
||||||
}: TenantScopedConn,
|
}: TenantScopedConn,
|
||||||
Json(payload): Json<CreateCorrespondentRequest>,
|
Json(payload): Json<CreateCorrespondentRequest>,
|
||||||
) -> AppResult<JsonResponse<CorrespondentSummary>> {
|
) -> AppResult<Json<CorrespondentSummary>> {
|
||||||
let name = normalize_name(&payload.name, || {
|
let name = payload.name.trim();
|
||||||
AppError::bad_request("name must not be empty")
|
if name.is_empty() {
|
||||||
})?;
|
return Err(AppError::bad_request("name must not be empty"));
|
||||||
|
}
|
||||||
|
|
||||||
let metadata_value = normalize_metadata(payload.metadata);
|
let metadata_value = normalize_metadata(payload.metadata);
|
||||||
let new_id = Uuid::new_v4();
|
let new_id = Uuid::new_v4();
|
||||||
let new_correspondent = NewCorrespondent {
|
let new_correspondent = NewCorrespondent {
|
||||||
id: new_id,
|
id: new_id,
|
||||||
name: name.clone(),
|
name: name.to_string(),
|
||||||
metadata: metadata_value,
|
metadata: metadata_value,
|
||||||
tenant_id,
|
tenant_id,
|
||||||
};
|
};
|
||||||
@@ -135,19 +122,11 @@ pub async fn create_correspondent(
|
|||||||
.find(new_id)
|
.find(new_id)
|
||||||
.filter(correspondents::tenant_id.eq(tenant_id))
|
.filter(correspondents::tenant_id.eq(tenant_id))
|
||||||
.first(&mut conn)
|
.first(&mut conn)
|
||||||
.into_app_result()?;
|
.one()?;
|
||||||
|
|
||||||
ok_json(build_summary(correspondent, 0))
|
build_summary(correspondent, 0).into_json()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
patch,
|
|
||||||
path = "/api/correspondents/{id}",
|
|
||||||
params(("id" = Uuid, Path, description = "Correspondent ID")),
|
|
||||||
request_body = UpdateCorrespondentRequest,
|
|
||||||
responses((status = 200, description = "Correspondent updated", body = CorrespondentSummary)),
|
|
||||||
tag = "Correspondents"
|
|
||||||
)]
|
|
||||||
pub async fn update_correspondent(
|
pub async fn update_correspondent(
|
||||||
Path(correspondent_id): Path<Uuid>,
|
Path(correspondent_id): Path<Uuid>,
|
||||||
TenantScopedConn {
|
TenantScopedConn {
|
||||||
@@ -156,31 +135,30 @@ pub async fn update_correspondent(
|
|||||||
..
|
..
|
||||||
}: TenantScopedConn,
|
}: TenantScopedConn,
|
||||||
Json(payload): Json<UpdateCorrespondentRequest>,
|
Json(payload): Json<UpdateCorrespondentRequest>,
|
||||||
) -> AppResult<JsonResponse<CorrespondentSummary>> {
|
) -> AppResult<Json<CorrespondentSummary>> {
|
||||||
let existing: Correspondent = correspondents::table
|
let existing: Correspondent = correspondents::table
|
||||||
.find(correspondent_id)
|
.find(correspondent_id)
|
||||||
.filter(correspondents::tenant_id.eq(tenant_id))
|
.filter(correspondents::tenant_id.eq(tenant_id))
|
||||||
.first(&mut conn)
|
.first(&mut conn)
|
||||||
.into_app_result()?;
|
.one()?;
|
||||||
|
|
||||||
let mut new_name: Option<String> = None;
|
let mut new_name: Option<String> = None;
|
||||||
if let Some(ref candidate) = payload.name {
|
if let Some(ref candidate) = payload.name {
|
||||||
let normalized = normalize_name(candidate, || {
|
let trimmed = candidate.trim();
|
||||||
AppError::bad_request("name must not be empty")
|
if trimmed.is_empty() {
|
||||||
})?;
|
return Err(AppError::bad_request("name must not be empty"));
|
||||||
if normalized != existing.name {
|
}
|
||||||
ensure_name_available(
|
if trimmed != existing.name {
|
||||||
|| {
|
let duplicate = correspondents::table
|
||||||
correspondents::table
|
.filter(correspondents::name.eq(trimmed))
|
||||||
.filter(correspondents::name.eq(&normalized))
|
.filter(correspondents::id.ne(correspondent_id))
|
||||||
.filter(correspondents::id.ne(correspondent_id))
|
.filter(correspondents::tenant_id.eq(tenant_id))
|
||||||
.filter(correspondents::tenant_id.eq(tenant_id))
|
.first::<Correspondent>(&mut conn)
|
||||||
.first::<Correspondent>(&mut conn)
|
.optional()?;
|
||||||
.optional()
|
if duplicate.is_some() {
|
||||||
},
|
return Err(AppError::bad_request("correspondent name already exists"));
|
||||||
|| AppError::bad_request("correspondent name already exists"),
|
}
|
||||||
)?;
|
new_name = Some(trimmed.to_string());
|
||||||
new_name = Some(normalized);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,7 +172,7 @@ pub async fn update_correspondent(
|
|||||||
|
|
||||||
if new_name.is_none() && new_metadata.is_none() {
|
if new_name.is_none() && new_metadata.is_none() {
|
||||||
let usage = load_usage_for_correspondent(&mut conn, tenant_id, correspondent_id)?;
|
let usage = load_usage_for_correspondent(&mut conn, tenant_id, correspondent_id)?;
|
||||||
return ok_json(build_summary(existing.clone(), usage));
|
return build_summary(existing.clone(), usage).into_json();
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut changeset = CorrespondentChangeset::default();
|
let mut changeset = CorrespondentChangeset::default();
|
||||||
@@ -212,26 +190,17 @@ pub async fn update_correspondent(
|
|||||||
.filter(correspondents::tenant_id.eq(tenant_id)),
|
.filter(correspondents::tenant_id.eq(tenant_id)),
|
||||||
)
|
)
|
||||||
.set((&changeset, correspondents::updated_at.eq(now)))
|
.set((&changeset, correspondents::updated_at.eq(now)))
|
||||||
.execute(&mut conn)
|
.execute(&mut conn)?;
|
||||||
.into_app_result()?
|
|
||||||
.or_not_found()?;
|
|
||||||
|
|
||||||
let updated: Correspondent = correspondents::table
|
let updated: Correspondent = correspondents::table
|
||||||
.find(correspondent_id)
|
.find(correspondent_id)
|
||||||
.filter(correspondents::tenant_id.eq(tenant_id))
|
.filter(correspondents::tenant_id.eq(tenant_id))
|
||||||
.first(&mut conn)
|
.first(&mut conn)
|
||||||
.into_app_result()?;
|
.one()?;
|
||||||
let usage = load_usage_for_correspondent(&mut conn, tenant_id, correspondent_id)?;
|
let usage = load_usage_for_correspondent(&mut conn, tenant_id, correspondent_id)?;
|
||||||
ok_json(build_summary(updated, usage))
|
build_summary(updated, usage).into_json()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
delete,
|
|
||||||
path = "/api/correspondents/{id}",
|
|
||||||
params(("id" = Uuid, Path, description = "Correspondent ID")),
|
|
||||||
responses((status = 204, description = "Correspondent deleted")),
|
|
||||||
tag = "Correspondents"
|
|
||||||
)]
|
|
||||||
pub async fn delete_correspondent(
|
pub async fn delete_correspondent(
|
||||||
Path(correspondent_id): Path<Uuid>,
|
Path(correspondent_id): Path<Uuid>,
|
||||||
TenantScopedConn {
|
TenantScopedConn {
|
||||||
@@ -252,25 +221,26 @@ pub async fn delete_correspondent(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
diesel::delete(
|
let deleted = diesel::delete(
|
||||||
correspondents::table
|
correspondents::table
|
||||||
.filter(correspondents::id.eq(correspondent_id))
|
.filter(correspondents::id.eq(correspondent_id))
|
||||||
.filter(correspondents::tenant_id.eq(tenant_id)),
|
.filter(correspondents::tenant_id.eq(tenant_id)),
|
||||||
)
|
)
|
||||||
.execute(&mut conn)
|
.execute(&mut conn)?;
|
||||||
.into_app_result()?
|
if deleted == 0 {
|
||||||
.or_not_found()?;
|
return Err(AppError::not_found());
|
||||||
|
}
|
||||||
no_content()
|
no_content()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_summary(correspondent: Correspondent, usage_count: i64) -> CorrespondentSummary {
|
fn build_summary(correspondent: Correspondent, total: i64) -> CorrespondentSummary {
|
||||||
CorrespondentSummary {
|
CorrespondentSummary {
|
||||||
id: correspondent.id,
|
id: correspondent.id,
|
||||||
name: correspondent.name,
|
name: correspondent.name,
|
||||||
metadata: correspondent.metadata,
|
metadata: correspondent.metadata,
|
||||||
created_at: to_iso(correspondent.created_at),
|
created_at: to_iso(correspondent.created_at),
|
||||||
updated_at: to_iso(correspondent.updated_at),
|
updated_at: to_iso(correspondent.updated_at),
|
||||||
usage_count,
|
usage: CorrespondentUsage { total },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,19 +264,3 @@ fn load_usage_for_correspondent(
|
|||||||
|
|
||||||
Ok(total)
|
Ok(total)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(utoipa::OpenApi)]
|
|
||||||
#[openapi(
|
|
||||||
paths(
|
|
||||||
crate::routes::correspondents::list_correspondents,
|
|
||||||
crate::routes::correspondents::create_correspondent,
|
|
||||||
crate::routes::correspondents::update_correspondent,
|
|
||||||
crate::routes::correspondents::delete_correspondent
|
|
||||||
),
|
|
||||||
components(schemas(
|
|
||||||
crate::routes::correspondents::CorrespondentSummary,
|
|
||||||
crate::routes::correspondents::CreateCorrespondentRequest,
|
|
||||||
crate::routes::correspondents::UpdateCorrespondentRequest
|
|
||||||
))
|
|
||||||
)]
|
|
||||||
pub struct CorrespondentsApiDoc;
|
|
||||||
|
|||||||
+1692
-645
File diff suppressed because it is too large
Load Diff
+453
-149
@@ -2,113 +2,248 @@ use axum::{
|
|||||||
extract::{Json, Path, Query, State},
|
extract::{Json, Path, Query, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
};
|
};
|
||||||
use utoipa::OpenApi;
|
use diesel::{dsl::exists, prelude::*, PgConnection};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::models::{Document, Folder, NewFolder};
|
||||||
|
use crate::schema::{documents, folders};
|
||||||
|
use crate::state::AppState;
|
||||||
use crate::{
|
use crate::{
|
||||||
auth::TenantScopedConn,
|
auth::TenantScopedConn,
|
||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
http::responders::{created_json, no_content, ok_json, JsonResponse},
|
|
||||||
services::folders::{
|
|
||||||
CreateFolderRequest, EnsureFolderPathRequest, FolderContentsData, FolderContentsQuery,
|
|
||||||
FolderInfo, FolderService, FolderTreeNode, UpdateFolderRequest,
|
|
||||||
},
|
|
||||||
state::AppState,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::services::documents::DocumentResponse;
|
use super::documents::{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,
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(utoipa::ToSchema, serde::Serialize)]
|
#[derive(Deserialize)]
|
||||||
|
pub struct CreateFolderRequest {
|
||||||
|
pub name: String,
|
||||||
|
pub parent_id: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct EnsureFolderPathRequest {
|
||||||
|
pub parent_id: Option<Uuid>,
|
||||||
|
pub segments: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
pub struct FolderResponse {
|
pub struct FolderResponse {
|
||||||
pub folder: FolderInfo,
|
pub folder: FolderInfo,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(utoipa::ToSchema, serde::Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct FolderContentsResponse {
|
pub struct FolderContentsResponse {
|
||||||
#[schema(nullable)]
|
|
||||||
pub folder: Option<FolderInfo>,
|
pub folder: Option<FolderInfo>,
|
||||||
pub subfolders: Vec<FolderInfo>,
|
pub subfolders: Vec<FolderInfo>,
|
||||||
pub documents: Vec<DocumentResponse>,
|
pub documents: Vec<DocumentResponse>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[derive(Deserialize)]
|
||||||
get,
|
pub struct FolderContentsQuery {
|
||||||
path = "/api/folders/{id}",
|
#[serde(default = "default_include_documents")]
|
||||||
params(("id" = Uuid, Path, description = "Folder ID")),
|
pub include_documents: bool,
|
||||||
responses((status = 200, description = "Folder detail", body = FolderResponse)),
|
}
|
||||||
tag = "Folders"
|
|
||||||
)]
|
const fn default_include_documents() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct FolderInfo {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub parent_id: Option<Uuid>,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_folder(
|
pub async fn get_folder(
|
||||||
State(state): State<AppState>,
|
|
||||||
Path(folder_id): Path<Uuid>,
|
Path(folder_id): Path<Uuid>,
|
||||||
TenantScopedConn {
|
TenantScopedConn {
|
||||||
mut conn,
|
mut conn,
|
||||||
tenant_id,
|
tenant_id,
|
||||||
..
|
..
|
||||||
}: TenantScopedConn,
|
}: TenantScopedConn,
|
||||||
) -> AppResult<JsonResponse<FolderResponse>> {
|
) -> AppResult<Json<FolderResponse>> {
|
||||||
let service = FolderService::new(&state);
|
let folder: Folder = folders::table
|
||||||
let folder = service.get_folder(&mut conn, tenant_id, folder_id)?;
|
.find(folder_id)
|
||||||
ok_json(FolderResponse { folder })
|
.filter(folders::tenant_id.eq(tenant_id))
|
||||||
|
.first(&mut conn)?;
|
||||||
|
|
||||||
|
Ok(Json(FolderResponse {
|
||||||
|
folder: folder_to_info(folder),
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/folders/path",
|
|
||||||
request_body = EnsureFolderPathRequest,
|
|
||||||
responses((status = 200, description = "Folder path ensured", body = FolderResponse)),
|
|
||||||
tag = "Folders"
|
|
||||||
)]
|
|
||||||
pub async fn ensure_folder_path(
|
pub async fn ensure_folder_path(
|
||||||
State(state): State<AppState>,
|
|
||||||
TenantScopedConn {
|
TenantScopedConn {
|
||||||
mut conn,
|
mut conn,
|
||||||
tenant_id,
|
tenant_id,
|
||||||
..
|
..
|
||||||
}: TenantScopedConn,
|
}: TenantScopedConn,
|
||||||
Json(payload): Json<EnsureFolderPathRequest>,
|
Json(payload): Json<EnsureFolderPathRequest>,
|
||||||
) -> AppResult<JsonResponse<FolderResponse>> {
|
) -> AppResult<Json<FolderResponse>> {
|
||||||
let service = FolderService::new(&state);
|
if payload.segments.is_empty() {
|
||||||
let folder = service.ensure_folder_path(&mut conn, tenant_id, payload)?;
|
return Err(AppError::bad_request("segments must not be empty"));
|
||||||
ok_json(FolderResponse { folder })
|
}
|
||||||
|
|
||||||
|
let target_folder = conn.transaction::<Folder, AppError, _>(|conn| {
|
||||||
|
let mut current_parent = payload.parent_id;
|
||||||
|
let mut last_folder: Option<Folder> = None;
|
||||||
|
|
||||||
|
for raw_name in &payload.segments {
|
||||||
|
let name = raw_name.trim();
|
||||||
|
if name.is_empty() {
|
||||||
|
return Err(AppError::bad_request("folder names must not be empty"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let existing: Option<Folder> = if let Some(parent_id) = current_parent {
|
||||||
|
folders::table
|
||||||
|
.filter(folders::tenant_id.eq(tenant_id))
|
||||||
|
.filter(folders::parent_id.eq(Some(parent_id)))
|
||||||
|
.filter(folders::name.eq(name))
|
||||||
|
.first(conn)
|
||||||
|
.optional()?
|
||||||
|
} else {
|
||||||
|
folders::table
|
||||||
|
.filter(folders::tenant_id.eq(tenant_id))
|
||||||
|
.filter(folders::parent_id.is_null())
|
||||||
|
.filter(folders::name.eq(name))
|
||||||
|
.first(conn)
|
||||||
|
.optional()?
|
||||||
|
};
|
||||||
|
|
||||||
|
let folder = if let Some(folder) = existing {
|
||||||
|
folder
|
||||||
|
} else {
|
||||||
|
let new_folder = NewFolder {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
name: name.to_string(),
|
||||||
|
parent_id: current_parent,
|
||||||
|
tenant_id,
|
||||||
|
};
|
||||||
|
|
||||||
|
let inserted_id: Option<Uuid> = diesel::insert_into(folders::table)
|
||||||
|
.values(&new_folder)
|
||||||
|
.on_conflict_do_nothing()
|
||||||
|
.returning(folders::id)
|
||||||
|
.get_result(conn)
|
||||||
|
.optional()?;
|
||||||
|
|
||||||
|
if let Some(id) = inserted_id {
|
||||||
|
folders::table
|
||||||
|
.find(id)
|
||||||
|
.filter(folders::tenant_id.eq(tenant_id))
|
||||||
|
.first(conn)?
|
||||||
|
} else if let Some(parent_id) = current_parent {
|
||||||
|
folders::table
|
||||||
|
.filter(folders::tenant_id.eq(tenant_id))
|
||||||
|
.filter(folders::parent_id.eq(Some(parent_id)))
|
||||||
|
.filter(folders::name.eq(name))
|
||||||
|
.first(conn)?
|
||||||
|
} else {
|
||||||
|
folders::table
|
||||||
|
.filter(folders::tenant_id.eq(tenant_id))
|
||||||
|
.filter(folders::parent_id.is_null())
|
||||||
|
.filter(folders::name.eq(name))
|
||||||
|
.first(conn)?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
current_parent = Some(folder.id);
|
||||||
|
last_folder = Some(folder);
|
||||||
|
}
|
||||||
|
|
||||||
|
last_folder.ok_or_else(|| AppError::internal("failed to resolve folder path".to_string()))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Json(FolderResponse {
|
||||||
|
folder: folder_to_info(target_folder),
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/folders",
|
|
||||||
request_body = CreateFolderRequest,
|
|
||||||
responses(
|
|
||||||
(status = 201, description = "Folder created", body = FolderResponse),
|
|
||||||
(status = 200, description = "Folder already existed", body = FolderResponse)
|
|
||||||
),
|
|
||||||
tag = "Folders"
|
|
||||||
)]
|
|
||||||
pub async fn create_folder(
|
pub async fn create_folder(
|
||||||
State(state): State<AppState>,
|
|
||||||
TenantScopedConn {
|
TenantScopedConn {
|
||||||
mut conn,
|
mut conn,
|
||||||
tenant_id,
|
tenant_id,
|
||||||
..
|
..
|
||||||
}: TenantScopedConn,
|
}: TenantScopedConn,
|
||||||
Json(payload): Json<CreateFolderRequest>,
|
Json(payload): Json<CreateFolderRequest>,
|
||||||
) -> AppResult<JsonResponse<FolderResponse>> {
|
) -> AppResult<Json<FolderResponse>> {
|
||||||
let service = FolderService::new(&state);
|
if payload.name.trim().is_empty() {
|
||||||
let (folder, created) = service.create_folder(&mut conn, tenant_id, payload)?;
|
return Err(AppError::bad_request("name must not be empty"));
|
||||||
let response = FolderResponse { folder };
|
|
||||||
if created {
|
|
||||||
created_json(response)
|
|
||||||
} else {
|
|
||||||
ok_json(response)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let name = payload.name.trim();
|
||||||
|
|
||||||
|
let existing: Option<Folder> = if let Some(parent_id) = payload.parent_id {
|
||||||
|
folders::table
|
||||||
|
.filter(folders::tenant_id.eq(tenant_id))
|
||||||
|
.filter(folders::parent_id.eq(Some(parent_id)))
|
||||||
|
.filter(folders::name.eq(name))
|
||||||
|
.first(&mut conn)
|
||||||
|
.optional()?
|
||||||
|
} else {
|
||||||
|
folders::table
|
||||||
|
.filter(folders::tenant_id.eq(tenant_id))
|
||||||
|
.filter(folders::parent_id.is_null())
|
||||||
|
.filter(folders::name.eq(name))
|
||||||
|
.first(&mut conn)
|
||||||
|
.optional()?
|
||||||
|
};
|
||||||
|
|
||||||
|
let folder: Folder = if let Some(folder) = existing {
|
||||||
|
folder
|
||||||
|
} else {
|
||||||
|
let new_folder = NewFolder {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
name: name.to_string(),
|
||||||
|
parent_id: payload.parent_id,
|
||||||
|
tenant_id,
|
||||||
|
};
|
||||||
|
|
||||||
|
let inserted_id: Option<Uuid> = diesel::insert_into(folders::table)
|
||||||
|
.values(&new_folder)
|
||||||
|
.on_conflict_do_nothing()
|
||||||
|
.returning(folders::id)
|
||||||
|
.get_result(&mut conn)
|
||||||
|
.optional()?;
|
||||||
|
|
||||||
|
if let Some(id) = inserted_id {
|
||||||
|
folders::table
|
||||||
|
.find(id)
|
||||||
|
.filter(folders::tenant_id.eq(tenant_id))
|
||||||
|
.first(&mut conn)?
|
||||||
|
} else if let Some(parent_id) = payload.parent_id {
|
||||||
|
folders::table
|
||||||
|
.filter(folders::tenant_id.eq(tenant_id))
|
||||||
|
.filter(folders::parent_id.eq(Some(parent_id)))
|
||||||
|
.filter(folders::name.eq(name))
|
||||||
|
.first(&mut conn)?
|
||||||
|
} else {
|
||||||
|
folders::table
|
||||||
|
.filter(folders::tenant_id.eq(tenant_id))
|
||||||
|
.filter(folders::parent_id.is_null())
|
||||||
|
.filter(folders::name.eq(name))
|
||||||
|
.first(&mut conn)?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(FolderResponse {
|
||||||
|
folder: folder_to_info(folder),
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/api/folders/{id}/contents",
|
|
||||||
params(("id" = String, Path, description = "Folder ID or 'root'"), FolderContentsQuery),
|
|
||||||
responses((status = 200, description = "Folder contents", body = FolderContentsResponse)),
|
|
||||||
tag = "Folders"
|
|
||||||
)]
|
|
||||||
pub async fn list_folder_contents(
|
pub async fn list_folder_contents(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(folder_identifier): Path<String>,
|
Path(folder_identifier): Path<String>,
|
||||||
@@ -119,13 +254,7 @@ pub async fn list_folder_contents(
|
|||||||
user_id,
|
user_id,
|
||||||
..
|
..
|
||||||
}: TenantScopedConn,
|
}: TenantScopedConn,
|
||||||
) -> AppResult<JsonResponse<FolderContentsResponse>> {
|
) -> AppResult<Json<FolderContentsResponse>> {
|
||||||
let FolderContentsQuery {
|
|
||||||
include_documents,
|
|
||||||
sort,
|
|
||||||
dir,
|
|
||||||
} = query;
|
|
||||||
|
|
||||||
let folder_id = if folder_identifier.eq_ignore_ascii_case("root") {
|
let folder_id = if folder_identifier.eq_ignore_ascii_case("root") {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
@@ -135,61 +264,82 @@ pub async fn list_folder_contents(
|
|||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
let service = FolderService::new(&state);
|
let folder = match folder_id {
|
||||||
let FolderContentsData {
|
Some(id) => Some(folder_to_info(
|
||||||
folder,
|
folders::table
|
||||||
subfolders,
|
.find(id)
|
||||||
documents,
|
.filter(folders::tenant_id.eq(tenant_id))
|
||||||
} = service.list_folder_contents(
|
.first::<Folder>(&mut conn)?,
|
||||||
&mut conn,
|
)),
|
||||||
tenant_id,
|
None => None,
|
||||||
folder_id,
|
};
|
||||||
sort,
|
|
||||||
dir,
|
|
||||||
include_documents,
|
|
||||||
)?;
|
|
||||||
|
|
||||||
let documents = if include_documents {
|
let child_folders: Vec<Folder> = if let Some(parent_id) = folder_id {
|
||||||
service.hydrate_documents(&mut conn, tenant_id, user_id, documents)?
|
folders::table
|
||||||
|
.filter(folders::parent_id.eq(parent_id))
|
||||||
|
.filter(folders::tenant_id.eq(tenant_id))
|
||||||
|
.order(folders::name.asc())
|
||||||
|
.load(&mut conn)?
|
||||||
|
} else {
|
||||||
|
folders::table
|
||||||
|
.filter(folders::parent_id.is_null())
|
||||||
|
.filter(folders::tenant_id.eq(tenant_id))
|
||||||
|
.order(folders::name.asc())
|
||||||
|
.load(&mut conn)?
|
||||||
|
};
|
||||||
|
let subfolders = child_folders.into_iter().map(folder_to_info).collect();
|
||||||
|
|
||||||
|
let documents = if query.include_documents {
|
||||||
|
let docs_query = documents::table
|
||||||
|
.filter(documents::deleted_at.is_null())
|
||||||
|
.filter(documents::tenant_id.eq(tenant_id))
|
||||||
|
.order(documents::uploaded_at.desc());
|
||||||
|
|
||||||
|
let docs: Vec<Document> = if let Some(current_folder) = folder_id {
|
||||||
|
docs_query
|
||||||
|
.filter(documents::folder_id.eq(current_folder))
|
||||||
|
.load(&mut conn)?
|
||||||
|
} else {
|
||||||
|
docs_query
|
||||||
|
.filter(documents::folder_id.is_null())
|
||||||
|
.load(&mut conn)?
|
||||||
|
};
|
||||||
|
|
||||||
|
let doc_ids: Vec<Uuid> = docs.iter().map(|doc| doc.id).collect();
|
||||||
|
let tags_map = load_tags_for_documents(&mut conn, &doc_ids)?;
|
||||||
|
let mut correspondents_map = load_correspondents_for_documents(&mut conn, &doc_ids)?;
|
||||||
|
drop(conn);
|
||||||
|
|
||||||
|
let primary_versions = load_primary_assets(&state, tenant_id, &docs)?;
|
||||||
|
|
||||||
|
let mut documents = Vec::with_capacity(doc_ids.len());
|
||||||
|
for doc in docs {
|
||||||
|
let tags = tags_map.get(&doc.id).cloned();
|
||||||
|
let correspondents = correspondents_map.remove(&doc.id).unwrap_or_default();
|
||||||
|
let current_version = primary_versions.get(&doc.id).cloned();
|
||||||
|
documents.push(to_document_response(
|
||||||
|
&state,
|
||||||
|
user_id,
|
||||||
|
doc,
|
||||||
|
tags,
|
||||||
|
correspondents,
|
||||||
|
current_version,
|
||||||
|
)?);
|
||||||
|
}
|
||||||
|
|
||||||
|
documents
|
||||||
} else {
|
} else {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
};
|
};
|
||||||
|
|
||||||
ok_json(FolderContentsResponse {
|
Ok(Json(FolderContentsResponse {
|
||||||
folder,
|
folder,
|
||||||
subfolders,
|
subfolders,
|
||||||
documents,
|
documents,
|
||||||
})
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/api/folders/tree",
|
|
||||||
responses((status = 200, description = "Folder hierarchy", body = [FolderTreeNode])),
|
|
||||||
tag = "Folders"
|
|
||||||
)]
|
|
||||||
pub async fn list_folder_tree(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
TenantScopedConn {
|
|
||||||
mut conn,
|
|
||||||
tenant_id,
|
|
||||||
..
|
|
||||||
}: TenantScopedConn,
|
|
||||||
) -> AppResult<JsonResponse<Vec<FolderTreeNode>>> {
|
|
||||||
let service = FolderService::new(&state);
|
|
||||||
let tree = service.list_folder_tree(&mut conn, tenant_id)?;
|
|
||||||
ok_json(tree)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
delete,
|
|
||||||
path = "/api/folders/{id}",
|
|
||||||
params(("id" = Uuid, Path, description = "Folder ID")),
|
|
||||||
responses((status = 204, description = "Folder deleted")),
|
|
||||||
tag = "Folders"
|
|
||||||
)]
|
|
||||||
pub async fn delete_folder(
|
pub async fn delete_folder(
|
||||||
State(state): State<AppState>,
|
|
||||||
Path(folder_id): Path<Uuid>,
|
Path(folder_id): Path<Uuid>,
|
||||||
TenantScopedConn {
|
TenantScopedConn {
|
||||||
mut conn,
|
mut conn,
|
||||||
@@ -197,52 +347,206 @@ pub async fn delete_folder(
|
|||||||
..
|
..
|
||||||
}: TenantScopedConn,
|
}: TenantScopedConn,
|
||||||
) -> AppResult<StatusCode> {
|
) -> AppResult<StatusCode> {
|
||||||
FolderService::new(&state).delete_folder(&mut conn, tenant_id, folder_id)?;
|
conn.transaction::<_, AppError, _>(|conn| {
|
||||||
no_content()
|
folders::table
|
||||||
|
.find(folder_id)
|
||||||
|
.filter(folders::tenant_id.eq(tenant_id))
|
||||||
|
.first::<Folder>(conn)?;
|
||||||
|
|
||||||
|
let has_child_folders: bool = diesel::select(exists(
|
||||||
|
folders::table
|
||||||
|
.filter(folders::parent_id.eq(Some(folder_id)))
|
||||||
|
.filter(folders::tenant_id.eq(tenant_id)),
|
||||||
|
))
|
||||||
|
.get_result(conn)?;
|
||||||
|
|
||||||
|
if has_child_folders {
|
||||||
|
return Err(AppError::bad_request(
|
||||||
|
"folder must be empty before deletion",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let has_documents: bool = diesel::select(exists(
|
||||||
|
documents::table
|
||||||
|
.filter(documents::folder_id.eq(Some(folder_id)))
|
||||||
|
.filter(documents::tenant_id.eq(tenant_id))
|
||||||
|
.filter(documents::deleted_at.is_null()),
|
||||||
|
))
|
||||||
|
.get_result(conn)?;
|
||||||
|
|
||||||
|
if has_documents {
|
||||||
|
return Err(AppError::bad_request(
|
||||||
|
"folder must be empty before deletion",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
diesel::delete(
|
||||||
|
folders::table
|
||||||
|
.filter(folders::id.eq(folder_id))
|
||||||
|
.filter(folders::tenant_id.eq(tenant_id)),
|
||||||
|
)
|
||||||
|
.execute(conn)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
patch,
|
|
||||||
path = "/api/folders/{id}",
|
|
||||||
params(("id" = Uuid, Path, description = "Folder ID")),
|
|
||||||
request_body = UpdateFolderRequest,
|
|
||||||
responses((status = 204, description = "Folder updated")),
|
|
||||||
tag = "Folders"
|
|
||||||
)]
|
|
||||||
pub async fn update_folder(
|
pub async fn update_folder(
|
||||||
State(state): State<AppState>,
|
|
||||||
Path(folder_id): Path<Uuid>,
|
Path(folder_id): Path<Uuid>,
|
||||||
TenantScopedConn {
|
TenantScopedConn {
|
||||||
mut conn,
|
mut conn,
|
||||||
tenant_id,
|
tenant_id,
|
||||||
..
|
..
|
||||||
}: TenantScopedConn,
|
}: TenantScopedConn,
|
||||||
Json(payload): Json<UpdateFolderRequest>,
|
Json(body): Json<Value>,
|
||||||
) -> AppResult<StatusCode> {
|
) -> AppResult<StatusCode> {
|
||||||
FolderService::new(&state).update_folder(&mut conn, tenant_id, folder_id, payload)?;
|
if !body.is_object() {
|
||||||
no_content()
|
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)
|
||||||
|
.filter(folders::tenant_id.eq(tenant_id))
|
||||||
|
.first(conn)?;
|
||||||
|
|
||||||
|
let mut next_parent = folder.parent_id;
|
||||||
|
let mut parent_changed = false;
|
||||||
|
match parent_class {
|
||||||
|
NullableValue::Omitted => {}
|
||||||
|
NullableValue::Null => {
|
||||||
|
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"))?;
|
||||||
|
if parent_id == folder_id {
|
||||||
|
return Err(AppError::bad_request("folder cannot be its own parent"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let _parent: Folder = folders::table
|
||||||
|
.find(parent_id)
|
||||||
|
.filter(folders::tenant_id.eq(tenant_id))
|
||||||
|
.first(conn)?;
|
||||||
|
|
||||||
|
if folder.parent_id != Some(parent_id) {
|
||||||
|
let descendant_ids = gather_descendant_folder_ids(conn, tenant_id, folder_id)?;
|
||||||
|
if descendant_ids.contains(&parent_id) {
|
||||||
|
return Err(AppError::bad_request(
|
||||||
|
"cannot move folder into itself or a descendant",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
parent_changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
next_parent = Some(parent_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut new_name = folder.name.clone();
|
||||||
|
let mut name_changed = false;
|
||||||
|
match name_class {
|
||||||
|
NullableValue::Omitted => {}
|
||||||
|
NullableValue::Null => {
|
||||||
|
return Err(AppError::bad_request("name cannot be null"));
|
||||||
|
}
|
||||||
|
NullableValue::String(value) => {
|
||||||
|
let trimmed = value.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Err(AppError::bad_request("name must not be empty"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if trimmed != folder.name {
|
||||||
|
new_name = trimmed.to_string();
|
||||||
|
name_changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !parent_changed && !name_changed {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let conflict = if let Some(parent_id) = next_parent {
|
||||||
|
folders::table
|
||||||
|
.filter(folders::parent_id.eq(Some(parent_id)))
|
||||||
|
.filter(folders::name.eq(&new_name))
|
||||||
|
.filter(folders::id.ne(folder_id))
|
||||||
|
.filter(folders::tenant_id.eq(tenant_id))
|
||||||
|
.first::<Folder>(conn)
|
||||||
|
.optional()?
|
||||||
|
} else {
|
||||||
|
folders::table
|
||||||
|
.filter(folders::parent_id.is_null())
|
||||||
|
.filter(folders::name.eq(&new_name))
|
||||||
|
.filter(folders::id.ne(folder_id))
|
||||||
|
.filter(folders::tenant_id.eq(tenant_id))
|
||||||
|
.first::<Folder>(conn)
|
||||||
|
.optional()?
|
||||||
|
};
|
||||||
|
|
||||||
|
if conflict.is_some() {
|
||||||
|
return Err(AppError::bad_request(
|
||||||
|
"a folder with the same name already exists in the target",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
diesel::update(
|
||||||
|
folders::table
|
||||||
|
.find(folder_id)
|
||||||
|
.filter(folders::tenant_id.eq(tenant_id)),
|
||||||
|
)
|
||||||
|
.set((
|
||||||
|
folders::parent_id.eq(next_parent),
|
||||||
|
folders::name.eq(&new_name),
|
||||||
|
))
|
||||||
|
.execute(conn)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(OpenApi)]
|
fn folder_to_info(folder: Folder) -> FolderInfo {
|
||||||
#[openapi(
|
FolderInfo {
|
||||||
paths(
|
id: folder.id,
|
||||||
crate::routes::folders::create_folder,
|
name: folder.name,
|
||||||
crate::routes::folders::ensure_folder_path,
|
parent_id: folder.parent_id,
|
||||||
crate::routes::folders::get_folder,
|
created_at: to_iso(folder.created_at),
|
||||||
crate::routes::folders::list_folder_contents,
|
updated_at: to_iso(folder.updated_at),
|
||||||
crate::routes::folders::list_folder_tree,
|
}
|
||||||
crate::routes::folders::delete_folder,
|
}
|
||||||
crate::routes::folders::update_folder
|
|
||||||
),
|
pub(super) fn gather_descendant_folder_ids(
|
||||||
components(schemas(
|
conn: &mut PgConnection,
|
||||||
crate::services::folders::CreateFolderRequest,
|
tenant_id: Uuid,
|
||||||
crate::services::folders::EnsureFolderPathRequest,
|
folder_id: Uuid,
|
||||||
crate::routes::folders::FolderResponse,
|
) -> AppResult<Vec<Uuid>> {
|
||||||
crate::services::folders::FolderInfo,
|
let mut ids = vec![folder_id];
|
||||||
crate::services::folders::FolderContentsQuery,
|
let mut queue = vec![folder_id];
|
||||||
crate::routes::folders::FolderContentsResponse,
|
|
||||||
crate::services::folders::FolderTreeNode,
|
while let Some(current) = queue.pop() {
|
||||||
crate::services::folders::UpdateFolderRequest
|
let child_ids: Vec<Uuid> = folders::table
|
||||||
))
|
.filter(folders::parent_id.eq(Some(current)))
|
||||||
)]
|
.filter(folders::tenant_id.eq(tenant_id))
|
||||||
pub struct FoldersApiDoc;
|
.select(folders::id)
|
||||||
|
.load(conn)?;
|
||||||
|
queue.extend(child_ids.iter().copied());
|
||||||
|
ids.extend(child_ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ids)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,46 +1,6 @@
|
|||||||
use axum::{extract::State, http::StatusCode, response::Json};
|
use axum::{http::StatusCode, response::Json};
|
||||||
use diesel::RunQueryDsl;
|
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::state::AppState;
|
pub async fn health_check() -> (StatusCode, Json<serde_json::Value>) {
|
||||||
|
(StatusCode::OK, Json(json!({ "status": "ok" })))
|
||||||
#[derive(utoipa::OpenApi)]
|
|
||||||
#[openapi(paths(crate::routes::health::health_check))]
|
|
||||||
pub struct HealthApiDoc;
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/api/health",
|
|
||||||
responses((status = 200, description = "Service is healthy")),
|
|
||||||
tag = "Health"
|
|
||||||
)]
|
|
||||||
pub async fn health_check(State(state): State<AppState>) -> (StatusCode, Json<serde_json::Value>) {
|
|
||||||
let database_ok = match state.db_unscoped() {
|
|
||||||
Ok(mut conn) => diesel::sql_query("SELECT 1")
|
|
||||||
.execute(&mut conn)
|
|
||||||
.map(|_| true)
|
|
||||||
.unwrap_or_else(|err| {
|
|
||||||
tracing::error!(error = ?err, "health check database ping failed");
|
|
||||||
false
|
|
||||||
}),
|
|
||||||
Err(err) => {
|
|
||||||
tracing::error!(error = ?err, "health check database connection failed");
|
|
||||||
false
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let status = if database_ok {
|
|
||||||
StatusCode::OK
|
|
||||||
} else {
|
|
||||||
StatusCode::SERVICE_UNAVAILABLE
|
|
||||||
};
|
|
||||||
|
|
||||||
let payload = json!({
|
|
||||||
"status": if database_ok { "ok" } else { "error" },
|
|
||||||
"checks": {
|
|
||||||
"database": if database_ok { "ok" } else { "unavailable" }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
(status, Json(payload))
|
|
||||||
}
|
}
|
||||||
|
|||||||
+57
-382
@@ -2,7 +2,7 @@ use axum::http::HeaderValue;
|
|||||||
use axum::{
|
use axum::{
|
||||||
extract::DefaultBodyLimit,
|
extract::DefaultBodyLimit,
|
||||||
middleware,
|
middleware,
|
||||||
response::{Html, Json},
|
response::Json,
|
||||||
routing::{delete, get, patch, post},
|
routing::{delete, get, patch, post},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
@@ -13,22 +13,14 @@ use tower_http::{
|
|||||||
};
|
};
|
||||||
use utoipa::OpenApi;
|
use utoipa::OpenApi;
|
||||||
|
|
||||||
use crate::{
|
use crate::{auth::AuthenticatedUser, openapi::ApiDoc, state::AppState};
|
||||||
auth::{capability_guard::RequireCapabilitiesLayer, AuthenticatedUser},
|
|
||||||
models::ApiCapability,
|
|
||||||
openapi::ApiDoc,
|
|
||||||
state::AppState,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod capability_sets;
|
|
||||||
pub mod correspondents;
|
pub mod correspondents;
|
||||||
pub mod documents;
|
pub mod documents;
|
||||||
pub mod folders;
|
pub mod folders;
|
||||||
pub mod health;
|
pub mod health;
|
||||||
pub mod profile;
|
|
||||||
pub mod tags;
|
pub mod tags;
|
||||||
pub mod tenants;
|
|
||||||
pub mod webdav;
|
pub mod webdav;
|
||||||
|
|
||||||
pub fn create_router(state: AppState) -> Router<()> {
|
pub fn create_router(state: AppState) -> Router<()> {
|
||||||
@@ -61,401 +53,115 @@ pub fn create_router(state: AppState) -> Router<()> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let auth_routes = Router::new()
|
let auth_routes = Router::new()
|
||||||
.route("/signup/start", post(auth::signup_start))
|
|
||||||
.route("/signup/finish", post(auth::signup_finish))
|
|
||||||
.route("/login", post(auth::login))
|
.route("/login", post(auth::login))
|
||||||
.route("/exchange-api-token", post(auth::api_token_exchange))
|
|
||||||
.route("/refresh", post(auth::refresh))
|
.route("/refresh", post(auth::refresh))
|
||||||
.route("/logout", post(auth::logout))
|
.route("/logout", post(auth::logout))
|
||||||
.route("/select-tenant", post(auth::select_tenant))
|
.route("/select-tenant", post(auth::select_tenant))
|
||||||
.route(
|
.route("/tenants", get(auth::list_tenants))
|
||||||
"/passkeys/register/start",
|
|
||||||
post(auth::passkey_register_start),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/passkeys/register/finish",
|
|
||||||
post(auth::passkey_register_finish),
|
|
||||||
)
|
|
||||||
.route("/passkeys/login/start", post(auth::passkey_login_start))
|
|
||||||
.route("/passkeys/login/finish", post(auth::passkey_login_finish))
|
|
||||||
.route("/me", get(auth::me));
|
.route("/me", get(auth::me));
|
||||||
|
|
||||||
let documents_routes = Router::new()
|
let documents_routes = Router::new()
|
||||||
.route(
|
.route("/check", get(documents::check_document))
|
||||||
"/check",
|
|
||||||
get(documents::check_document).layer(RequireCapabilitiesLayer::all([
|
|
||||||
ApiCapability::DocumentsRead,
|
|
||||||
])),
|
|
||||||
)
|
|
||||||
.route(
|
.route(
|
||||||
"/",
|
"/",
|
||||||
get(documents::list_documents).layer(RequireCapabilitiesLayer::all([
|
get(documents::list_documents).post(documents::upload_document),
|
||||||
ApiCapability::DocumentsRead,
|
|
||||||
])),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/",
|
|
||||||
post(documents::upload_document).layer(RequireCapabilitiesLayer::all([
|
|
||||||
ApiCapability::DocumentsWrite,
|
|
||||||
ApiCapability::DocumentsUpload,
|
|
||||||
])),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/bulk/move",
|
|
||||||
post(documents::bulk_move_documents).layer(RequireCapabilitiesLayer::all([
|
|
||||||
ApiCapability::DocumentsEdit,
|
|
||||||
])),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/bulk/tags",
|
|
||||||
post(documents::bulk_update_tags).layer(RequireCapabilitiesLayer::all([
|
|
||||||
ApiCapability::DocumentsEdit,
|
|
||||||
])),
|
|
||||||
)
|
)
|
||||||
|
.route("/bulk/move", post(documents::bulk_move_documents))
|
||||||
|
.route("/bulk/tags", post(documents::bulk_update_tags))
|
||||||
.route(
|
.route(
|
||||||
"/bulk/correspondents",
|
"/bulk/correspondents",
|
||||||
post(documents::bulk_assign_correspondents).layer(RequireCapabilitiesLayer::all([
|
post(documents::bulk_assign_correspondents),
|
||||||
ApiCapability::DocumentsEdit,
|
|
||||||
])),
|
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/bulk/reanalyze",
|
"/bulk/reanalyze",
|
||||||
post(documents::reanalyze_selected_documents).layer(RequireCapabilitiesLayer::all([
|
post(documents::reanalyze_selected_documents),
|
||||||
ApiCapability::DocumentsWrite,
|
|
||||||
])),
|
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/{id}",
|
"/:id",
|
||||||
get(documents::get_document).layer(RequireCapabilitiesLayer::all([
|
get(documents::get_document)
|
||||||
ApiCapability::DocumentsRead,
|
.delete(documents::delete_document)
|
||||||
])),
|
.patch(documents::update_document),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/{id}/download",
|
"/:id/assets",
|
||||||
post(documents::refresh_document_download).layer(RequireCapabilitiesLayer::all([
|
get(documents::list_document_assets).post(documents::request_document_assets),
|
||||||
ApiCapability::DocumentsRead,
|
)
|
||||||
])),
|
.route("/:id/folder", patch(documents::move_document))
|
||||||
|
.route("/:id/versions", get(documents::list_document_versions))
|
||||||
|
.route(
|
||||||
|
"/:id/versions/:version_id",
|
||||||
|
get(documents::get_document_version),
|
||||||
|
)
|
||||||
|
.route("/:id/restore", post(documents::restore_document))
|
||||||
|
.route("/:id/tags", post(documents::assign_tags))
|
||||||
|
.route("/:id/tags/:tag_id", delete(documents::remove_tag))
|
||||||
|
.route(
|
||||||
|
"/:id/correspondents",
|
||||||
|
post(documents::assign_correspondents),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/{id}/trash",
|
"/:id/correspondents/:correspondent_id",
|
||||||
post(documents::trash_document).layer(RequireCapabilitiesLayer::all([
|
delete(documents::remove_correspondent),
|
||||||
ApiCapability::DocumentsWrite,
|
|
||||||
])),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/{id}",
|
|
||||||
delete(documents::delete_document).layer(RequireCapabilitiesLayer::all([
|
|
||||||
ApiCapability::DocumentsWrite,
|
|
||||||
])),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/{id}",
|
|
||||||
patch(documents::update_document).layer(RequireCapabilitiesLayer::all([
|
|
||||||
ApiCapability::DocumentsEdit,
|
|
||||||
])),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/{id}/assets",
|
|
||||||
get(documents::list_document_assets).layer(RequireCapabilitiesLayer::all([
|
|
||||||
ApiCapability::DocumentsRead,
|
|
||||||
])),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/{id}/assets",
|
|
||||||
post(documents::request_document_assets).layer(RequireCapabilitiesLayer::all([
|
|
||||||
ApiCapability::DocumentsWrite,
|
|
||||||
])),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/{id}/folder",
|
|
||||||
patch(documents::move_document).layer(RequireCapabilitiesLayer::all([
|
|
||||||
ApiCapability::DocumentsEdit,
|
|
||||||
])),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/{id}/versions",
|
|
||||||
get(documents::list_document_versions).layer(RequireCapabilitiesLayer::all([
|
|
||||||
ApiCapability::DocumentsRead,
|
|
||||||
])),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/{id}/versions/{version_id}",
|
|
||||||
get(documents::get_document_version).layer(RequireCapabilitiesLayer::all([
|
|
||||||
ApiCapability::DocumentsRead,
|
|
||||||
])),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/{id}/versions/{version_id}/download",
|
|
||||||
post(documents::refresh_document_version_download).layer(
|
|
||||||
RequireCapabilitiesLayer::all([ApiCapability::DocumentsRead]),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/{id}/restore",
|
|
||||||
post(documents::restore_document).layer(RequireCapabilitiesLayer::all([
|
|
||||||
ApiCapability::DocumentsEdit,
|
|
||||||
])),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/{id}/tags",
|
|
||||||
post(documents::assign_tags).layer(RequireCapabilitiesLayer::all([
|
|
||||||
ApiCapability::DocumentsEdit,
|
|
||||||
])),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/{id}/tags/{tag_id}",
|
|
||||||
delete(documents::remove_tag).layer(RequireCapabilitiesLayer::all([
|
|
||||||
ApiCapability::DocumentsEdit,
|
|
||||||
])),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/{id}/correspondents",
|
|
||||||
post(documents::assign_correspondents).layer(RequireCapabilitiesLayer::all([
|
|
||||||
ApiCapability::DocumentsEdit,
|
|
||||||
])),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/{id}/correspondents/{correspondent_id}",
|
|
||||||
delete(documents::remove_correspondent).layer(RequireCapabilitiesLayer::all([
|
|
||||||
ApiCapability::DocumentsEdit,
|
|
||||||
])),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let download_routes =
|
let download_routes =
|
||||||
Router::new().route("/api/download/{token}", get(documents::download_with_token));
|
Router::new().route("/download/:token", get(documents::download_with_token));
|
||||||
|
|
||||||
let folders_routes = Router::new()
|
let folders_routes = Router::new()
|
||||||
.route(
|
.route("/", post(folders::create_folder))
|
||||||
"/",
|
.route("/path", post(folders::ensure_folder_path))
|
||||||
post(folders::create_folder)
|
.route("/:id", get(folders::get_folder))
|
||||||
.layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersWrite])),
|
.route("/:id", delete(folders::delete_folder))
|
||||||
)
|
.route("/:id", patch(folders::update_folder))
|
||||||
.route(
|
.route("/:id/contents", get(folders::list_folder_contents));
|
||||||
"/path",
|
|
||||||
post(folders::ensure_folder_path)
|
|
||||||
.layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersWrite])),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/tree",
|
|
||||||
get(folders::list_folder_tree)
|
|
||||||
.layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersRead])),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/{id}",
|
|
||||||
get(folders::get_folder)
|
|
||||||
.layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersRead])),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/{id}",
|
|
||||||
delete(folders::delete_folder)
|
|
||||||
.layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersWrite])),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/{id}",
|
|
||||||
patch(folders::update_folder)
|
|
||||||
.layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersEdit])),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/{id}/contents",
|
|
||||||
get(folders::list_folder_contents)
|
|
||||||
.layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersRead])),
|
|
||||||
);
|
|
||||||
|
|
||||||
let tags_routes = Router::new()
|
let tags_routes = Router::new()
|
||||||
.route(
|
.route("/", get(tags::list_tags).post(tags::create_tag))
|
||||||
"/",
|
.route("/:id", patch(tags::update_tag).delete(tags::delete_tag));
|
||||||
get(tags::list_tags).layer(RequireCapabilitiesLayer::all([ApiCapability::TagsRead])),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/",
|
|
||||||
post(tags::create_tag).layer(RequireCapabilitiesLayer::all([ApiCapability::TagsWrite])),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/{id}",
|
|
||||||
patch(tags::update_tag).layer(RequireCapabilitiesLayer::all([ApiCapability::TagsEdit])),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/{id}",
|
|
||||||
delete(tags::delete_tag)
|
|
||||||
.layer(RequireCapabilitiesLayer::all([ApiCapability::TagsWrite])),
|
|
||||||
);
|
|
||||||
|
|
||||||
let correspondents_routes = Router::new()
|
let correspondents_routes = Router::new()
|
||||||
.route(
|
.route(
|
||||||
"/",
|
"/",
|
||||||
get(correspondents::list_correspondents).layer(RequireCapabilitiesLayer::all([
|
get(correspondents::list_correspondents).post(correspondents::create_correspondent),
|
||||||
ApiCapability::CorrespondentsRead,
|
|
||||||
])),
|
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/",
|
"/:id",
|
||||||
post(correspondents::create_correspondent).layer(RequireCapabilitiesLayer::all([
|
patch(correspondents::update_correspondent)
|
||||||
ApiCapability::CorrespondentsWrite,
|
.delete(correspondents::delete_correspondent),
|
||||||
])),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/{id}",
|
|
||||||
patch(correspondents::update_correspondent).layer(RequireCapabilitiesLayer::all([
|
|
||||||
ApiCapability::CorrespondentsEdit,
|
|
||||||
])),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/{id}",
|
|
||||||
delete(correspondents::delete_correspondent).layer(RequireCapabilitiesLayer::all([
|
|
||||||
ApiCapability::CorrespondentsWrite,
|
|
||||||
])),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let profile_routes = Router::new()
|
|
||||||
.route(
|
|
||||||
"/api-tokens",
|
|
||||||
get(profile::list_api_tokens)
|
|
||||||
.layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileRead])),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/api-tokens",
|
|
||||||
post(profile::create_api_token)
|
|
||||||
.layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileWrite])),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/api-tokens/{id}/regenerate",
|
|
||||||
post(profile::regenerate_api_token)
|
|
||||||
.layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileWrite])),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/api-tokens/{id}",
|
|
||||||
delete(profile::delete_api_token)
|
|
||||||
.layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileWrite])),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/passkeys",
|
|
||||||
get(profile::list_passkeys)
|
|
||||||
.layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileRead])),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/passkeys/{id}",
|
|
||||||
delete(profile::delete_passkey)
|
|
||||||
.layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileWrite])),
|
|
||||||
);
|
|
||||||
|
|
||||||
let capability_sets_routes = Router::new()
|
|
||||||
.route(
|
|
||||||
"/",
|
|
||||||
get(capability_sets::list_capability_sets).layer(RequireCapabilitiesLayer::all([
|
|
||||||
ApiCapability::CapabilitySetsRead,
|
|
||||||
])),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/",
|
|
||||||
post(capability_sets::create_capability_set).layer(RequireCapabilitiesLayer::all([
|
|
||||||
ApiCapability::CapabilitySetsWrite,
|
|
||||||
])),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/{id}",
|
|
||||||
get(capability_sets::get_capability_set).layer(RequireCapabilitiesLayer::all([
|
|
||||||
ApiCapability::CapabilitySetsRead,
|
|
||||||
])),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/{id}",
|
|
||||||
patch(capability_sets::update_capability_set).layer(RequireCapabilitiesLayer::all([
|
|
||||||
ApiCapability::CapabilitySetsWrite,
|
|
||||||
])),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/{id}",
|
|
||||||
delete(capability_sets::delete_capability_set).layer(RequireCapabilitiesLayer::all([
|
|
||||||
ApiCapability::CapabilitySetsWrite,
|
|
||||||
])),
|
|
||||||
);
|
|
||||||
|
|
||||||
let capabilities_routes = Router::new().route(
|
|
||||||
"/",
|
|
||||||
get(capability_sets::list_capabilities).layer(RequireCapabilitiesLayer::all([
|
|
||||||
ApiCapability::CapabilitySetsRead,
|
|
||||||
])),
|
|
||||||
);
|
|
||||||
|
|
||||||
let protected_state = state.clone();
|
let protected_state = state.clone();
|
||||||
let assets_routes = Router::new()
|
let assets_routes = Router::new().route("/:asset_id", get(documents::get_document_asset));
|
||||||
.route(
|
|
||||||
"/{asset_id}",
|
|
||||||
get(documents::get_document_asset).layer(RequireCapabilitiesLayer::all([
|
|
||||||
ApiCapability::DocumentsRead,
|
|
||||||
])),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/{asset_id}/download",
|
|
||||||
post(documents::refresh_asset_download).layer(RequireCapabilitiesLayer::all([
|
|
||||||
ApiCapability::DocumentsRead,
|
|
||||||
])),
|
|
||||||
);
|
|
||||||
|
|
||||||
let manage_tenants_layer = RequireCapabilitiesLayer::all([ApiCapability::TenantsWrite]);
|
|
||||||
let tenants_routes = Router::new()
|
|
||||||
.route("/", get(tenants::list_tenants))
|
|
||||||
.route("/{tenant_id}", get(tenants::get_tenant))
|
|
||||||
.route(
|
|
||||||
"/{tenant_id}",
|
|
||||||
patch(tenants::update_tenant).layer(manage_tenants_layer.clone()),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/{tenant_id}/users",
|
|
||||||
get(tenants::list_tenant_users).layer(manage_tenants_layer.clone()),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/{tenant_id}/users/{user_id}",
|
|
||||||
get(tenants::get_tenant_user).layer(manage_tenants_layer.clone()),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/{tenant_id}/users/{user_id}",
|
|
||||||
patch(tenants::update_tenant_user).layer(manage_tenants_layer.clone()),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/{tenant_id}/users/{user_id}",
|
|
||||||
delete(tenants::delete_tenant_user).layer(manage_tenants_layer.clone()),
|
|
||||||
);
|
|
||||||
|
|
||||||
let protected_routes = Router::new()
|
let protected_routes = Router::new()
|
||||||
.nest("/api/documents", documents_routes)
|
.nest("/api/documents", documents_routes)
|
||||||
.nest("/api/folders", folders_routes)
|
.nest("/api/folders", folders_routes)
|
||||||
.nest("/api/tags", tags_routes)
|
.nest("/api/tags", tags_routes)
|
||||||
.nest("/api/correspondents", correspondents_routes)
|
.nest("/api/correspondents", correspondents_routes)
|
||||||
.nest("/api/profile", profile_routes)
|
|
||||||
.nest("/api/capability-sets", capability_sets_routes)
|
|
||||||
.nest("/api/capabilities", capabilities_routes)
|
|
||||||
.nest("/api/assets", assets_routes)
|
.nest("/api/assets", assets_routes)
|
||||||
.nest("/api/tenants", tenants_routes)
|
|
||||||
.layer(middleware::from_extractor_with_state::<AuthenticatedUser, _>(protected_state));
|
.layer(middleware::from_extractor_with_state::<AuthenticatedUser, _>(protected_state));
|
||||||
|
|
||||||
let upload_limit = state.config.upload_body_limit_bytes;
|
let openapi_arc = Arc::new(ApiDoc::openapi());
|
||||||
|
let docs_route = Router::new().route(
|
||||||
let openapi_spec = Arc::new(ApiDoc::openapi());
|
"/api/docs/openapi.json",
|
||||||
let docs_router = Router::new()
|
get({
|
||||||
.route(
|
let spec = openapi_arc.clone();
|
||||||
"/api/docs",
|
move || {
|
||||||
get(move || async { Html(render_swagger_ui("/api/docs/openapi.json")) }),
|
let spec = spec.clone();
|
||||||
)
|
async move { Json((*spec).clone()) }
|
||||||
.route(
|
}
|
||||||
"/api/docs/openapi.json",
|
}),
|
||||||
get({
|
);
|
||||||
let spec = openapi_spec.clone();
|
|
||||||
move || async move { Json((*spec).clone()) }
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
Router::new()
|
Router::new()
|
||||||
.merge(download_routes)
|
.merge(download_routes)
|
||||||
.merge(protected_routes)
|
.merge(protected_routes)
|
||||||
.merge(docs_router)
|
.merge(docs_route)
|
||||||
.nest("/api/auth", auth_routes)
|
.nest("/api/auth", auth_routes)
|
||||||
.route("/api/health", get(health::health_check))
|
.route("/api/health", get(health::health_check))
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
.layer(cors)
|
.layer(cors)
|
||||||
.layer(DefaultBodyLimit::max(
|
.layer(DefaultBodyLimit::max(1024 * 1024 * 512))
|
||||||
usize::try_from(upload_limit).unwrap_or(usize::MAX),
|
|
||||||
))
|
|
||||||
.layer(
|
.layer(
|
||||||
TraceLayer::new_for_http()
|
TraceLayer::new_for_http()
|
||||||
.make_span_with(DefaultMakeSpan::new().level(tracing::Level::INFO))
|
.make_span_with(DefaultMakeSpan::new().level(tracing::Level::INFO))
|
||||||
@@ -463,34 +169,3 @@ pub fn create_router(state: AppState) -> Router<()> {
|
|||||||
.on_failure(DefaultOnFailure::new().level(tracing::Level::ERROR)),
|
.on_failure(DefaultOnFailure::new().level(tracing::Level::ERROR)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_swagger_ui(spec_url: &str) -> String {
|
|
||||||
format!(
|
|
||||||
r#"<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<title>Papercrate API Docs</title>
|
|
||||||
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
|
|
||||||
<style>
|
|
||||||
html {{ box-sizing: border-box; font-family: sans-serif; }}
|
|
||||||
*, *:before, *:after {{ box-sizing: inherit; }}
|
|
||||||
body {{ margin: 0; background: #fafafa; }}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="swagger-ui"></div>
|
|
||||||
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
|
||||||
<script>
|
|
||||||
window.addEventListener('load', () => {{
|
|
||||||
window.ui = SwaggerUIBundle({{
|
|
||||||
url: '{spec_url}',
|
|
||||||
dom_id: '#swagger-ui',
|
|
||||||
deepLinking: true,
|
|
||||||
}});
|
|
||||||
}});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>"#
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,150 +0,0 @@
|
|||||||
use axum::{
|
|
||||||
extract::{Path, Query, State},
|
|
||||||
http::StatusCode,
|
|
||||||
Json,
|
|
||||||
};
|
|
||||||
use utoipa::OpenApi;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
auth::{passkeys::PasskeySummary, TenantScopedConn},
|
|
||||||
error::AppResult,
|
|
||||||
http::responders::JsonResponse,
|
|
||||||
services::profile::{
|
|
||||||
ApiTokenCreatedResponse, ApiTokenResponse, CreateApiTokenRequest, ProfileService,
|
|
||||||
RevokePasskeyQuery,
|
|
||||||
},
|
|
||||||
state::AppState,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/api/profile/passkeys",
|
|
||||||
responses((status = 200, description = "List registered passkeys", body = [PasskeySummary])),
|
|
||||||
tag = "Profile"
|
|
||||||
)]
|
|
||||||
pub async fn list_passkeys(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
TenantScopedConn {
|
|
||||||
mut conn, user_id, ..
|
|
||||||
}: TenantScopedConn,
|
|
||||||
) -> AppResult<JsonResponse<Vec<PasskeySummary>>> {
|
|
||||||
ProfileService::new(&state).list_passkeys(&mut conn, user_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/api/profile/api-tokens",
|
|
||||||
responses((status = 200, description = "List API tokens", body = [ApiTokenResponse])),
|
|
||||||
tag = "Profile"
|
|
||||||
)]
|
|
||||||
pub async fn list_api_tokens(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
TenantScopedConn {
|
|
||||||
mut conn,
|
|
||||||
tenant_id,
|
|
||||||
user_id,
|
|
||||||
..
|
|
||||||
}: TenantScopedConn,
|
|
||||||
) -> AppResult<JsonResponse<Vec<ApiTokenResponse>>> {
|
|
||||||
ProfileService::new(&state).list_api_tokens(&mut conn, tenant_id, user_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/profile/api-tokens",
|
|
||||||
request_body = CreateApiTokenRequest,
|
|
||||||
responses((status = 201, description = "API token created", body = ApiTokenCreatedResponse)),
|
|
||||||
tag = "Profile"
|
|
||||||
)]
|
|
||||||
pub async fn create_api_token(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
TenantScopedConn {
|
|
||||||
mut conn,
|
|
||||||
tenant_id,
|
|
||||||
user_id,
|
|
||||||
..
|
|
||||||
}: TenantScopedConn,
|
|
||||||
Json(payload): Json<CreateApiTokenRequest>,
|
|
||||||
) -> AppResult<JsonResponse<ApiTokenCreatedResponse>> {
|
|
||||||
ProfileService::new(&state).create_api_token(&mut conn, tenant_id, user_id, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/profile/api-tokens/{id}/regenerate",
|
|
||||||
params(("id" = Uuid, Path, description = "API token ID")),
|
|
||||||
responses((status = 200, description = "API token regenerated", body = ApiTokenCreatedResponse)),
|
|
||||||
tag = "Profile"
|
|
||||||
)]
|
|
||||||
pub async fn regenerate_api_token(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
TenantScopedConn {
|
|
||||||
mut conn,
|
|
||||||
tenant_id,
|
|
||||||
user_id,
|
|
||||||
..
|
|
||||||
}: TenantScopedConn,
|
|
||||||
Path(token_id): Path<Uuid>,
|
|
||||||
) -> AppResult<JsonResponse<ApiTokenCreatedResponse>> {
|
|
||||||
ProfileService::new(&state).regenerate_api_token(&mut conn, tenant_id, user_id, token_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
delete,
|
|
||||||
path = "/api/profile/api-tokens/{id}",
|
|
||||||
params(("id" = Uuid, Path, description = "API token ID")),
|
|
||||||
responses((status = 204, description = "API token revoked")),
|
|
||||||
tag = "Profile"
|
|
||||||
)]
|
|
||||||
pub async fn delete_api_token(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
TenantScopedConn {
|
|
||||||
mut conn, user_id, ..
|
|
||||||
}: TenantScopedConn,
|
|
||||||
Path(token_id): Path<Uuid>,
|
|
||||||
) -> AppResult<StatusCode> {
|
|
||||||
ProfileService::new(&state).delete_api_token(&mut conn, user_id, token_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
delete,
|
|
||||||
path = "/api/profile/passkeys/{id}",
|
|
||||||
params(
|
|
||||||
("id" = Uuid, Path, description = "Passkey ID"),
|
|
||||||
("reason" = Option<String>, Query, description = "Optional reason for revoking the passkey")
|
|
||||||
),
|
|
||||||
responses((status = 204, description = "Passkey revoked")),
|
|
||||||
tag = "Profile"
|
|
||||||
)]
|
|
||||||
pub async fn delete_passkey(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
TenantScopedConn {
|
|
||||||
mut conn, user_id, ..
|
|
||||||
}: TenantScopedConn,
|
|
||||||
Path(passkey_id): Path<Uuid>,
|
|
||||||
Query(query): Query<RevokePasskeyQuery>,
|
|
||||||
) -> AppResult<StatusCode> {
|
|
||||||
ProfileService::new(&state).delete_passkey(&mut conn, user_id, passkey_id, query.reason)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(OpenApi)]
|
|
||||||
#[openapi(
|
|
||||||
paths(
|
|
||||||
crate::routes::profile::list_api_tokens,
|
|
||||||
crate::routes::profile::create_api_token,
|
|
||||||
crate::routes::profile::regenerate_api_token,
|
|
||||||
crate::routes::profile::delete_api_token,
|
|
||||||
crate::routes::profile::list_passkeys,
|
|
||||||
crate::routes::profile::delete_passkey
|
|
||||||
),
|
|
||||||
components(schemas(
|
|
||||||
crate::models::ApiCapability,
|
|
||||||
crate::services::profile::ApiTokenResponse,
|
|
||||||
crate::services::profile::ApiTokenCreatedResponse,
|
|
||||||
crate::services::profile::CreateApiTokenRequest,
|
|
||||||
crate::services::profile::RevokePasskeyQuery,
|
|
||||||
crate::auth::passkeys::PasskeySummary
|
|
||||||
))
|
|
||||||
)]
|
|
||||||
pub struct ProfileApiDoc;
|
|
||||||
+66
-146
@@ -1,26 +1,20 @@
|
|||||||
|
use crate::utils::json::{classify_nullable, NullableValue};
|
||||||
use axum::{extract::Path, http::StatusCode, Json};
|
use axum::{extract::Path, http::StatusCode, Json};
|
||||||
use diesel::{dsl::count_star, prelude::*};
|
use diesel::{dsl::count_star, prelude::*};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use utoipa::ToSchema;
|
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::auth::TenantScopedConn;
|
||||||
auth::TenantScopedConn,
|
use crate::error::{AppError, AppResult};
|
||||||
error::{AppError, AppResult},
|
use crate::models::{NewTag, Tag};
|
||||||
http::responders::{no_content, ok_json, IntoAppResult, JsonResponse, RowsAffectedExt},
|
use crate::schema::{document_tags, tags};
|
||||||
models::{NewTag, Tag},
|
use crate::utils::db::{no_content, EnsureEntity, IntoJsonResponse};
|
||||||
schema::{document_tags, tags},
|
|
||||||
utils::{
|
|
||||||
json::deserialize_patch_field,
|
|
||||||
named_entity::{ensure_name_available, normalize_name},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Deserialize, ToSchema)]
|
#[derive(Deserialize)]
|
||||||
pub struct CreateTagRequest {
|
pub struct CreateTagRequest {
|
||||||
pub label: String,
|
pub label: String,
|
||||||
#[schema(nullable)]
|
|
||||||
pub color: Option<String>,
|
pub color: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,62 +25,21 @@ struct UpdateTagChangeset<'a> {
|
|||||||
color: Option<Option<&'a str>>,
|
color: Option<Option<&'a str>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[derive(Serialize)]
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn update_tag_request_deserializes_null_fields() {
|
|
||||||
let request: UpdateTagRequest = serde_json::from_value(json!({
|
|
||||||
"label": null,
|
|
||||||
"color": null
|
|
||||||
}))
|
|
||||||
.unwrap();
|
|
||||||
assert!(matches!(request.label, Some(None)));
|
|
||||||
assert!(matches!(request.color, Some(None)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn update_tag_request_omitted_fields_are_none() {
|
|
||||||
let request: UpdateTagRequest = serde_json::from_value(json!({})).unwrap();
|
|
||||||
assert!(request.label.is_none());
|
|
||||||
assert!(request.color.is_none());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
|
||||||
pub struct TagCatalogEntry {
|
pub struct TagCatalogEntry {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub label: String,
|
pub label: String,
|
||||||
#[schema(nullable)]
|
|
||||||
pub color: Option<String>,
|
pub color: Option<String>,
|
||||||
pub usage_count: i64,
|
pub usage_count: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Deserialize, ToSchema)]
|
|
||||||
pub struct UpdateTagRequest {
|
|
||||||
#[serde(default, deserialize_with = "deserialize_patch_field")]
|
|
||||||
#[schema(nullable, value_type = Option<String>)]
|
|
||||||
pub label: Option<Option<String>>,
|
|
||||||
#[serde(default, deserialize_with = "deserialize_patch_field")]
|
|
||||||
#[schema(nullable, value_type = Option<String>)]
|
|
||||||
pub color: Option<Option<String>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/api/tags",
|
|
||||||
responses((status = 200, description = "Tags", body = [TagCatalogEntry])),
|
|
||||||
tag = "Tags"
|
|
||||||
)]
|
|
||||||
pub async fn list_tags(
|
pub async fn list_tags(
|
||||||
TenantScopedConn {
|
TenantScopedConn {
|
||||||
mut conn,
|
mut conn,
|
||||||
tenant_id,
|
tenant_id,
|
||||||
..
|
..
|
||||||
}: TenantScopedConn,
|
}: TenantScopedConn,
|
||||||
) -> AppResult<JsonResponse<Vec<TagCatalogEntry>>> {
|
) -> AppResult<Json<Vec<TagCatalogEntry>>> {
|
||||||
let tag_list: Vec<Tag> = tags::table
|
let tag_list: Vec<Tag> = tags::table
|
||||||
.filter(tags::tenant_id.eq(tenant_id))
|
.filter(tags::tenant_id.eq(tenant_id))
|
||||||
.order(tags::label.asc())
|
.order(tags::label.asc())
|
||||||
@@ -110,16 +63,9 @@ pub async fn list_tags(
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
ok_json(response)
|
response.into_json()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/tags",
|
|
||||||
request_body = CreateTagRequest,
|
|
||||||
responses((status = 200, description = "Tag created", body = TagCatalogEntry)),
|
|
||||||
tag = "Tags"
|
|
||||||
)]
|
|
||||||
pub async fn create_tag(
|
pub async fn create_tag(
|
||||||
TenantScopedConn {
|
TenantScopedConn {
|
||||||
mut conn,
|
mut conn,
|
||||||
@@ -127,14 +73,14 @@ pub async fn create_tag(
|
|||||||
..
|
..
|
||||||
}: TenantScopedConn,
|
}: TenantScopedConn,
|
||||||
Json(payload): Json<CreateTagRequest>,
|
Json(payload): Json<CreateTagRequest>,
|
||||||
) -> AppResult<JsonResponse<TagCatalogEntry>> {
|
) -> AppResult<Json<TagCatalogEntry>> {
|
||||||
let label = normalize_name(&payload.label, || {
|
if payload.label.trim().is_empty() {
|
||||||
AppError::bad_request("label must not be empty")
|
return Err(AppError::bad_request("label must not be empty"));
|
||||||
})?;
|
}
|
||||||
|
|
||||||
let new_tag = NewTag {
|
let new_tag = NewTag {
|
||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
label: label.clone(),
|
label: payload.label.trim().to_string(),
|
||||||
color: payload.color,
|
color: payload.color,
|
||||||
tenant_id,
|
tenant_id,
|
||||||
};
|
};
|
||||||
@@ -157,24 +103,17 @@ pub async fn create_tag(
|
|||||||
.find(new_tag.id)
|
.find(new_tag.id)
|
||||||
.filter(tags::tenant_id.eq(tenant_id))
|
.filter(tags::tenant_id.eq(tenant_id))
|
||||||
.first(&mut conn)
|
.first(&mut conn)
|
||||||
.into_app_result()?;
|
.one()?;
|
||||||
|
|
||||||
ok_json(TagCatalogEntry {
|
TagCatalogEntry {
|
||||||
id: tag.id,
|
id: tag.id,
|
||||||
label: tag.label,
|
label: tag.label,
|
||||||
color: tag.color,
|
color: tag.color,
|
||||||
usage_count: 0,
|
usage_count: 0,
|
||||||
})
|
}
|
||||||
|
.into_json()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
patch,
|
|
||||||
path = "/api/tags/{id}",
|
|
||||||
params(("id" = Uuid, Path, description = "Tag ID")),
|
|
||||||
request_body = UpdateTagRequest,
|
|
||||||
responses((status = 200, description = "Tag updated", body = TagCatalogEntry)),
|
|
||||||
tag = "Tags"
|
|
||||||
)]
|
|
||||||
pub async fn update_tag(
|
pub async fn update_tag(
|
||||||
Path(tag_id): Path<Uuid>,
|
Path(tag_id): Path<Uuid>,
|
||||||
TenantScopedConn {
|
TenantScopedConn {
|
||||||
@@ -182,51 +121,55 @@ pub async fn update_tag(
|
|||||||
tenant_id,
|
tenant_id,
|
||||||
..
|
..
|
||||||
}: TenantScopedConn,
|
}: TenantScopedConn,
|
||||||
Json(payload): Json<UpdateTagRequest>,
|
Json(body): Json<Value>,
|
||||||
) -> AppResult<JsonResponse<TagCatalogEntry>> {
|
) -> AppResult<Json<TagCatalogEntry>> {
|
||||||
let existing: Tag = tags::table
|
let existing: Tag = tags::table
|
||||||
.find(tag_id)
|
.find(tag_id)
|
||||||
.filter(tags::tenant_id.eq(tenant_id))
|
.filter(tags::tenant_id.eq(tenant_id))
|
||||||
.first(&mut conn)
|
.first(&mut conn)
|
||||||
.into_app_result()?;
|
.one()?;
|
||||||
let UpdateTagRequest { label, color } = payload;
|
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)?;
|
||||||
|
|
||||||
if label.is_none() && color.is_none() {
|
if matches!(label_class, NullableValue::Omitted)
|
||||||
|
&& matches!(color_class, NullableValue::Omitted)
|
||||||
|
{
|
||||||
let usage_count: i64 = document_tags::table
|
let usage_count: i64 = document_tags::table
|
||||||
.filter(document_tags::tag_id.eq(tag_id))
|
.filter(document_tags::tag_id.eq(tag_id))
|
||||||
.select(count_star())
|
.select(count_star())
|
||||||
.first(&mut conn)?;
|
.first(&mut conn)?;
|
||||||
return ok_json(TagCatalogEntry {
|
return TagCatalogEntry {
|
||||||
id: existing.id,
|
id: existing.id,
|
||||||
label: existing.label.clone(),
|
label: existing.label.clone(),
|
||||||
color: existing.color.clone(),
|
color: existing.color.clone(),
|
||||||
usage_count,
|
usage_count,
|
||||||
});
|
}
|
||||||
|
.into_json();
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut new_label: Option<String> = None;
|
let mut new_label: Option<String> = None;
|
||||||
let mut label_changed = false;
|
let mut label_changed = false;
|
||||||
match label {
|
match label_class {
|
||||||
None => {}
|
NullableValue::Omitted => {}
|
||||||
Some(None) => {
|
NullableValue::Null => {
|
||||||
return Err(AppError::bad_request("label cannot be null"));
|
return Err(AppError::bad_request("label cannot be null"));
|
||||||
}
|
}
|
||||||
Some(Some(value)) => {
|
NullableValue::String(value) => {
|
||||||
let normalized =
|
let trimmed = value.trim();
|
||||||
normalize_name(&value, || AppError::bad_request("label must not be empty"))?;
|
if trimmed.is_empty() {
|
||||||
if normalized != existing.label {
|
return Err(AppError::bad_request("label must not be empty"));
|
||||||
ensure_name_available(
|
}
|
||||||
|| {
|
if trimmed != existing.label {
|
||||||
tags::table
|
let duplicate = tags::table
|
||||||
.filter(tags::label.eq(&normalized))
|
.filter(tags::label.eq(trimmed))
|
||||||
.filter(tags::id.ne(tag_id))
|
.filter(tags::id.ne(tag_id))
|
||||||
.filter(tags::tenant_id.eq(tenant_id))
|
.filter(tags::tenant_id.eq(tenant_id))
|
||||||
.first::<Tag>(&mut conn)
|
.first::<Tag>(&mut conn)
|
||||||
.optional()
|
.optional()?;
|
||||||
},
|
if duplicate.is_some() {
|
||||||
|| AppError::bad_request("tag label already exists"),
|
return Err(AppError::bad_request("tag label already exists"));
|
||||||
)?;
|
}
|
||||||
new_label = Some(normalized);
|
new_label = Some(trimmed.to_string());
|
||||||
label_changed = true;
|
label_changed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -234,13 +177,13 @@ pub async fn update_tag(
|
|||||||
|
|
||||||
let mut color_change: Option<Option<String>> = None;
|
let mut color_change: Option<Option<String>> = None;
|
||||||
let mut color_changed = false;
|
let mut color_changed = false;
|
||||||
match color {
|
match color_class {
|
||||||
None => {}
|
NullableValue::Omitted => {}
|
||||||
Some(None) => {
|
NullableValue::Null => {
|
||||||
color_change = Some(None);
|
color_change = Some(None);
|
||||||
color_changed = true;
|
color_changed = true;
|
||||||
}
|
}
|
||||||
Some(Some(value)) => {
|
NullableValue::String(value) => {
|
||||||
let trimmed = value.trim();
|
let trimmed = value.trim();
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
return Err(AppError::bad_request("color must not be empty"));
|
return Err(AppError::bad_request("color must not be empty"));
|
||||||
@@ -258,12 +201,12 @@ pub async fn update_tag(
|
|||||||
.filter(document_tags::tenant_id.eq(tenant_id))
|
.filter(document_tags::tenant_id.eq(tenant_id))
|
||||||
.select(count_star())
|
.select(count_star())
|
||||||
.first(&mut conn)?;
|
.first(&mut conn)?;
|
||||||
return ok_json(TagCatalogEntry {
|
return Ok(Json(TagCatalogEntry {
|
||||||
id: existing.id,
|
id: existing.id,
|
||||||
label: existing.label.clone(),
|
label: existing.label.clone(),
|
||||||
color: existing.color.clone(),
|
color: existing.color.clone(),
|
||||||
usage_count,
|
usage_count,
|
||||||
});
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
let changeset = UpdateTagChangeset {
|
let changeset = UpdateTagChangeset {
|
||||||
@@ -279,36 +222,28 @@ pub async fn update_tag(
|
|||||||
.filter(tags::tenant_id.eq(tenant_id)),
|
.filter(tags::tenant_id.eq(tenant_id)),
|
||||||
)
|
)
|
||||||
.set(&changeset)
|
.set(&changeset)
|
||||||
.execute(&mut conn)
|
.execute(&mut conn)?;
|
||||||
.into_app_result()?
|
|
||||||
.or_not_found()?;
|
|
||||||
|
|
||||||
let updated: Tag = tags::table
|
let updated: Tag = tags::table
|
||||||
.find(tag_id)
|
.find(tag_id)
|
||||||
.filter(tags::tenant_id.eq(tenant_id))
|
.filter(tags::tenant_id.eq(tenant_id))
|
||||||
.first(&mut conn)
|
.first(&mut conn)
|
||||||
.into_app_result()?;
|
.one()?;
|
||||||
let usage_count: i64 = document_tags::table
|
let usage_count: i64 = document_tags::table
|
||||||
.filter(document_tags::tag_id.eq(tag_id))
|
.filter(document_tags::tag_id.eq(tag_id))
|
||||||
.filter(document_tags::tenant_id.eq(tenant_id))
|
.filter(document_tags::tenant_id.eq(tenant_id))
|
||||||
.select(count_star())
|
.select(count_star())
|
||||||
.first(&mut conn)?;
|
.first(&mut conn)?;
|
||||||
|
|
||||||
ok_json(TagCatalogEntry {
|
TagCatalogEntry {
|
||||||
id: updated.id,
|
id: updated.id,
|
||||||
label: updated.label,
|
label: updated.label,
|
||||||
color: updated.color,
|
color: updated.color,
|
||||||
usage_count,
|
usage_count,
|
||||||
})
|
}
|
||||||
|
.into_json()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
delete,
|
|
||||||
path = "/api/tags/{id}",
|
|
||||||
params(("id" = Uuid, Path, description = "Tag ID")),
|
|
||||||
responses((status = 204, description = "Tag deleted")),
|
|
||||||
tag = "Tags"
|
|
||||||
)]
|
|
||||||
pub async fn delete_tag(
|
pub async fn delete_tag(
|
||||||
Path(tag_id): Path<Uuid>,
|
Path(tag_id): Path<Uuid>,
|
||||||
TenantScopedConn {
|
TenantScopedConn {
|
||||||
@@ -329,30 +264,15 @@ pub async fn delete_tag(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
diesel::delete(
|
let deleted = diesel::delete(
|
||||||
tags::table
|
tags::table
|
||||||
.find(tag_id)
|
.find(tag_id)
|
||||||
.filter(tags::tenant_id.eq(tenant_id)),
|
.filter(tags::tenant_id.eq(tenant_id)),
|
||||||
)
|
)
|
||||||
.execute(&mut conn)
|
.execute(&mut conn)?;
|
||||||
.into_app_result()?
|
if deleted == 0 {
|
||||||
.or_not_found()?;
|
return Err(AppError::not_found());
|
||||||
|
}
|
||||||
|
|
||||||
no_content()
|
no_content()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(utoipa::OpenApi)]
|
|
||||||
#[openapi(
|
|
||||||
paths(
|
|
||||||
crate::routes::tags::list_tags,
|
|
||||||
crate::routes::tags::create_tag,
|
|
||||||
crate::routes::tags::update_tag,
|
|
||||||
crate::routes::tags::delete_tag
|
|
||||||
),
|
|
||||||
components(schemas(
|
|
||||||
crate::routes::tags::CreateTagRequest,
|
|
||||||
crate::routes::tags::TagCatalogEntry,
|
|
||||||
crate::routes::tags::UpdateTagRequest
|
|
||||||
))
|
|
||||||
)]
|
|
||||||
pub struct TagsApiDoc;
|
|
||||||
|
|||||||
@@ -1,154 +0,0 @@
|
|||||||
use axum::extract::{Path, State};
|
|
||||||
use axum::{http::StatusCode, Json};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::auth::{AuthenticatedUser, TenantMembershipUser};
|
|
||||||
use crate::error::AppResult;
|
|
||||||
use crate::http::responders::JsonResponse;
|
|
||||||
use crate::services::auth::{AuthService, TenantSnippet};
|
|
||||||
use crate::services::tenants::{
|
|
||||||
TenantApiService, TenantUserListResponse, TenantUserSummary, UpdateTenantRequest,
|
|
||||||
UpdateTenantUserRequest,
|
|
||||||
};
|
|
||||||
use crate::state::AppState;
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/api/tenants",
|
|
||||||
responses((status = 200, body = [TenantSnippet], description = "Tenant memberships for the current user")),
|
|
||||||
tag = "Tenants"
|
|
||||||
)]
|
|
||||||
pub async fn list_tenants(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
user: TenantMembershipUser,
|
|
||||||
) -> AppResult<Json<Vec<TenantSnippet>>> {
|
|
||||||
let response = AuthService::new(&state).list_tenants(user.user_id)?;
|
|
||||||
Ok(Json(response.into_inner().tenants))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/api/tenants/{tenant_id}",
|
|
||||||
params(("tenant_id" = Uuid, Path, description = "Tenant identifier")),
|
|
||||||
responses((status = 200, body = TenantSnippet, description = "Tenant details")),
|
|
||||||
tag = "Tenants"
|
|
||||||
)]
|
|
||||||
pub async fn get_tenant(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Path(tenant_id): Path<Uuid>,
|
|
||||||
user: TenantMembershipUser,
|
|
||||||
) -> AppResult<JsonResponse<TenantSnippet>> {
|
|
||||||
AuthService::new(&state).get_tenant(user.user_id, tenant_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
patch,
|
|
||||||
path = "/api/tenants/{tenant_id}",
|
|
||||||
params(("tenant_id" = Uuid, Path, description = "Tenant identifier")),
|
|
||||||
request_body = UpdateTenantRequest,
|
|
||||||
responses((status = 200, body = TenantSnippet, description = "Updated tenant")),
|
|
||||||
tag = "Tenants"
|
|
||||||
)]
|
|
||||||
pub async fn update_tenant(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Path(tenant_id): Path<Uuid>,
|
|
||||||
user: AuthenticatedUser,
|
|
||||||
Json(payload): Json<UpdateTenantRequest>,
|
|
||||||
) -> AppResult<JsonResponse<TenantSnippet>> {
|
|
||||||
TenantApiService::new(&state).update_name(user, tenant_id, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/api/tenants/{tenant_id}/users",
|
|
||||||
params(("tenant_id" = Uuid, Path, description = "Tenant ID")),
|
|
||||||
responses((status = 200, body = [TenantUserSummary], description = "All users for the tenant")),
|
|
||||||
tag = "Tenants"
|
|
||||||
)]
|
|
||||||
pub async fn list_tenant_users(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Path(tenant_id): Path<Uuid>,
|
|
||||||
user: AuthenticatedUser,
|
|
||||||
) -> AppResult<Json<Vec<TenantUserSummary>>> {
|
|
||||||
let response = TenantApiService::new(&state).list_users(&user, tenant_id)?;
|
|
||||||
Ok(Json(response.into_inner().users))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/api/tenants/{tenant_id}/users/{user_id}",
|
|
||||||
params(
|
|
||||||
("tenant_id" = Uuid, Path, description = "Tenant ID"),
|
|
||||||
("user_id" = Uuid, Path, description = "User ID")
|
|
||||||
),
|
|
||||||
responses((status = 200, body = TenantUserSummary, description = "Tenant user details")),
|
|
||||||
tag = "Tenants"
|
|
||||||
)]
|
|
||||||
pub async fn get_tenant_user(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Path((tenant_id, target_user_id)): Path<(Uuid, Uuid)>,
|
|
||||||
user: AuthenticatedUser,
|
|
||||||
) -> AppResult<JsonResponse<TenantUserSummary>> {
|
|
||||||
TenantApiService::new(&state).get_user(&user, tenant_id, target_user_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
patch,
|
|
||||||
path = "/api/tenants/{tenant_id}/users/{user_id}",
|
|
||||||
params(
|
|
||||||
("tenant_id" = Uuid, Path, description = "Tenant ID"),
|
|
||||||
("user_id" = Uuid, Path, description = "User ID")
|
|
||||||
),
|
|
||||||
request_body = UpdateTenantUserRequest,
|
|
||||||
responses((status = 200, body = TenantUserSummary, description = "Updated tenant user")),
|
|
||||||
tag = "Tenants"
|
|
||||||
)]
|
|
||||||
pub async fn update_tenant_user(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Path((tenant_id, target_user_id)): Path<(Uuid, Uuid)>,
|
|
||||||
user: AuthenticatedUser,
|
|
||||||
Json(payload): Json<UpdateTenantUserRequest>,
|
|
||||||
) -> AppResult<JsonResponse<TenantUserSummary>> {
|
|
||||||
TenantApiService::new(&state).update_user(&user, tenant_id, target_user_id, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
delete,
|
|
||||||
path = "/api/tenants/{tenant_id}/users/{user_id}",
|
|
||||||
params(
|
|
||||||
("tenant_id" = Uuid, Path, description = "Tenant ID"),
|
|
||||||
("user_id" = Uuid, Path, description = "User ID")
|
|
||||||
),
|
|
||||||
responses((status = 204, description = "Membership removed")),
|
|
||||||
tag = "Tenants"
|
|
||||||
)]
|
|
||||||
pub async fn delete_tenant_user(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Path((tenant_id, target_user_id)): Path<(Uuid, Uuid)>,
|
|
||||||
user: AuthenticatedUser,
|
|
||||||
) -> AppResult<StatusCode> {
|
|
||||||
TenantApiService::new(&state).remove_user(&user, tenant_id, target_user_id)?;
|
|
||||||
Ok(StatusCode::NO_CONTENT)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(utoipa::OpenApi)]
|
|
||||||
#[openapi(
|
|
||||||
paths(
|
|
||||||
list_tenants,
|
|
||||||
get_tenant,
|
|
||||||
update_tenant,
|
|
||||||
list_tenant_users,
|
|
||||||
get_tenant_user,
|
|
||||||
update_tenant_user,
|
|
||||||
delete_tenant_user,
|
|
||||||
),
|
|
||||||
components(schemas(
|
|
||||||
crate::services::auth::TenantListResponse,
|
|
||||||
crate::services::auth::TenantSnippet,
|
|
||||||
UpdateTenantRequest,
|
|
||||||
UpdateTenantUserRequest,
|
|
||||||
TenantUserListResponse,
|
|
||||||
TenantUserSummary,
|
|
||||||
))
|
|
||||||
)]
|
|
||||||
pub struct TenantsApiDoc;
|
|
||||||
+164
-121
@@ -8,7 +8,6 @@ use axum::Router;
|
|||||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
use diesel::OptionalExtension;
|
|
||||||
use diesel::PgConnection;
|
use diesel::PgConnection;
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
use percent_encoding::{percent_decode_str, utf8_percent_encode, NON_ALPHANUMERIC};
|
use percent_encoding::{percent_decode_str, utf8_percent_encode, NON_ALPHANUMERIC};
|
||||||
@@ -16,28 +15,31 @@ use quick_xml::events::{BytesDecl, BytesEnd, BytesStart, BytesText, Event};
|
|||||||
use quick_xml::Writer;
|
use quick_xml::Writer;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::auth::{
|
use crate::auth::password;
|
||||||
api_tokens::{find_active_token_by_secret, touch_api_token},
|
|
||||||
ensure_active_tenant_with_conn,
|
|
||||||
};
|
|
||||||
use crate::error::{AppError, AppResult};
|
use crate::error::{AppError, AppResult};
|
||||||
use crate::models::{ApiCapability, Document, DocumentVersion, Folder, User};
|
use crate::models::{Document, DocumentVersion, Folder, User};
|
||||||
use crate::schema::{
|
use crate::schema::{
|
||||||
document_versions::dsl as document_versions_dsl, documents::dsl as documents_dsl,
|
document_versions::dsl as document_versions_dsl, documents::dsl as documents_dsl,
|
||||||
folders::dsl as folders_dsl, user_memberships::dsl as memberships_dsl, users::dsl as users_dsl,
|
folders::dsl as folders_dsl, tenants::dsl as tenant_dsl,
|
||||||
|
user_memberships::dsl as memberships_dsl, users::dsl as users_dsl,
|
||||||
};
|
};
|
||||||
use crate::state::{AppState, PgPooledConnection};
|
use crate::state::AppState;
|
||||||
use crate::tenants::{apply_tenant_guc, apply_user_guc, clear_user_guc};
|
use crate::utils::{http::inline_content_disposition, time::to_http_date};
|
||||||
use crate::utils::{error::StorageResultExt, http::inline_content_disposition, time::to_http_date};
|
|
||||||
|
|
||||||
const REALM: &str = "Papercrate WebDAV";
|
const REALM: &str = "Papercrate WebDAV";
|
||||||
const DOWNLOAD_URL_TTL_SECONDS: u64 = 300;
|
const DOWNLOAD_URL_TTL_SECONDS: u64 = 300;
|
||||||
|
|
||||||
struct WebDavContext {
|
#[derive(Clone, Debug)]
|
||||||
|
struct TenantEntry {
|
||||||
tenant_id: Uuid,
|
tenant_id: Uuid,
|
||||||
|
slug: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct WebDavContext {
|
||||||
_user_id: Uuid,
|
_user_id: Uuid,
|
||||||
_username: String,
|
_username: String,
|
||||||
conn: PgPooledConnection,
|
tenants: Vec<TenantEntry>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn create_router() -> Router<AppState> {
|
pub fn create_router() -> Router<AppState> {
|
||||||
@@ -75,7 +77,7 @@ async fn handle_propfind(
|
|||||||
path: &str,
|
path: &str,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
) -> Result<Response, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
let mut context = match authenticate(state, &headers)? {
|
let context = match authenticate(state, &headers)? {
|
||||||
Some(user) => user,
|
Some(user) => user,
|
||||||
None => return Ok(unauthorized_response()),
|
None => return Ok(unauthorized_response()),
|
||||||
};
|
};
|
||||||
@@ -87,21 +89,35 @@ async fn handle_propfind(
|
|||||||
|
|
||||||
let segments = parse_segments(path)?;
|
let segments = parse_segments(path)?;
|
||||||
|
|
||||||
let tenant_id = context.tenant_id;
|
|
||||||
|
|
||||||
let resources = if segments.is_empty() {
|
let resources = if segments.is_empty() {
|
||||||
let contents = fetch_folder_contents(&mut context.conn, tenant_id, None)?;
|
build_account_root_resources(&context.tenants, depth)
|
||||||
build_resources_for_folder(None, &[], &contents, depth)
|
|
||||||
} else {
|
} else {
|
||||||
let resolution = match resolve_path(&mut context.conn, tenant_id, &segments)? {
|
let (requested_slug, remainder) = segments.split_first().unwrap();
|
||||||
|
let tenant_entry = match context
|
||||||
|
.tenants
|
||||||
|
.iter()
|
||||||
|
.find(|entry| entry.slug.eq_ignore_ascii_case(requested_slug))
|
||||||
|
{
|
||||||
|
Some(entry) => TenantEntry {
|
||||||
|
tenant_id: entry.tenant_id,
|
||||||
|
slug: entry.slug.clone(),
|
||||||
|
},
|
||||||
|
None => return Ok(not_found_response()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let resolution = match resolve_path(state, &tenant_entry, remainder)? {
|
||||||
Some(resolved) => resolved,
|
Some(resolved) => resolved,
|
||||||
None => return Ok(not_found_response()),
|
None => return Ok(not_found_response()),
|
||||||
};
|
};
|
||||||
|
|
||||||
match resolution {
|
match resolution {
|
||||||
|
ResolvedPath::TenantRoot { chain } => {
|
||||||
|
let contents = fetch_folder_contents(state, tenant_entry.tenant_id, None)?;
|
||||||
|
build_resources_for_folder(None, &chain, &contents, depth)
|
||||||
|
}
|
||||||
ResolvedPath::Folder { folder, chain } => {
|
ResolvedPath::Folder { folder, chain } => {
|
||||||
let contents =
|
let contents =
|
||||||
fetch_folder_contents(&mut context.conn, tenant_id, Some(folder.id))?;
|
fetch_folder_contents(state, tenant_entry.tenant_id, Some(folder.id))?;
|
||||||
build_resources_for_folder(Some(&folder), &chain, &contents, depth)
|
build_resources_for_folder(Some(&folder), &chain, &contents, depth)
|
||||||
}
|
}
|
||||||
ResolvedPath::Document {
|
ResolvedPath::Document {
|
||||||
@@ -112,10 +128,8 @@ async fn handle_propfind(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let body = render_multistatus(&resources).map_err(|err| {
|
let body = render_multistatus(&resources)
|
||||||
tracing::error!(error = ?err, "failed to render WebDAV response");
|
.map_err(|err| AppError::internal(format!("failed to render WebDAV response: {err}")))?;
|
||||||
AppError::internal("failed to render WebDAV response")
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let response = Response::builder()
|
let response = Response::builder()
|
||||||
.status(multi_status())
|
.status(multi_status())
|
||||||
@@ -132,18 +146,34 @@ async fn handle_get_or_head(
|
|||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
method: Method,
|
method: Method,
|
||||||
) -> Result<Response, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
let mut context = match authenticate(state, &headers)? {
|
let context = match authenticate(state, &headers)? {
|
||||||
Some(user) => user,
|
Some(user) => user,
|
||||||
None => return Ok(unauthorized_response()),
|
None => return Ok(unauthorized_response()),
|
||||||
};
|
};
|
||||||
|
|
||||||
let tenant_id = context.tenant_id;
|
|
||||||
let segments = parse_segments(path)?;
|
let segments = parse_segments(path)?;
|
||||||
if segments.is_empty() {
|
let (requested_slug, remainder) = match segments.split_first() {
|
||||||
|
Some(values) => values,
|
||||||
|
None => return Ok(method_not_allowed()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let tenant_entry = match context
|
||||||
|
.tenants
|
||||||
|
.iter()
|
||||||
|
.find(|entry| entry.slug.eq_ignore_ascii_case(requested_slug))
|
||||||
|
{
|
||||||
|
Some(entry) => TenantEntry {
|
||||||
|
tenant_id: entry.tenant_id,
|
||||||
|
slug: entry.slug.clone(),
|
||||||
|
},
|
||||||
|
None => return Ok(not_found_response()),
|
||||||
|
};
|
||||||
|
|
||||||
|
if remainder.is_empty() {
|
||||||
return Ok(method_not_allowed());
|
return Ok(method_not_allowed());
|
||||||
}
|
}
|
||||||
|
|
||||||
let resolution = match resolve_path(&mut context.conn, tenant_id, &segments)? {
|
let resolution = match resolve_path(state, &tenant_entry, remainder)? {
|
||||||
Some(resolved) => resolved,
|
Some(resolved) => resolved,
|
||||||
None => return Ok(not_found_response()),
|
None => return Ok(not_found_response()),
|
||||||
};
|
};
|
||||||
@@ -237,16 +267,18 @@ fn parse_segments(path: &str) -> AppResult<Vec<String>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn fetch_folder_contents(
|
fn fetch_folder_contents(
|
||||||
conn: &mut PgPooledConnection,
|
state: &AppState,
|
||||||
tenant_id: Uuid,
|
tenant_id: Uuid,
|
||||||
folder_id: Option<Uuid>,
|
folder_id: Option<Uuid>,
|
||||||
) -> AppResult<WebDavFolderContents> {
|
) -> AppResult<WebDavFolderContents> {
|
||||||
|
let mut conn = state.db_for_tenant(tenant_id)?;
|
||||||
|
|
||||||
let folder = match folder_id {
|
let folder = match folder_id {
|
||||||
Some(id) => Some(
|
Some(id) => Some(
|
||||||
folders_dsl::folders
|
folders_dsl::folders
|
||||||
.filter(folders_dsl::tenant_id.eq(tenant_id))
|
.filter(folders_dsl::tenant_id.eq(tenant_id))
|
||||||
.find(id)
|
.find(id)
|
||||||
.first::<Folder>(conn)?,
|
.first::<Folder>(&mut conn)?,
|
||||||
),
|
),
|
||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
@@ -256,12 +288,12 @@ fn fetch_folder_contents(
|
|||||||
.filter(folders_dsl::tenant_id.eq(tenant_id))
|
.filter(folders_dsl::tenant_id.eq(tenant_id))
|
||||||
.filter(folders_dsl::parent_id.eq(Some(id)))
|
.filter(folders_dsl::parent_id.eq(Some(id)))
|
||||||
.order(folders_dsl::name.asc())
|
.order(folders_dsl::name.asc())
|
||||||
.load(conn)?,
|
.load(&mut conn)?,
|
||||||
None => folders_dsl::folders
|
None => folders_dsl::folders
|
||||||
.filter(folders_dsl::tenant_id.eq(tenant_id))
|
.filter(folders_dsl::tenant_id.eq(tenant_id))
|
||||||
.filter(folders_dsl::parent_id.is_null())
|
.filter(folders_dsl::parent_id.is_null())
|
||||||
.order(folders_dsl::name.asc())
|
.order(folders_dsl::name.asc())
|
||||||
.load(conn)?,
|
.load(&mut conn)?,
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut docs_query = documents_dsl::documents
|
let mut docs_query = documents_dsl::documents
|
||||||
@@ -275,8 +307,8 @@ fn fetch_folder_contents(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let documents: Vec<Document> = docs_query
|
let documents: Vec<Document> = docs_query
|
||||||
.order(documents_dsl::created_at.desc())
|
.order(documents_dsl::uploaded_at.desc())
|
||||||
.load(conn)?;
|
.load(&mut conn)?;
|
||||||
|
|
||||||
let version_ids: Vec<Uuid> = documents.iter().map(|doc| doc.current_version_id).collect();
|
let version_ids: Vec<Uuid> = documents.iter().map(|doc| doc.current_version_id).collect();
|
||||||
let versions: Vec<DocumentVersion> = if version_ids.is_empty() {
|
let versions: Vec<DocumentVersion> = if version_ids.is_empty() {
|
||||||
@@ -284,7 +316,7 @@ fn fetch_folder_contents(
|
|||||||
} else {
|
} else {
|
||||||
document_versions_dsl::document_versions
|
document_versions_dsl::document_versions
|
||||||
.filter(document_versions_dsl::id.eq_any(&version_ids))
|
.filter(document_versions_dsl::id.eq_any(&version_ids))
|
||||||
.load(conn)?
|
.load(&mut conn)?
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut version_map = versions
|
let mut version_map = versions
|
||||||
@@ -322,10 +354,9 @@ async fn stream_document(
|
|||||||
.presign_get_object(
|
.presign_get_object(
|
||||||
&version.s3_key,
|
&version.s3_key,
|
||||||
Duration::from_secs(DOWNLOAD_URL_TTL_SECONDS),
|
Duration::from_secs(DOWNLOAD_URL_TTL_SECONDS),
|
||||||
None,
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.storage_context("failed to presign document download")?;
|
.map_err(|err| AppError::internal(format!("failed to presign document download: {err}")))?;
|
||||||
|
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
let mut request = client.request(method.clone(), url.clone());
|
let mut request = client.request(method.clone(), url.clone());
|
||||||
@@ -334,24 +365,25 @@ async fn stream_document(
|
|||||||
request = request.header(header::RANGE, range.clone());
|
request = request.header(header::RANGE, range.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
let upstream = request.send().await.map_err(|err| {
|
let upstream = request
|
||||||
tracing::error!(error = ?err, "failed to fetch document stream");
|
.send()
|
||||||
AppError::internal("failed to fetch document stream")
|
.await
|
||||||
})?;
|
.map_err(|err| AppError::internal(format!("failed to fetch document stream: {err}")))?;
|
||||||
|
|
||||||
let status =
|
let status =
|
||||||
StatusCode::from_u16(upstream.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
|
StatusCode::from_u16(upstream.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
|
||||||
|
|
||||||
if !(status.is_success() || status == StatusCode::PARTIAL_CONTENT) {
|
if !(status.is_success() || status == StatusCode::PARTIAL_CONTENT) {
|
||||||
tracing::error!(status = %status, "upstream download returned error status");
|
return Err(AppError::internal(format!(
|
||||||
return Err(AppError::internal("failed to fetch document stream"));
|
"upstream download returned status {status}"
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut builder = Response::builder().status(status);
|
let mut builder = Response::builder().status(status);
|
||||||
|
|
||||||
if let Some(content_type) = upstream.headers().get(header::CONTENT_TYPE) {
|
if let Some(content_type) = upstream.headers().get(header::CONTENT_TYPE) {
|
||||||
builder = builder.header(header::CONTENT_TYPE, content_type);
|
builder = builder.header(header::CONTENT_TYPE, content_type);
|
||||||
} else if let Some(ref typ) = document.mime_type {
|
} else if let Some(ref typ) = document.content_type {
|
||||||
builder = builder.header(header::CONTENT_TYPE, typ);
|
builder = builder.header(header::CONTENT_TYPE, typ);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,10 +404,9 @@ async fn stream_document(
|
|||||||
builder = builder.header(header::ETAG, format!("\"{}\"", version.id));
|
builder = builder.header(header::ETAG, format!("\"{}\"", version.id));
|
||||||
|
|
||||||
if method == Method::HEAD {
|
if method == Method::HEAD {
|
||||||
return builder.body(Body::empty()).map_err(|err| {
|
return builder
|
||||||
tracing::error!(error = ?err, "failed to build WebDAV response");
|
.body(Body::empty())
|
||||||
AppError::internal("failed to build WebDAV response")
|
.map_err(|err| AppError::internal(format!("failed to build response: {err}")));
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let stream = upstream
|
let stream = upstream
|
||||||
@@ -383,10 +414,9 @@ async fn stream_document(
|
|||||||
.map(|chunk| chunk.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err)));
|
.map(|chunk| chunk.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err)));
|
||||||
let body = Body::from_stream(stream);
|
let body = Body::from_stream(stream);
|
||||||
|
|
||||||
builder.body(body).map_err(|err| {
|
builder
|
||||||
tracing::error!(error = ?err, "failed to build WebDAV response");
|
.body(body)
|
||||||
AppError::internal("failed to build WebDAV response")
|
.map_err(|err| AppError::internal(format!("failed to build response: {err}")))
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavContext>, AppError> {
|
fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavContext>, AppError> {
|
||||||
@@ -428,84 +458,55 @@ fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavCo
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let (presented_username, secret) = match credential_str.split_once(':') {
|
let (username, password) = match credential_str.split_once(':') {
|
||||||
Some((username, secret)) if !username.is_empty() => (username, secret),
|
Some((username, password)) if !username.is_empty() => (username, password),
|
||||||
_ => return Ok(None),
|
_ => return Ok(None),
|
||||||
};
|
};
|
||||||
|
|
||||||
tracing::debug!(presented_username = %presented_username, "attempting webdav login");
|
tracing::debug!(%username, "attempting webdav login");
|
||||||
let mut conn = state.db_unscoped()?;
|
let mut conn = state.db_unscoped()?;
|
||||||
|
|
||||||
let token = match find_active_token_by_secret(
|
let user: User = match users_dsl::users
|
||||||
&mut conn,
|
.filter(users_dsl::username.eq(username))
|
||||||
None,
|
.first(&mut conn)
|
||||||
secret,
|
{
|
||||||
Some(ApiCapability::WebdavRead),
|
|
||||||
)? {
|
|
||||||
Some(token) => token,
|
|
||||||
None => {
|
|
||||||
tracing::warn!(presented_username = %presented_username, "webdav token invalid or expired");
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let user: User = match users_dsl::users.find(token.user_id).first(&mut conn) {
|
|
||||||
Ok(user) => user,
|
Ok(user) => user,
|
||||||
Err(diesel::result::Error::NotFound) => {
|
Err(diesel::result::Error::NotFound) => {
|
||||||
tracing::warn!(
|
tracing::warn!(%username, "webdav user not found");
|
||||||
presented_username = %presented_username,
|
|
||||||
user_id = %token.user_id,
|
|
||||||
"webdav token user missing"
|
|
||||||
);
|
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
Err(err) => return Err(AppError::from(err)),
|
Err(err) => return Err(AppError::from(err)),
|
||||||
};
|
};
|
||||||
|
|
||||||
apply_user_guc(&mut conn, user.id)?;
|
let valid = password::verify_password(password, &user.password_hash)
|
||||||
|
.map_err(|_| AppError::internal("failed to verify password"))?;
|
||||||
|
|
||||||
let membership_exists = memberships_dsl::user_memberships
|
if !valid {
|
||||||
.filter(memberships_dsl::user_id.eq(user.id))
|
tracing::warn!(%username, "webdav password invalid");
|
||||||
.filter(memberships_dsl::tenant_id.eq(token.tenant_id))
|
|
||||||
.select(memberships_dsl::tenant_id)
|
|
||||||
.first::<Uuid>(&mut conn)
|
|
||||||
.optional()?;
|
|
||||||
|
|
||||||
clear_user_guc(&mut conn)?;
|
|
||||||
|
|
||||||
let tenant_id = match membership_exists {
|
|
||||||
Some(id) => id,
|
|
||||||
None => {
|
|
||||||
tracing::warn!(
|
|
||||||
presented_username = %presented_username,
|
|
||||||
username = %user.username,
|
|
||||||
tenant_id = %token.tenant_id,
|
|
||||||
"webdav token tenant membership missing"
|
|
||||||
);
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Err(err) = ensure_active_tenant_with_conn(&mut conn, tenant_id) {
|
|
||||||
tracing::warn!(tenant_id = %tenant_id, error = ?err, "webdav tenant not active");
|
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
apply_tenant_guc(&mut conn, tenant_id)?;
|
let tenant_rows: Vec<(Uuid, String)> = memberships_dsl::user_memberships
|
||||||
touch_api_token(&mut conn, token.id)?;
|
.inner_join(tenant_dsl::tenants)
|
||||||
|
.filter(memberships_dsl::user_id.eq(user.id))
|
||||||
|
.select((tenant_dsl::id, tenant_dsl::slug))
|
||||||
|
.load(&mut conn)?;
|
||||||
|
|
||||||
tracing::debug!(
|
if tenant_rows.is_empty() {
|
||||||
presented_username = %presented_username,
|
tracing::warn!(%username, "webdav user has no tenant memberships");
|
||||||
username = %user.username,
|
return Ok(None);
|
||||||
tenant_id = %tenant_id,
|
}
|
||||||
token_id = %token.id,
|
|
||||||
"webdav token login success"
|
let tenants: Vec<TenantEntry> = tenant_rows
|
||||||
);
|
.into_iter()
|
||||||
|
.map(|(tenant_id, slug)| TenantEntry { tenant_id, slug })
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
tracing::debug!(%username, tenant_count = tenants.len(), "webdav login success");
|
||||||
Ok(Some(WebDavContext {
|
Ok(Some(WebDavContext {
|
||||||
tenant_id,
|
|
||||||
_user_id: user.id,
|
_user_id: user.id,
|
||||||
_username: user.username,
|
_username: user.username,
|
||||||
conn,
|
tenants,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -529,7 +530,7 @@ fn build_resources_for_folder(
|
|||||||
display_name,
|
display_name,
|
||||||
is_collection: true,
|
is_collection: true,
|
||||||
content_length: None,
|
content_length: None,
|
||||||
mime_type: None,
|
content_type: None,
|
||||||
last_modified,
|
last_modified,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -545,7 +546,7 @@ fn build_resources_for_folder(
|
|||||||
display_name: subfolder.name.clone(),
|
display_name: subfolder.name.clone(),
|
||||||
is_collection: true,
|
is_collection: true,
|
||||||
content_length: None,
|
content_length: None,
|
||||||
mime_type: None,
|
content_type: None,
|
||||||
last_modified: Some(to_http_date(subfolder.updated_at)),
|
last_modified: Some(to_http_date(subfolder.updated_at)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -563,6 +564,37 @@ fn build_resources_for_folder(
|
|||||||
resources
|
resources
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_account_root_resources(tenants: &[TenantEntry], depth: u8) -> Vec<DavResource> {
|
||||||
|
let mut resources = Vec::new();
|
||||||
|
|
||||||
|
resources.push(DavResource {
|
||||||
|
href: "/".to_string(),
|
||||||
|
display_name: "/".to_string(),
|
||||||
|
is_collection: true,
|
||||||
|
content_length: None,
|
||||||
|
content_type: None,
|
||||||
|
last_modified: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
if depth == 0 {
|
||||||
|
return resources;
|
||||||
|
}
|
||||||
|
|
||||||
|
for tenant in tenants {
|
||||||
|
let href = build_href(&[tenant.slug.clone()], true);
|
||||||
|
resources.push(DavResource {
|
||||||
|
href,
|
||||||
|
display_name: tenant.slug.clone(),
|
||||||
|
is_collection: true,
|
||||||
|
content_length: None,
|
||||||
|
content_type: None,
|
||||||
|
last_modified: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
resources
|
||||||
|
}
|
||||||
|
|
||||||
fn build_resources_for_document(
|
fn build_resources_for_document(
|
||||||
chain: &[String],
|
chain: &[String],
|
||||||
document: &Document,
|
document: &Document,
|
||||||
@@ -583,7 +615,7 @@ fn document_to_resource(
|
|||||||
display_name: document.title.clone(),
|
display_name: document.title.clone(),
|
||||||
is_collection: false,
|
is_collection: false,
|
||||||
content_length: Some(version.size_bytes),
|
content_length: Some(version.size_bytes),
|
||||||
mime_type: document.mime_type.clone(),
|
content_type: document.content_type.clone(),
|
||||||
last_modified: Some(to_http_date(document.updated_at)),
|
last_modified: Some(to_http_date(document.updated_at)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -639,7 +671,7 @@ fn render_multistatus(resources: &[DavResource]) -> Result<Vec<u8>, quick_xml::E
|
|||||||
writer.write_event(Event::End(BytesEnd::new("D:getcontentlength")))?;
|
writer.write_event(Event::End(BytesEnd::new("D:getcontentlength")))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(content_type) = &resource.mime_type {
|
if let Some(content_type) = &resource.content_type {
|
||||||
writer.write_event(Event::Start(BytesStart::new("D:getcontenttype")))?;
|
writer.write_event(Event::Start(BytesStart::new("D:getcontenttype")))?;
|
||||||
writer.write_event(Event::Text(BytesText::new(content_type)))?;
|
writer.write_event(Event::Text(BytesText::new(content_type)))?;
|
||||||
writer.write_event(Event::End(BytesEnd::new("D:getcontenttype")))?;
|
writer.write_event(Event::End(BytesEnd::new("D:getcontenttype")))?;
|
||||||
@@ -681,10 +713,13 @@ struct DavResource {
|
|||||||
display_name: String,
|
display_name: String,
|
||||||
is_collection: bool,
|
is_collection: bool,
|
||||||
content_length: Option<i64>,
|
content_length: Option<i64>,
|
||||||
mime_type: Option<String>,
|
content_type: Option<String>,
|
||||||
last_modified: Option<String>,
|
last_modified: Option<String>,
|
||||||
}
|
}
|
||||||
enum ResolvedPath {
|
enum ResolvedPath {
|
||||||
|
TenantRoot {
|
||||||
|
chain: Vec<String>,
|
||||||
|
},
|
||||||
Folder {
|
Folder {
|
||||||
folder: Folder,
|
folder: Folder,
|
||||||
chain: Vec<String>,
|
chain: Vec<String>,
|
||||||
@@ -697,18 +732,24 @@ enum ResolvedPath {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_path(
|
fn resolve_path(
|
||||||
conn: &mut PgPooledConnection,
|
state: &AppState,
|
||||||
tenant_id: Uuid,
|
tenant: &TenantEntry,
|
||||||
segments: &[String],
|
segments: &[String],
|
||||||
) -> AppResult<Option<ResolvedPath>> {
|
) -> AppResult<Option<ResolvedPath>> {
|
||||||
|
let mut conn = state.db_for_tenant(tenant.tenant_id)?;
|
||||||
let mut parent_id: Option<Uuid> = None;
|
let mut parent_id: Option<Uuid> = None;
|
||||||
let mut chain: Vec<String> = Vec::new();
|
let mut chain: Vec<String> = vec![tenant.slug.clone()];
|
||||||
let mut current_folder: Option<Folder> = None;
|
let mut current_folder: Option<Folder> = None;
|
||||||
|
|
||||||
|
if segments.is_empty() {
|
||||||
|
return Ok(Some(ResolvedPath::TenantRoot { chain }));
|
||||||
|
}
|
||||||
|
|
||||||
for (index, segment) in segments.iter().enumerate() {
|
for (index, segment) in segments.iter().enumerate() {
|
||||||
let is_last = index == segments.len() - 1;
|
let is_last = index == segments.len() - 1;
|
||||||
|
|
||||||
if let Some(folder) = find_folder_by_name(conn, tenant_id, parent_id, segment)? {
|
if let Some(folder) = find_folder_by_name(&mut conn, tenant.tenant_id, parent_id, segment)?
|
||||||
|
{
|
||||||
chain.push(folder.name.clone());
|
chain.push(folder.name.clone());
|
||||||
if is_last {
|
if is_last {
|
||||||
return Ok(Some(ResolvedPath::Folder { folder, chain }));
|
return Ok(Some(ResolvedPath::Folder { folder, chain }));
|
||||||
@@ -720,7 +761,7 @@ fn resolve_path(
|
|||||||
|
|
||||||
if is_last {
|
if is_last {
|
||||||
if let Some((document, version)) =
|
if let Some((document, version)) =
|
||||||
find_document_by_filename(conn, tenant_id, parent_id, segment)?
|
find_document_by_filename(&mut conn, tenant.tenant_id, parent_id, segment)?
|
||||||
{
|
{
|
||||||
chain.push(document.filename.clone());
|
chain.push(document.filename.clone());
|
||||||
return Ok(Some(ResolvedPath::Document {
|
return Ok(Some(ResolvedPath::Document {
|
||||||
@@ -732,7 +773,7 @@ fn resolve_path(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Ok(uuid) = Uuid::parse_str(segment) {
|
if let Ok(uuid) = Uuid::parse_str(segment) {
|
||||||
if let Some(folder) = find_folder_by_id(conn, tenant_id, uuid)? {
|
if let Some(folder) = find_folder_by_id(&mut conn, tenant.tenant_id, uuid)? {
|
||||||
if folder.parent_id != parent_id {
|
if folder.parent_id != parent_id {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
@@ -745,7 +786,9 @@ fn resolve_path(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some((document, version)) = find_document_by_id(conn, tenant_id, uuid)? {
|
if let Some((document, version)) =
|
||||||
|
find_document_by_id(&mut conn, tenant.tenant_id, uuid)?
|
||||||
|
{
|
||||||
if document.folder_id != parent_id {
|
if document.folder_id != parent_id {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-26
@@ -1,34 +1,38 @@
|
|||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::Result;
|
||||||
use s3::{bucket::Bucket, creds::Credentials, region::Region};
|
use aws_config::meta::region::RegionProviderChain;
|
||||||
|
use aws_credential_types::Credentials;
|
||||||
|
use aws_sdk_s3::{
|
||||||
|
config::{Builder as S3ConfigBuilder, Region},
|
||||||
|
Client as S3Client,
|
||||||
|
};
|
||||||
|
|
||||||
use crate::config::AppConfig;
|
use crate::config::AppConfig;
|
||||||
|
|
||||||
pub fn build_bucket(config: &AppConfig) -> Result<Bucket> {
|
pub async fn build_client(config: &AppConfig) -> Result<S3Client> {
|
||||||
let region = if let Some(endpoint) = &config.aws_endpoint_url {
|
let region = Region::new(config.aws_region.clone());
|
||||||
Region::Custom {
|
let region_provider = RegionProviderChain::first_try(Some(region))
|
||||||
region: config.aws_region.clone(),
|
.or_default_provider()
|
||||||
endpoint: endpoint.clone(),
|
.or_else("us-east-1");
|
||||||
}
|
|
||||||
} else {
|
|
||||||
config
|
|
||||||
.aws_region
|
|
||||||
.parse::<Region>()
|
|
||||||
.context("invalid AWS region")?
|
|
||||||
};
|
|
||||||
|
|
||||||
let credentials = if let (Some(access_key), Some(secret_key)) = (
|
#[allow(deprecated)]
|
||||||
config.aws_access_key_id.as_deref(),
|
let mut loader = aws_config::from_env().region(region_provider);
|
||||||
config.aws_secret_access_key.as_deref(),
|
|
||||||
|
if let Some(endpoint) = &config.aws_endpoint_url {
|
||||||
|
loader = loader.endpoint_url(endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let (Some(access_key), Some(secret_key)) = (
|
||||||
|
config.aws_access_key_id.clone(),
|
||||||
|
config.aws_secret_access_key.clone(),
|
||||||
) {
|
) {
|
||||||
Credentials::new(Some(access_key), Some(secret_key), None, None, None)
|
let credentials = Credentials::new(access_key, secret_key, None, None, "static");
|
||||||
.context("failed to create static AWS credentials")?
|
loader = loader.credentials_provider(credentials);
|
||||||
} else {
|
}
|
||||||
Credentials::default().context("failed to load AWS credentials")?
|
|
||||||
};
|
|
||||||
|
|
||||||
let bucket = Bucket::new(&config.s3_bucket, region, credentials)
|
let base_config = loader.load().await;
|
||||||
.map_err(|err| anyhow!("failed to create S3 bucket client: {err}"))?;
|
let s3_config = S3ConfigBuilder::from(&base_config)
|
||||||
let bucket = bucket.with_path_style();
|
.force_path_style(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
Ok(*bucket)
|
Ok(S3Client::from_conf(s3_config))
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user