3 Commits
Author SHA1 Message Date
nils be451bda1e dev: switch to fully containerized Docker Compose workflow
ci / docker (backend, backend/Dockerfile, backend) (push) Failing after 14s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Failing after 13s
- Replace tmux setup with a full Docker Compose development stack
- Update backend Dockerfile to use `cargo-chef` caching and `supervisord`
- Containerize frontend development server
- Update tests to run in isolated containers
- Remove legacy `papercrate.tmux`
2025-12-17 19:55:47 +01:00
nils b8c2426079 Update screenshots
ci / docker (backend, backend/Dockerfile, backend) (push) Failing after 14s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Failing after 13s
2025-12-17 13:58:41 +01:00
nils 3c9a9fc060 Initial commit
ci / docker (backend, backend/Dockerfile, backend) (push) Failing after 13s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Failing after 12s
2025-12-17 13:37:31 +01:00
385 changed files with 50076 additions and 29632 deletions
-95
View File
@@ -1,95 +0,0 @@
name: ci
on:
push:
branches:
- main
- staging
- dev
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: Derive repository metadata
id: repo_meta
run: |
repo="${GITHUB_REPOSITORY:-$GITEA_REPOSITORY}"
owner="${repo%%/*}"
name="${repo##*/}"
echo "repo_owner=$owner" >> "$GITHUB_OUTPUT"
echo "repo_name=$name" >> "$GITHUB_OUTPUT"
- name: Compute base tag
id: compute_tag
run: |
sha="${GITHUB_SHA:-$GITEA_SHA}"
ref_type="${GITHUB_REF_TYPE:-$GITEA_REF_TYPE}"
ref_name="${GITHUB_REF_NAME:-$GITEA_REF_NAME}"
if [ -z "$sha" ]; then
echo "base_tag=$ref_name" >> "$GITHUB_OUTPUT"
exit 0
fi
short="${sha:0:7}"
tag="$short"
if [ "$ref_type" = "tag" ]; then
tag="$ref_name"
elif [ "$ref_name" = "dev" ]; then
tag="${tag}-dev"
fi
echo "base_tag=$tag" >> "$GITHUB_OUTPUT"
- name: Login to local registry
uses: docker/login-action@v2
with:
registry: ${{ vars.REGISTRY_URL }}
username: ${{ vars.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Login to GHCR
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ vars.GHCR_USERNAME }}
password: ${{ secrets.GHCR_PASSWORD }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
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 }}/${{ steps.repo_meta.outputs.repo_name }}-${{ matrix.service }}:${{ steps.compute_tag.outputs.base_tag }}
${{ vars.REGISTRY_URL }}/${{ steps.repo_meta.outputs.repo_name }}-${{ matrix.service }}:${{ gitea.sha }}
ghcr.io/paperless-dms/${{ steps.repo_meta.outputs.repo_name }}-${{ matrix.service }}:${{ steps.compute_tag.outputs.base_tag }}
ghcr.io/paperless-dms/${{ steps.repo_meta.outputs.repo_name }}-${{ matrix.service }}:${{ gitea.sha }}
+146
View File
@@ -0,0 +1,146 @@
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
+55 -31
View File
@@ -5,15 +5,40 @@ integration testing, and infrastructure automation.
## Local Development ## Local Development
Use the provided `papercrate.tmux` to spin up the full stack in one tmux session: 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 ```bash
tmux -f papercrate.tmux attach docker compose -f docker-compose.dev.yml up --build
``` ```
This creates windows for the compose stack, frontend dev server, backend API, and ### Apply Code Changes
background worker using the repository-relative paths defined in the tmux file.
Detach with `Ctrl+b d` and reattach later with the same command. 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: The development Postgres container now seeds two database roles:
@@ -27,21 +52,21 @@ role with `SET ROLE papercrate_app_login;` before querying tenant tables.
## Backend Integration Tests ## Backend Integration Tests
Integration tests require a running Postgres instance (and, optionally, Quickwit 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.
for OCR indexing). The repository includes a lightweight compose file for local
runs: To run the tests:
```bash ```bash
docker compose -f docker-compose.test.yml up -d docker compose -f docker-compose.test.yml run --rm test-runner
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: 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 ```bash
docker compose -f docker-compose.test.yml down docker compose -f docker-compose.test.yml down
``` ```
@@ -50,14 +75,8 @@ The compose service uses tmpfs storage, giving each test run a clean database.
## Runtime Dependencies ## Runtime Dependencies
- `ocrmypdf` (optional but recommended): Used by the OCR worker to extract text - `ocrmypdf`: Used by the worker to extract text from images. If missing, the worker logs a warning and skips text extraction for that document.
from PDFs when no embedded text layer is available. Ensure it is installed and - `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.
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 ## Configuration
@@ -66,6 +85,9 @@ The backend reads its settings from environment variables. In particular:
- `DATABASE_URL` connection string for the primary Postgres database (required). - `DATABASE_URL` connection string for the primary Postgres database (required).
- `DATABASE_MAX_POOL_SIZE` optional override for the r2d2 connection pool size. - `DATABASE_MAX_POOL_SIZE` optional override for the r2d2 connection pool size.
Defaults to `2`; increase it in staging/production to match expected concurrency. 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 On startup each binary logs the effective configuration with secrets redacted
(for example, the database password is masked). This makes it easier to confirm (for example, the database password is masked). This makes it easier to confirm
@@ -73,9 +95,8 @@ runtime settings in staging without exposing credentials.
## Running Migrations in Kubernetes ## Running Migrations in Kubernetes
The backend container image ships the `diesel` CLI, so schema migrations can be The backend container image ships with the `papercrate-admin` binary, which can execute schema migrations
executed as a short-lived Job (or Helm hook) before rolling out new pods. Example as a short-lived Job (or Helm hook) before rolling out new pods. Example manifest:
manifest:
```yaml ```yaml
apiVersion: batch/v1 apiVersion: batch/v1
@@ -89,16 +110,19 @@ spec:
containers: containers:
- name: migrate - name: migrate
image: ghcr.io/example/papercrate-backend:<TAG> image: ghcr.io/example/papercrate-backend:<TAG>
command: ["/usr/local/bin/diesel", "migration", "run"] command: ["/usr/local/bin/papercrate-admin", "migrate-database"]
env: env:
- name: DATABASE_URL - name: DATABASE_URL
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
name: papercrate-db name: papercrate-db
key: DATABASE_URL key: DATABASE_URL
- name: MIGRATIONS_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 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.
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
View File
@@ -0,0 +1 @@
target/
+985 -1119
View File
File diff suppressed because it is too large Load Diff
+26 -17
View File
@@ -5,22 +5,20 @@ edition = "2021"
[dependencies] [dependencies]
# Web framework # Web framework
axum = { version = "0.7", features = ["multipart"] } axum = { version = "0.8", features = ["multipart"] }
tokio = { version = "1", features = ["full"] } tokio = { version = "1.48", features = ["full"] }
tower = { version = "0.4", features = ["make", "util"] } tower = { version = "0.5", features = ["make", "util"] }
tower-http = { version = "0.6", features = ["cors", "trace"] } tower-http = { version = "0.6", features = ["cors", "trace"] }
axum-extra = { version = "0.9", features = ["typed-header"] } axum-extra = { version = "0.12", features = ["typed-header"] }
# Database # Database
diesel = { version = "2.1", features = ["postgres", "uuid", "chrono", "serde_json", "r2d2"] } diesel = { version = "2.3.3", 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
aws-config = "1.1" rust-s3 = { version = "0.37", features = ["with-tokio", "tokio-rustls-tls"] }
aws-sdk-s3 = "1.14"
aws-credential-types = "1.2"
# Serialization # Serialization
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
@@ -34,41 +32,52 @@ 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", "webp"] }
pdfium-render = "0.8" pdfium-render = "0.8.36"
mime_guess = "2.0" mime_guess = "2.0"
tempfile = "3.10" tempfile = "3.10"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] } reqwest = { version = "0.12.24", default-features = false, features = ["json", "rustls-tls", "stream"] }
percent-encoding = "2.3" percent-encoding = "2.3"
base64 = "0.21" base64 = "0.22"
quick-xml = "0.32" quick-xml = "0.38"
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"] } clap = { version = "4.5", features = ["derive"] }
# Error handling # Error handling
thiserror = "1.0" thiserror = "2.0"
anyhow = "1.0" anyhow = "1.0"
# Authentication & security # Authentication & security
argon2 = "0.5" argon2 = "0.5"
jsonwebtoken = "9" jsonwebtoken = { version = "10", features = ["rust_crypto"] }
webauthn-rs = { version = "0.5", features = ["danger-allow-state-serialisation", "danger-credential-internals"] } webauthn-rs = { version = "0.5", features = ["danger-allow-state-serialisation", "danger-credential-internals"] }
serde_bytes = "0.11" serde_bytes = "0.11"
serde_cbor_2 = "0.13" serde_cbor_2 = "0.13"
# Misc # Misc
rand = "0.8" rand = "0.9"
hyper = "1.2"
http-body-util = "0.1"
chrono-tz = "0.8"
[dev-dependencies] [dev-dependencies]
once_cell = "1.19" once_cell = "1.19"
hyper = "1.2"
http-body-util = "0.1"
webauthn-rs-core = "0.5" webauthn-rs-core = "0.5"
serde_yaml = "0.9"
[build-dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_yaml = "0.9"
serde_json = "1.0"
regex = "1.11"
[[bin]] [[bin]]
name = "backend" name = "backend"
+211 -38
View File
@@ -1,62 +1,235 @@
# 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 WORKDIR /app
RUN apt-get update \ # Install PDFium
&& apt-get install -y --no-install-recommends \ ARG TARGETARCH
build-essential \ RUN set -eux; \
pkg-config \ case "${TARGETARCH}" in \
libssl-dev \ amd64|x86_64) pdfium_package=pdfium-linux-x64.tgz ;; \
libpq-dev \ arm64|aarch64) pdfium_package=pdfium-linux-arm64.tgz ;; \
libjpeg-dev \ *) echo "Unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \
libpng-dev \ esac; \
curl \ 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/* && rm -rf /var/lib/apt/lists/*
COPY Cargo.toml Cargo.lock ./ # ------------------------------------------------------------------------------
# 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 src ./src
COPY migrations ./migrations COPY migrations ./migrations
COPY tests ./tests COPY tests ./tests
COPY resources ./resources
COPY diesel.toml ./ COPY diesel.toml ./
RUN cargo build --release --bin backend --bin worker --bin webdav --bin admin RUN set -eux; \
RUN cargo install diesel_cli --no-default-features --features postgres 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 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 \ ca-certificates curl libssl3 libpq5 libjpeg62-turbo libpng16-16 \
curl \ ${RUNTIME_DEPS} \
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 /app/target/release/backend /usr/local/bin/papercrate-backend COPY --from=builder /artifacts/backend /usr/local/bin/papercrate-backend
COPY --from=builder /app/target/release/worker /usr/local/bin/papercrate-worker COPY --from=builder /artifacts/worker /usr/local/bin/papercrate-worker
COPY --from=builder /app/target/release/webdav /usr/local/bin/papercrate-webdav COPY --from=builder /artifacts/webdav /usr/local/bin/papercrate-webdav
COPY --from=builder /app/target/release/admin /usr/local/bin/papercrate-admin COPY --from=builder /artifacts/admin /usr/local/bin/papercrate-admin
COPY --from=builder /usr/local/cargo/bin/diesel /usr/local/bin/diesel COPY --from=base /usr/local/lib/libpdfium.so /usr/local/lib/libpdfium.so
RUN ldconfig
COPY migrations ./migrations COPY migrations ./migrations
COPY diesel.toml ./
ENV RUST_LOG=info ENV RUST_LOG=info
USER appuser USER appuser
+123
View File
@@ -0,0 +1,123 @@
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(())
}
-4
View File
@@ -1,6 +1,2 @@
[print_schema]
file = "src/schema.rs"
custom_type_derives = ["diesel::query_builder::QueryId", "Clone"]
[migrations_directory] [migrations_directory]
dir = "migrations" dir = "migrations"
@@ -0,0 +1,13 @@
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;
@@ -0,0 +1,13 @@
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;
@@ -0,0 +1,28 @@
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;
@@ -0,0 +1,158 @@
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;
@@ -0,0 +1 @@
DROP INDEX IF EXISTS shared.jobs_purge_document_pending_unique;
@@ -0,0 +1,8 @@
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');
@@ -0,0 +1,5 @@
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;
@@ -0,0 +1,37 @@
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;
@@ -0,0 +1,13 @@
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;
@@ -0,0 +1,14 @@
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;
@@ -0,0 +1,4 @@
-- diesel:run_in_transaction = false
-- Enum values cannot be removed safely; this down migration intentionally left empty.
SELECT 1;
@@ -0,0 +1,46 @@
-- 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 $$;
@@ -0,0 +1,9 @@
-- 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');
@@ -0,0 +1,22 @@
-- 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;
@@ -0,0 +1,24 @@
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;
@@ -0,0 +1,27 @@
-- 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;
@@ -0,0 +1,3 @@
-- Revert column rename.
ALTER TABLE tenant.documents
RENAME COLUMN mime_type TO content_type;
@@ -0,0 +1,3 @@
-- Rename document content_type column to mime_type for consistency with API.
ALTER TABLE tenant.documents
RENAME COLUMN content_type TO mime_type;
@@ -0,0 +1,3 @@
UPDATE tenant.document_assets
SET asset_type = 'ocr-text'
WHERE asset_type = 'text-content';
@@ -0,0 +1,3 @@
UPDATE tenant.document_assets
SET asset_type = 'text-content'
WHERE asset_type = 'ocr-text';
@@ -0,0 +1 @@
DROP INDEX IF EXISTS tenant.idx_documents_title_trgm;
@@ -0,0 +1,6 @@
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;
+648
View File
@@ -0,0 +1,648 @@
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"
+16
View File
@@ -0,0 +1,16 @@
#!/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
+47 -62
View File
@@ -1,16 +1,16 @@
use argon2::{ use argon2::{
password_hash::{PasswordHasher, SaltString}, password_hash::{rand_core::OsRng as PasswordHashOsRng, PasswordHasher, SaltString},
Argon2, Argon2,
}; };
use chrono::{NaiveDateTime, Utc}; use chrono::{NaiveDateTime, Utc};
use diesel::prelude::*; use diesel::prelude::*;
use rand::rngs::OsRng; use rand::{rngs::OsRng, TryRngCore};
use rand::RngCore;
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
auth::capability_sets::{load_capabilities_for_set, load_capability_set},
error::AppError, error::AppError,
models::{ApiToken, ApiTokenCapability, NewApiToken}, models::{ApiCapability, ApiToken, CapabilitySet, NewApiToken},
schema::api_tokens, schema::api_tokens,
state::PgPooledConnection, state::PgPooledConnection,
tenants::{apply_api_token_prefix, clear_api_token_prefix}, tenants::{apply_api_token_prefix, clear_api_token_prefix},
@@ -34,9 +34,10 @@ pub fn create_api_token(
tenant_id: Uuid, tenant_id: Uuid,
label: Option<String>, label: Option<String>,
expires_at: Option<NaiveDateTime>, expires_at: Option<NaiveDateTime>,
capabilities: Vec<ApiTokenCapability>, capability_set_id: Uuid,
) -> Result<IssuedApiToken, AppError> { ) -> Result<IssuedApiToken, AppError> {
let capabilities = normalize_capabilities(capabilities)?; let capability_set =
validate_capability_set_belongs_to_tenant(conn, capability_set_id, tenant_id)?;
let raw_secret = generate_secret()?; let raw_secret = generate_secret()?;
let token_prefix = raw_secret[..TOKEN_PREFIX_LENGTH].to_string(); let token_prefix = raw_secret[..TOKEN_PREFIX_LENGTH].to_string();
@@ -49,7 +50,7 @@ pub fn create_api_token(
token_hash, token_hash,
label, label,
expires_at, expires_at,
capabilities, capability_set_id: capability_set.id,
}; };
let record = diesel::insert_into(api_tokens::table) let record = diesel::insert_into(api_tokens::table)
@@ -116,38 +117,13 @@ pub fn regenerate_api_token(
}) })
} }
/// Updates the set of capabilities associated with an API token.
pub fn update_api_token_capabilities(
conn: &mut PgPooledConnection,
token_id: Uuid,
user_id: Uuid,
tenant_id: Option<Uuid>,
capabilities: Vec<ApiTokenCapability>,
) -> Result<ApiToken, AppError> {
let capabilities = normalize_capabilities(capabilities)?;
let token = find_user_token(conn, token_id, user_id, tenant_id)?;
if token.revoked_at.is_some() {
return Err(AppError::bad_request(
"cannot modify capabilities of a revoked API token",
));
}
let updated = diesel::update(api_tokens::table.find(token.id))
.set(api_tokens::capabilities.eq(capabilities))
.get_result::<ApiToken>(conn)?;
Ok(updated)
}
/// Attempts to resolve an API token by its secret value while ensuring it provides the /// Attempts to resolve an API token by its secret value while ensuring it provides the
/// requested capability. /// requested capability.
pub fn find_active_token_by_secret( pub fn find_active_token_by_secret(
conn: &mut PgPooledConnection, conn: &mut PgPooledConnection,
tenant_id: Option<Uuid>, tenant_id: Option<Uuid>,
secret: &str, secret: &str,
required_capability: ApiTokenCapability, required_capability: Option<ApiCapability>,
) -> Result<Option<ApiToken>, AppError> { ) -> Result<Option<ApiToken>, AppError> {
if secret.len() < TOKEN_PREFIX_LENGTH { if secret.len() < TOKEN_PREFIX_LENGTH {
return Ok(None); return Ok(None);
@@ -175,8 +151,11 @@ pub fn find_active_token_by_secret(
})?; })?;
for token in candidates { for token in candidates {
if !token.capabilities.contains(&required_capability) { if let Some(required) = required_capability {
continue; let capabilities = load_capabilities_for_set(conn, token.capability_set_id)?;
if !capabilities.contains(&required) {
continue;
}
} }
if verify_token_secret(secret, &token.token_hash)? { if verify_token_secret(secret, &token.token_hash)? {
@@ -218,23 +197,6 @@ pub fn verify_token_secret(secret: &str, token_hash: &str) -> Result<bool, AppEr
}) })
} }
fn normalize_capabilities(
capabilities: Vec<ApiTokenCapability>,
) -> Result<Vec<ApiTokenCapability>, AppError> {
if capabilities.is_empty() {
return Err(AppError::bad_request("at least one capability is required"));
}
let mut unique = Vec::new();
for capability in capabilities {
if !unique.contains(&capability) {
unique.push(capability);
}
}
Ok(unique)
}
fn find_user_token( fn find_user_token(
conn: &mut PgPooledConnection, conn: &mut PgPooledConnection,
token_id: Uuid, token_id: Uuid,
@@ -257,6 +219,21 @@ fn find_user_token(
.ok_or_else(AppError::not_found) .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>( fn with_api_token_prefix<T, F>(
conn: &mut PgPooledConnection, conn: &mut PgPooledConnection,
prefix: &str, prefix: &str,
@@ -286,7 +263,8 @@ fn generate_secret() -> Result<String, AppError> {
} }
fn hash_secret(secret: &str) -> Result<String, AppError> { fn hash_secret(secret: &str) -> Result<String, AppError> {
let salt = SaltString::generate(&mut OsRng); let mut salt_rng = PasswordHashOsRng;
let salt = SaltString::generate(&mut salt_rng);
let hash = Argon2::default() let hash = Argon2::default()
.hash_password(secret.as_bytes(), &salt) .hash_password(secret.as_bytes(), &salt)
.map_err(|err| { .map_err(|err| {
@@ -299,6 +277,9 @@ fn hash_secret(secret: &str) -> Result<String, AppError> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::auth::capability_sets::{
compute_slug, normalize_capabilities, owner_capabilities, webdav_capabilities,
};
#[test] #[test]
fn generated_secret_has_expected_length() { fn generated_secret_has_expected_length() {
@@ -316,15 +297,10 @@ mod tests {
#[test] #[test]
fn normalize_capabilities_deduplicates() { fn normalize_capabilities_deduplicates() {
let caps = normalize_capabilities(vec![ let mut caps = owner_capabilities().to_vec();
ApiTokenCapability::Api, caps.push(ApiCapability::DocumentsRead);
ApiTokenCapability::Webdav, let normalized = normalize_capabilities(caps).unwrap();
ApiTokenCapability::Api, assert_eq!(normalized.len(), owner_capabilities().len());
])
.unwrap();
assert_eq!(caps.len(), 2);
assert!(caps.contains(&ApiTokenCapability::Api));
assert!(caps.contains(&ApiTokenCapability::Webdav));
} }
#[test] #[test]
@@ -332,6 +308,15 @@ mod tests {
assert!(normalize_capabilities(Vec::new()).is_err()); 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] #[test]
fn prefix_length_is_less_than_secret_length() { fn prefix_length_is_less_than_secret_length() {
assert!(TOKEN_PREFIX_LENGTH < TOKEN_SECRET_LENGTH * 2); assert!(TOKEN_PREFIX_LENGTH < TOKEN_SECRET_LENGTH * 2);
+120
View File
@@ -0,0 +1,120 @@
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 })
}
}
+323
View File
@@ -0,0 +1,323 @@
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(())
}
+70 -8
View File
@@ -2,10 +2,29 @@ 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,
@@ -23,28 +42,34 @@ pub struct JwtService {
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: Duration::minutes(config.jwt_expiry_minutes), expiry: access_expiry,
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: Duration::minutes(15), selector_expiry: access_expiry,
signup_audience: format!("{}:signup", config.jwt_audience), signup_audience: format!("{}:signup", config.jwt_audience),
signup_expiry: Duration::minutes(15), signup_expiry: Duration::minutes(15),
}) })
} }
pub fn generate_token(&self, user_id: Uuid, tenant_id: Uuid, username: &str) -> Result<String> { pub fn generate_token(&self, context: AccessTokenContext) -> Result<String> {
let now = Utc::now(); let now = Utc::now();
let exp = now + self.expiry; let exp = now + self.expiry;
let claims = Claims { let claims = Claims {
sub: user_id, sub: context.user_id,
tenant_id, tenant_id: context.tenant_id,
username: username.to_owned(), username: context.username,
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,
@@ -65,13 +90,38 @@ 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 {
doc_id: document_id, subject: DownloadSubject::Document {
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(),
@@ -148,15 +198,27 @@ 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 {
pub doc_id: Uuid, #[serde(flatten)]
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,
+166 -36
View File
@@ -1,58 +1,153 @@
pub mod api_tokens; pub mod api_tokens;
pub mod capability_guard;
pub mod capability_sets;
pub mod jwt; pub mod jwt;
pub mod passkeys; pub mod passkeys;
pub mod password; pub mod password;
use axum::{async_trait, extract::FromRequestParts, http::request::Parts}; use std::sync::{Arc, Mutex};
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 utoipa::ToSchema;
use crate::{ use crate::{
error::AppError, auth::capability_sets::{load_capabilities_for_set, load_capability_set},
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)]
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)] #[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;
async fn from_request_parts( #[allow(refining_impl_trait)]
parts: &mut Parts, fn from_request_parts<'a>(
parts: &'a mut Parts,
state: &AppState, state: &AppState,
) -> Result<Self, Self::Rejection> { ) -> impl std::future::Future<Output = Result<Self, Self::Rejection>> + Send + 'a {
if let Some(user) = parts.extensions.get::<AuthenticatedUser>() { let state = state.clone();
return Ok(user.clone()); async move {
} if let Some(user) = parts.extensions.get::<AuthenticatedUser>() {
return Ok(user.clone());
}
let TypedHeader(Authorization(bearer)) = let TypedHeader(Authorization(bearer)) =
TypedHeader::<Authorization<Bearer>>::from_request_parts(parts, state) TypedHeader::<Authorization<Bearer>>::from_request_parts(parts, &state)
.await .await
.map_err(|_| AppError::unauthorized())?;
let claims = state
.jwt
.verify_token(bearer.token())
.map_err(|_| AppError::unauthorized())?; .map_err(|_| AppError::unauthorized())?;
let claims = state let mut tenant_conn = state.db_for_tenant(claims.tenant_id)?;
.jwt let capability_set = load_capability_set(&mut tenant_conn, claims.capability_set_id)
.verify_token(bearer.token()) .map_err(|_| AppError::unauthorized())?;
.map_err(|_| AppError::unauthorized())?;
let user = AuthenticatedUser { if capability_set.cap_version != claims.cap_version {
user_id: claims.sub, return Err(AppError::unauthorized());
username: claims.username, }
tenant_id: claims.tenant_id,
};
parts.extensions.insert(user.clone()); let capabilities = load_capabilities_for_set(&mut tenant_conn, capability_set.id)
.map_err(|_| AppError::unauthorized())?;
Ok(user) 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)
}
} }
} }
@@ -69,23 +164,58 @@ impl TenantScopedConn {
} }
} }
#[async_trait]
impl FromRequestParts<AppState> for TenantScopedConn { impl FromRequestParts<AppState> for TenantScopedConn {
type Rejection = AppError; type Rejection = AppError;
async fn from_request_parts( #[allow(refining_impl_trait)]
parts: &mut Parts, fn from_request_parts<'a>(
parts: &'a mut Parts,
state: &AppState, state: &AppState,
) -> Result<Self, Self::Rejection> { ) -> impl std::future::Future<Output = Result<Self, Self::Rejection>> + Send + 'a {
let user = AuthenticatedUser::from_request_parts(parts, state).await?; let state = state.clone();
let tenant_id = user.tenant_id; async move {
let conn = state.db_for_tenant(tenant_id)?; let user = AuthenticatedUser::from_request_parts(parts, &state).await?;
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)?
};
Ok(Self { ensure_active_tenant_with_conn(&mut conn, tenant_id)?;
conn,
tenant_id, Ok(Self {
user_id: user.user_id, conn,
user, tenant_id,
}) 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(())
}
+6 -3
View File
@@ -1,9 +1,11 @@
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use argon2::{ use argon2::{
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, password_hash::{
rand_core::OsRng as PasswordHashOsRng, PasswordHash, PasswordHasher, PasswordVerifier,
SaltString,
},
Argon2, Argon2,
}; };
use rand::rngs::OsRng;
pub fn verify_password(password: &str, password_hash: &str) -> Result<bool> { pub fn verify_password(password: &str, password_hash: &str) -> Result<bool> {
let parsed_hash = PasswordHash::new(password_hash).map_err(|err| anyhow!(err))?; let parsed_hash = PasswordHash::new(password_hash).map_err(|err| anyhow!(err))?;
@@ -13,7 +15,8 @@ pub fn verify_password(password: &str, password_hash: &str) -> Result<bool> {
} }
pub fn hash_password(password: &str) -> Result<String> { pub fn hash_password(password: &str) -> Result<String> {
let salt = SaltString::generate(&mut OsRng); let mut rng = PasswordHashOsRng;
let salt = SaltString::generate(&mut rng);
let hash = Argon2::default() let hash = Argon2::default()
.hash_password(password.as_bytes(), &salt) .hash_password(password.as_bytes(), &salt)
.map_err(|err| anyhow!(err))?; .map_err(|err| anyhow!(err))?;
+350 -77
View File
@@ -3,29 +3,31 @@ use std::sync::Arc;
use anyhow::{anyhow, bail, Context, Result}; use anyhow::{anyhow, bail, Context, Result};
use chrono::{Duration as ChronoDuration, Utc}; use chrono::{Duration as ChronoDuration, Utc};
use clap::{Parser, Subcommand, ValueEnum}; use clap::{Parser, Subcommand, ValueEnum};
use diesel::{dsl::exists, prelude::*, select}; use diesel::{dsl::exists, pg::PgConnection, prelude::*, select};
use rand::{rngs::OsRng, RngCore}; use diesel_migrations::MigrationHarness;
use rand::{rngs::OsRng, TryRngCore};
use reqwest::{Client, Method, StatusCode}; use reqwest::{Client, Method, StatusCode};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use tokio::task;
use uuid::Uuid; use uuid::Uuid;
use papercrate::{ use papercrate::{
config::AppConfig, auth::capability_sets::{ensure_capability_set, owner_capabilities},
config::{redact_database_url, AppConfig},
db::{self, PgPool}, db::{self, PgPool},
documents::search::ensure_quickwit_index, documents::search::ensure_quickwit_index,
jobs::{enqueue_job, JOB_ANALYZE_DOCUMENT}, jobs::{enqueue_job, JOB_ANALYZE_DOCUMENT, JOB_DELETE_TENANT},
migrations::MIGRATIONS,
models::{ models::{
DocumentAsset, DocumentAssetObject, MagicToken, MagicTokenKind, NewUser, NewUserMembership, DocumentAsset, MagicToken, MagicTokenKind, NewUser, NewUserMembership, Tenant,
Tenant, TenantStatus, User, TenantStatus, User,
}, },
s3, s3,
schema::{ schema::{document_assets, documents, magic_tokens, tenants, user_memberships, users},
document_asset_objects, document_assets, documents, magic_tokens, tenants,
user_memberships, users,
},
storage::{ObjectStorage, S3Storage, TenantStorage}, storage::{ObjectStorage, S3Storage, TenantStorage},
tenants::TenantService, tenants::{apply_tenant_guc, clear_tenant_context, TenantService},
utils::tracing::init_tracing, utils::{text::normalize_identifier, tracing::init_tracing},
workers::tenants::{build_delete_proof_message, sign_delete_proof, DeleteAction},
}; };
#[derive(Parser)] #[derive(Parser)]
@@ -57,6 +59,15 @@ enum Command {
}, },
DeleteTenant { DeleteTenant {
tenant_id: Uuid, tenant_id: Uuid,
#[arg(long = "tenant-name")]
tenant_name: String,
},
ResetTenant {
tenant_id: Uuid,
#[arg(long = "tenant-name")]
tenant_name: String,
#[arg(long = "final-status", value_enum, default_value_t = TenantFinalStatusArg::Active)]
final_status: TenantFinalStatusArg,
}, },
AddUserToTenant { AddUserToTenant {
username: String, username: String,
@@ -72,6 +83,10 @@ enum Command {
ListTenants, ListTenants,
DeleteAssets { DeleteAssets {
tenant_id: Uuid, tenant_id: Uuid,
#[arg(long = "asset-type")]
asset_type: Option<String>,
#[arg(long = "all", help = "Confirm deleting every asset for the tenant")]
delete_all: bool,
}, },
QuickwitCreate { QuickwitCreate {
tenant_id: Uuid, tenant_id: Uuid,
@@ -79,6 +94,15 @@ enum Command {
QuickwitDelete { QuickwitDelete {
tenant_id: Uuid, tenant_id: Uuid,
}, },
EnqueueDeleteTenant {
tenant_id: Uuid,
#[arg(long = "tenant-name")]
tenant_name: String,
#[arg(long = "remove-tenant")]
remove_tenant: bool,
#[arg(long = "final-status", value_enum)]
final_status: Option<TenantFinalStatusArg>,
},
MagicToken { MagicToken {
username: String, username: String,
#[arg(long = "ttl-minutes", default_value_t = 10)] #[arg(long = "ttl-minutes", default_value_t = 10)]
@@ -92,6 +116,14 @@ enum Command {
#[arg(long = "kind", value_enum, default_value_t = MagicTokenKindArg::EmailLogin)] #[arg(long = "kind", value_enum, default_value_t = MagicTokenKindArg::EmailLogin)]
kind: MagicTokenKindArg, kind: MagicTokenKindArg,
}, },
MigrateDatabase {
#[arg(
long = "database-url",
value_name = "URL",
help = "Override the migrations database URL (defaults to MIGRATIONS_DATABASE_URL or DATABASE_URL)"
)]
database_url: Option<String>,
},
} }
#[derive(Copy, Clone, Debug, ValueEnum)] #[derive(Copy, Clone, Debug, ValueEnum)]
@@ -111,6 +143,21 @@ impl From<MagicTokenKindArg> for MagicTokenKind {
} }
} }
#[derive(Copy, Clone, Debug, ValueEnum)]
enum TenantFinalStatusArg {
Active,
Suspended,
}
impl TenantFinalStatusArg {
fn as_str(&self) -> &'static str {
match self {
TenantFinalStatusArg::Active => "active",
TenantFinalStatusArg::Suspended => "suspended",
}
}
}
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
init_tracing("info"); init_tracing("info");
@@ -127,7 +174,15 @@ async fn main() -> Result<()> {
storage_root, storage_root,
quickwit_index, quickwit_index,
} => create_tenant(&pool, &name, storage_root, quickwit_index)?, } => create_tenant(&pool, &name, storage_root, quickwit_index)?,
Command::DeleteTenant { tenant_id } => delete_tenant(&pool, tenant_id)?, Command::DeleteTenant {
tenant_id,
tenant_name,
} => delete_tenant(&config, &pool, tenant_id, &tenant_name)?,
Command::ResetTenant {
tenant_id,
tenant_name,
final_status,
} => reset_tenant(&config, &pool, tenant_id, &tenant_name, final_status)?,
Command::AddUserToTenant { Command::AddUserToTenant {
username, username,
tenant_id, tenant_id,
@@ -138,8 +193,17 @@ async fn main() -> Result<()> {
} => remove_user_from_tenant(&pool, &username, tenant_id)?, } => remove_user_from_tenant(&pool, &username, tenant_id)?,
Command::ReanalyzeDocuments { tenant_id } => reanalyze_documents(&pool, tenant_id)?, Command::ReanalyzeDocuments { tenant_id } => reanalyze_documents(&pool, tenant_id)?,
Command::ListTenants => list_tenants(&pool)?, Command::ListTenants => list_tenants(&pool)?,
Command::DeleteAssets { tenant_id } => { Command::DeleteAssets {
delete_assets_for_tenant(&config, &pool, tenant_id).await? tenant_id,
asset_type,
delete_all,
} => {
let asset_type = asset_type.as_deref();
if asset_type.is_none() && !delete_all {
bail!("refusing to delete all assets without --all confirmation");
}
delete_assets_for_tenant(&config, &pool, tenant_id, asset_type).await?
} }
Command::QuickwitCreate { tenant_id } => { Command::QuickwitCreate { tenant_id } => {
quickwit_index(&config, &pool, tenant_id, Method::POST).await? quickwit_index(&config, &pool, tenant_id, Method::POST).await?
@@ -147,6 +211,19 @@ async fn main() -> Result<()> {
Command::QuickwitDelete { tenant_id } => { Command::QuickwitDelete { tenant_id } => {
quickwit_index(&config, &pool, tenant_id, Method::DELETE).await? quickwit_index(&config, &pool, tenant_id, Method::DELETE).await?
} }
Command::EnqueueDeleteTenant {
tenant_id,
tenant_name,
remove_tenant,
final_status,
} => enqueue_delete_tenant_job(
&config,
&pool,
tenant_id,
&tenant_name,
remove_tenant,
final_status,
)?,
Command::MagicToken { Command::MagicToken {
username, username,
ttl_minutes, ttl_minutes,
@@ -155,26 +232,55 @@ async fn main() -> Result<()> {
} => { } => {
create_magic_token(&pool, &username, ttl_minutes, max_uses, kind.into())?; create_magic_token(&pool, &username, ttl_minutes, max_uses, kind.into())?;
} }
Command::MigrateDatabase { database_url } => {
let url = database_url.unwrap_or_else(|| config.migrations_database_url().to_string());
migrate_database(url).await?;
}
} }
Ok(()) Ok(())
} }
async fn migrate_database(database_url: String) -> Result<()> {
let redacted = redact_database_url(&database_url);
tracing::info!(database_url = %redacted, "running pending migrations");
let result = task::spawn_blocking(move || -> Result<()> {
let mut conn =
PgConnection::establish(&database_url).context("failed to connect to database")?;
conn.run_pending_migrations(MIGRATIONS)
.map_err(|err| anyhow!("failed to run migrations: {err}"))?;
Ok(())
})
.await
.context("migration task panicked")?;
result?;
tracing::info!(database_url = %redacted, "migrations completed");
Ok(())
}
fn create_user(pool: &PgPool, username: &str) -> Result<()> { fn create_user(pool: &PgPool, username: &str) -> Result<()> {
if username.trim().is_empty() { let username = normalize_identifier(
bail!("username must not be empty"); username,
} 100,
"username must not be empty",
"username must not exceed 100 characters",
Some("username may only contain printable characters"),
|ch| !ch.is_control(),
)
.map_err(|err| anyhow!("{:?}", err))?;
let mut conn = pool.get().context("failed to get database connection")?; let mut conn = pool.get().context("failed to get database connection")?;
let exists: bool = let exists: bool =
select(exists(users::table.filter(users::username.eq(username)))).get_result(&mut conn)?; select(exists(users::table.filter(users::username.eq(&username)))).get_result(&mut conn)?;
if exists { if exists {
bail!("user '{}' already exists", username); bail!("user '{}' already exists", username);
} }
let new_user = NewUser { let new_user = NewUser {
id: Uuid::new_v4(), id: Uuid::new_v4(),
username: username.to_string(), username: username.clone(),
}; };
diesel::insert_into(users::table) diesel::insert_into(users::table)
@@ -226,8 +332,17 @@ fn delete_user(pool: &PgPool, username: &str) -> Result<()> {
.optional()? .optional()?
.ok_or_else(|| anyhow!("user '{}' not found", username))?; .ok_or_else(|| anyhow!("user '{}' not found", username))?;
diesel::delete(user_memberships::table.filter(user_memberships::user_id.eq(user.id))) let has_memberships: bool = select(exists(
.execute(&mut conn)?; user_memberships::table.filter(user_memberships::user_id.eq(user.id)),
))
.get_result(&mut conn)?;
if has_memberships {
bail!(
"user '{}' is still a member of one or more tenants; remove memberships first",
username
);
}
diesel::delete(users::table.filter(users::id.eq(user.id))).execute(&mut conn)?; diesel::delete(users::table.filter(users::id.eq(user.id))).execute(&mut conn)?;
println!("deleted user '{}'", username); println!("deleted user '{}'", username);
@@ -316,7 +431,9 @@ fn create_magic_token(
fn generate_random_token() -> String { fn generate_random_token() -> String {
let mut bytes = [0u8; 32]; let mut bytes = [0u8; 32];
OsRng.fill_bytes(&mut bytes); OsRng
.try_fill_bytes(&mut bytes)
.expect("failed to read random bytes");
hex::encode(bytes) hex::encode(bytes)
} }
@@ -326,7 +443,51 @@ fn hash_token(token: &str) -> String {
hex::encode(hasher.finalize()) hex::encode(hasher.finalize())
} }
fn delete_tenant(pool: &PgPool, tenant_id: Uuid) -> Result<()> { fn delete_tenant(
config: &AppConfig,
pool: &PgPool,
tenant_id: Uuid,
tenant_name: &str,
) -> Result<()> {
enqueue_delete_tenant_job_internal(config, pool, tenant_id, tenant_name, true, None)?;
println!(
"delete job enqueued; tenant '{}' will be permanently removed",
tenant_name
);
Ok(())
}
fn reset_tenant(
config: &AppConfig,
pool: &PgPool,
tenant_id: Uuid,
tenant_name: &str,
final_status: TenantFinalStatusArg,
) -> Result<()> {
enqueue_delete_tenant_job_internal(
config,
pool,
tenant_id,
tenant_name,
false,
Some(final_status),
)?;
println!(
"reset job enqueued; tenant '{}' will be wiped and set to {}",
tenant_name,
final_status.as_str()
);
Ok(())
}
fn enqueue_delete_tenant_job_internal(
config: &AppConfig,
pool: &PgPool,
tenant_id: Uuid,
expected_name: &str,
remove_tenant: bool,
final_status: Option<TenantFinalStatusArg>,
) -> Result<()> {
let mut conn = pool.get().context("failed to get database connection")?; let mut conn = pool.get().context("failed to get database connection")?;
let tenant: Tenant = tenants::table let tenant: Tenant = tenants::table
@@ -335,19 +496,100 @@ fn delete_tenant(pool: &PgPool, tenant_id: Uuid) -> Result<()> {
.optional()? .optional()?
.ok_or_else(|| anyhow!("tenant '{}' not found", tenant_id))?; .ok_or_else(|| anyhow!("tenant '{}' not found", tenant_id))?;
let member_exists: bool = select(exists( apply_tenant_guc(&mut conn, tenant.id)
user_memberships::table.filter(user_memberships::tenant_id.eq(tenant.id)), .map_err(|err| anyhow!("failed to set tenant context for {}: {err:?}", tenant.name))?;
))
.get_result(&mut conn)?; if tenant.name != expected_name {
if member_exists { bail!(
bail!("tenant '{}' still has user memberships", tenant.name); "tenant name mismatch: expected '{}', database has '{}'",
expected_name,
tenant.name
);
} }
diesel::delete(tenants::table.filter(tenants::id.eq(tenant.id))).execute(&mut conn)?; diesel::update(tenants::table.find(tenant.id))
println!("deleted tenant '{}'", tenant.name); .set(tenants::status.eq(TenantStatus::Deleting))
.execute(&mut conn)?;
let nonce = generate_random_token();
let issued_at = Utc::now();
let issued_at_str = issued_at.to_rfc3339();
let action = if remove_tenant {
DeleteAction::Delete
} else {
DeleteAction::Reset
};
let resolved_final_status = if remove_tenant {
None
} else {
Some(
final_status
.unwrap_or(TenantFinalStatusArg::Suspended)
.as_str(),
)
};
let message = build_delete_proof_message(
tenant.id,
expected_name,
action,
&nonce,
&issued_at_str,
resolved_final_status,
);
let signature = sign_delete_proof(&config.jwt_secret, &message).map_err(|err| anyhow!(err))?;
let mut payload = serde_json::json!({
"remove_tenant": remove_tenant,
"tenant_name": tenant.name.clone(),
"action": action.as_str(),
"nonce": nonce,
"issued_at": issued_at_str,
"signature": signature,
});
if let Some(status) = resolved_final_status {
payload["final_status"] = serde_json::json!(status);
}
enqueue_job(&mut conn, tenant.id, JOB_DELETE_TENANT, payload, None)?;
let status_label = if remove_tenant {
"deleted"
} else {
final_status.map(|s| s.as_str()).unwrap_or("suspended")
};
println!(
"delete-tenant job enqueued for '{}' (remove_tenant={}, final_status={})",
tenant.name, remove_tenant, status_label
);
clear_tenant_context(&mut conn).map_err(|err| {
anyhow!(
"failed to clear tenant context for {}: {err:?}",
tenant.name
)
})?;
Ok(()) Ok(())
} }
fn enqueue_delete_tenant_job(
config: &AppConfig,
pool: &PgPool,
tenant_id: Uuid,
tenant_name: &str,
remove_tenant: bool,
final_status: Option<TenantFinalStatusArg>,
) -> Result<()> {
enqueue_delete_tenant_job_internal(
config,
pool,
tenant_id,
tenant_name,
remove_tenant,
final_status,
)
}
fn add_user_to_tenant(pool: &PgPool, username: &str, tenant_id: Uuid) -> Result<()> { fn add_user_to_tenant(pool: &PgPool, username: &str, tenant_id: Uuid) -> Result<()> {
let mut conn = pool.get().context("failed to get database connection")?; let mut conn = pool.get().context("failed to get database connection")?;
@@ -363,10 +605,15 @@ fn add_user_to_tenant(pool: &PgPool, username: &str, tenant_id: Uuid) -> Result<
.optional()? .optional()?
.ok_or_else(|| anyhow!("tenant '{}' not found", tenant_id))?; .ok_or_else(|| anyhow!("tenant '{}' not found", tenant_id))?;
let owner_capability_set_id = ensure_capability_set(&mut conn, tenant.id, owner_capabilities())
.map_err(|err| anyhow!("failed to ensure owner capability set: {:?}", err))?
.id;
let membership = NewUserMembership { let membership = NewUserMembership {
id: Uuid::new_v4(), id: Uuid::new_v4(),
user_id: user.id, user_id: user.id,
tenant_id: tenant.id, tenant_id: tenant.id,
capability_set_id: Some(owner_capability_set_id),
}; };
diesel::insert_into(user_memberships::table) diesel::insert_into(user_memberships::table)
@@ -479,10 +726,10 @@ async fn delete_assets_for_tenant(
config: &AppConfig, config: &AppConfig,
pool: &PgPool, pool: &PgPool,
tenant_id: Uuid, tenant_id: Uuid,
asset_type: Option<&str>,
) -> Result<()> { ) -> Result<()> {
let s3_client = s3::build_client(config).await?; let bucket = s3::build_bucket(config)?;
let storage: Arc<dyn ObjectStorage> = let storage: Arc<dyn ObjectStorage> = Arc::new(S3Storage::new(bucket));
Arc::new(S3Storage::new(s3_client, config.s3_bucket.clone()));
let mut conn = pool.get().context("failed to get database connection")?; let mut conn = pool.get().context("failed to get database connection")?;
let tenant: Tenant = tenants::table let tenant: Tenant = tenants::table
@@ -491,57 +738,83 @@ async fn delete_assets_for_tenant(
.optional()? .optional()?
.ok_or_else(|| anyhow!("tenant '{}' not found", tenant_id))?; .ok_or_else(|| anyhow!("tenant '{}' not found", tenant_id))?;
let tenant_storage = TenantStorage::new(Arc::clone(&storage), &tenant) apply_tenant_guc(&mut conn, tenant.id)
.with_context(|| format!("missing storage root for tenant {}", tenant.name))?; .map_err(|err| anyhow!("failed to set tenant context for {}: {err:?}", tenant.name))?;
let assets: Vec<DocumentAsset> = document_assets::table let result = async {
.filter(document_assets::tenant_id.eq(tenant.id)) let tenant_storage = TenantStorage::new(Arc::clone(&storage), &tenant)
.load(&mut conn) .with_context(|| format!("missing storage root for tenant {}", tenant.name))?;
.with_context(|| format!("failed to load assets for tenant {}", tenant.name))?;
if assets.is_empty() { let mut asset_query = document_assets::table
println!("Tenant {}: no assets", tenant.name); .filter(document_assets::tenant_id.eq(tenant.id))
return Ok(()); .into_boxed();
}
println!( if let Some(asset_type) = asset_type {
"Tenant {} ({}): deleting {} assets…", asset_query = asset_query.filter(document_assets::asset_type.eq(asset_type));
tenant.name,
tenant.id,
assets.len()
);
let asset_ids: Vec<Uuid> = assets.iter().map(|asset| asset.id).collect();
let objects: Vec<DocumentAssetObject> = document_asset_objects::table
.filter(document_asset_objects::tenant_id.eq(tenant.id))
.filter(document_asset_objects::asset_id.eq_any(&asset_ids))
.load(&mut conn)
.with_context(|| format!("failed to load asset objects for tenant {}", tenant.name))?;
for object in &objects {
if let Err(err) = tenant_storage.delete_object(&object.s3_key).await {
eprintln!(
"Failed to delete object {} (tenant {}): {err}",
object.s3_key, tenant.name
);
} }
}
diesel::delete( let assets: Vec<DocumentAsset> = asset_query
document_asset_objects::table .load(&mut conn)
.filter(document_asset_objects::tenant_id.eq(tenant.id)) .with_context(|| format!("failed to load assets for tenant {}", tenant.name))?;
.filter(document_asset_objects::asset_id.eq_any(&asset_ids)),
)
.execute(&mut conn)
.with_context(|| format!("failed to remove asset objects for tenant {}", tenant.name))?;
diesel::delete(document_assets::table.filter(document_assets::tenant_id.eq(tenant.id))) if assets.is_empty() {
match asset_type {
Some(asset_type) => {
println!("Tenant {}: no assets of type '{}'", tenant.name, asset_type)
}
None => println!("Tenant {}: no assets", tenant.name),
}
return Ok(());
}
match asset_type {
Some(asset_type) => println!(
"Tenant {} ({}): deleting {} '{}' assets…",
tenant.name,
tenant.id,
assets.len(),
asset_type
),
None => println!(
"Tenant {} ({}): deleting {} assets…",
tenant.name,
tenant.id,
assets.len()
),
}
let asset_ids: Vec<Uuid> = assets.iter().map(|asset| asset.id).collect();
for asset in &assets {
if let Err(err) = tenant_storage.delete_object(&asset.s3_key).await {
eprintln!(
"Failed to delete object {} (tenant {}): {err}",
asset.s3_key, tenant.name
);
}
}
diesel::delete(
document_assets::table
.filter(document_assets::tenant_id.eq(tenant.id))
.filter(document_assets::id.eq_any(&asset_ids)),
)
.execute(&mut conn) .execute(&mut conn)
.with_context(|| format!("failed to remove asset records for tenant {}", tenant.name))?; .with_context(|| format!("failed to remove asset records for tenant {}", tenant.name))?;
println!("Tenant {}: asset records deleted.", tenant.name); println!("Tenant {}: asset records deleted.", tenant.name);
Ok(()) Ok(())
}
.await;
clear_tenant_context(&mut conn).map_err(|err| {
anyhow!(
"failed to clear tenant context for {}: {err:?}",
tenant.name
)
})?;
result
} }
async fn quickwit_index( async fn quickwit_index(
+76 -1
View File
@@ -1,6 +1,7 @@
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;
@@ -9,6 +10,8 @@ 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")]
@@ -41,6 +44,8 @@ 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)]
@@ -58,6 +63,16 @@ pub struct AppConfig {
pub worker_max_document_bytes: u64, pub worker_max_document_bytes: u64,
#[serde(default = "default_upload_body_limit_bytes")] #[serde(default = "default_upload_body_limit_bytes")]
pub upload_body_limit_bytes: u64, 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)] #[serde(default)]
pub webauthn_rp_id: Option<String>, pub webauthn_rp_id: Option<String>,
#[serde(default)] #[serde(default)]
@@ -73,12 +88,14 @@ 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(), 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, worker_max_document_bytes = config.worker_max_document_bytes,
upload_body_limit_bytes = config.upload_body_limit_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)
@@ -93,6 +110,18 @@ 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 {
@@ -180,11 +209,57 @@ fn default_upload_body_limit_bytes() -> u64 {
128 * 1024 * 1024 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 { fn default_webauthn_rp_name() -> String {
"Papercrate".to_string() "Papercrate".to_string()
} }
fn redact_database_url(raw: &str) -> 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() {
+53 -84
View File
@@ -1,6 +1,7 @@
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;
@@ -8,10 +9,16 @@ use utoipa::ToSchema;
use uuid::Uuid; use uuid::Uuid;
use crate::error::{AppError, AppResult}; use crate::error::{AppError, AppResult};
use crate::models::{Document, DocumentAsset, DocumentAssetObject, DocumentVersion}; use crate::models::{Document, DocumentAsset, DocumentVersion};
use crate::schema::{document_asset_objects, document_assets, document_versions}; use crate::schema::{document_assets, document_versions};
use crate::state::AppState; use crate::state::{AppState, PgPooledConnection};
use crate::utils::time::to_iso; use crate::utils::{http::inline_content_disposition, 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 {
@@ -22,21 +29,7 @@ pub struct DocumentAssetResponse {
pub metadata: Value, pub metadata: Value,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
#[schema(nullable)] #[schema(nullable)]
pub cardinality: Option<i32>, pub download: Option<DownloadLink>,
}
#[derive(Serialize, Clone, ToSchema)]
pub struct DocumentAssetObjectResponse {
pub id: Uuid,
pub ordinal: i32,
#[schema(value_type = Object)]
pub metadata: Value,
#[serde(skip_serializing_if = "Option::is_none")]
#[schema(nullable)]
pub url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[schema(nullable)]
pub expires_at: Option<i64>,
} }
#[derive(Serialize, ToSchema)] #[derive(Serialize, ToSchema)]
@@ -49,9 +42,7 @@ pub struct DocumentAssetDetailResponse {
pub created_at: String, pub created_at: String,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
#[schema(nullable)] #[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)]
@@ -71,22 +62,35 @@ 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_path: String, pub download: DownloadLink,
} }
pub fn build_download_path( pub fn build_download_link(
state: &AppState, state: &AppState,
document: &Document, document: &Document,
version_id: Uuid,
user_id: Uuid, user_id: Uuid,
) -> AppResult<String> { ) -> AppResult<DownloadLink> {
state state
.jwt .jwt
.generate_download_token(document.id, user_id, document.tenant_id) .generate_download_token(document.id, version_id, user_id, document.tenant_id)
.map(|token| format!("/download/{token}"))
.map_err(|err| { .map_err(|err| {
tracing::error!(error = ?err, "failed to generate download token"); tracing::error!(error = ?err, "failed to generate download token");
AppError::internal("failed to generate download token") 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 {
@@ -106,13 +110,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,
cardinality: asset.cardinality, download: None,
} }
} }
pub fn to_asset_detail_response( pub fn to_asset_detail_response(
asset: DocumentAsset, asset: DocumentAsset,
objects: Vec<DocumentAssetObjectResponse>, download: Option<DownloadLink>,
) -> DocumentAssetDetailResponse { ) -> DocumentAssetDetailResponse {
DocumentAssetDetailResponse { DocumentAssetDetailResponse {
id: asset.id, id: asset.id,
@@ -120,69 +124,46 @@ 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),
cardinality: asset.cardinality, download,
objects,
} }
} }
pub fn to_asset_object_response( pub fn asset_disposition(asset: &DocumentAsset) -> Option<String> {
object: DocumentAssetObject, let filename = asset.asset_type.clone();
url: Option<String>, inline_content_disposition(&filename)
expires_at: Option<i64>,
) -> DocumentAssetObjectResponse {
DocumentAssetObjectResponse {
id: object.id,
ordinal: object.ordinal,
metadata: object.metadata,
url,
expires_at,
}
} }
pub fn delete_asset(state: &AppState, tenant_id: Uuid, asset_id: Uuid) -> AppResult<()> { pub fn delete_asset(
let mut conn = state.db_for_tenant(tenant_id)?; conn: &mut PgPooledConnection,
tenant_id: Uuid,
asset_id: Uuid,
) -> AppResult<()> {
diesel::delete( diesel::delete(
document_assets::table document_assets::table
.filter(document_assets::id.eq(asset_id)) .filter(document_assets::id.eq(asset_id))
.filter(document_assets::tenant_id.eq(tenant_id)), .filter(document_assets::tenant_id.eq(tenant_id)),
) )
.execute(&mut conn)?; .execute(conn)?;
Ok(()) Ok(())
} }
pub async fn load_asset_responses( pub fn load_asset_responses_with_conn(
state: &AppState, conn: &mut PgPooledConnection,
tenant_id: Uuid, tenant_id: Uuid,
version_id: Uuid, version_id: Uuid,
) -> AppResult<Vec<DocumentAssetResponse>> { ) -> AppResult<Vec<DocumentAssetResponse>> {
let mut conn = state.db_for_tenant(tenant_id)?; 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(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())
.select(( .load(conn)?;
document_assets::all_columns,
document_asset_objects::all_columns.nullable(),
))
.load(&mut conn)?;
drop(conn);
Ok(assets Ok(assets.into_iter().map(to_asset_summary).collect())
.into_iter()
.map(|(asset, _)| to_asset_summary(asset))
.collect())
} }
pub fn load_primary_assets( pub fn load_primary_assets(
state: &AppState, conn: &mut PgPooledConnection,
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() {
@@ -199,37 +180,25 @@ 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(&mut conn)?; .load(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, Option<DocumentAssetObject>)> = document_assets::table let assets: Vec<DocumentAsset> = 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(),
)) ))
.select(( .load(conn)?;
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, _object) in assets { for asset 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
+4
View File
@@ -2,5 +2,9 @@ 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};
+58
View File
@@ -0,0 +1,58 @@
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
pub const UNICODE_COLLATION_NAME: &str = "unicode_ci";
pub const UNICODE_COLLATION_LOCALE: &str = "und-u-ks-level2";
const TITLE_ASC: &str = "title COLLATE \"unicode_ci\" ASC";
const TITLE_DESC: &str = "title COLLATE \"unicode_ci\" DESC";
const ISSUED_AT_ASC: &str = "issued_at ASC NULLS LAST";
const ISSUED_AT_DESC: &str = "issued_at DESC NULLS LAST";
const CREATED_AT_ASC: &str = "created_at ASC";
const CREATED_AT_DESC: &str = "created_at DESC";
const UPDATED_AT_ASC: &str = "updated_at ASC";
const UPDATED_AT_DESC: &str = "updated_at DESC";
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum DocumentSortField {
Title,
IssuedAt,
CreatedAt,
UpdatedAt,
}
impl Default for DocumentSortField {
fn default() -> Self {
DocumentSortField::Title
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum SortDirection {
Asc,
Desc,
}
impl Default for SortDirection {
fn default() -> Self {
SortDirection::Asc
}
}
pub fn ordering_clauses(
field: DocumentSortField,
direction: SortDirection,
) -> (&'static str, Option<&'static str>) {
match (field, direction) {
(DocumentSortField::Title, SortDirection::Asc) => (TITLE_ASC, None),
(DocumentSortField::Title, SortDirection::Desc) => (TITLE_DESC, None),
(DocumentSortField::IssuedAt, SortDirection::Asc) => (ISSUED_AT_ASC, Some(TITLE_ASC)),
(DocumentSortField::IssuedAt, SortDirection::Desc) => (ISSUED_AT_DESC, Some(TITLE_ASC)),
(DocumentSortField::CreatedAt, SortDirection::Asc) => (CREATED_AT_ASC, Some(TITLE_ASC)),
(DocumentSortField::CreatedAt, SortDirection::Desc) => (CREATED_AT_DESC, Some(TITLE_ASC)),
(DocumentSortField::UpdatedAt, SortDirection::Asc) => (UPDATED_AT_ASC, Some(TITLE_ASC)),
(DocumentSortField::UpdatedAt, SortDirection::Desc) => (UPDATED_AT_DESC, Some(TITLE_ASC)),
}
}
+33
View File
@@ -0,0 +1,33 @@
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)
}
+14
View File
@@ -163,6 +163,20 @@ pub async fn ensure_quickwit_index(client: &Client, endpoint: &str, index_id: &s
} }
} }
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) {
+18 -6
View File
@@ -5,7 +5,8 @@ use axum::{
}; };
use serde::Serialize; use serde::Serialize;
use serde_json::Value; 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>;
@@ -39,6 +40,10 @@ 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")
} }
@@ -58,10 +63,16 @@ impl AppError {
} }
} }
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(ErrorResponse { let body = Json(ApiErrorResponse {
error: self.message, error: self.message,
code: self.code, code: self.code,
details: self.details, details: self.details,
@@ -70,8 +81,8 @@ impl IntoResponse for AppError {
} }
} }
#[derive(Serialize)] #[derive(Serialize, ToSchema)]
struct ErrorResponse { pub struct ApiErrorResponse {
error: String, error: String,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
code: Option<String>, code: Option<String>,
@@ -84,8 +95,9 @@ impl From<diesel::result::Error> for AppError {
match value { match value {
diesel::result::Error::NotFound => AppError::not_found(), diesel::result::Error::NotFound => AppError::not_found(),
other => { other => {
tracing::error!(error = ?other, "database operation failed"); let message = format!("database operation failed: {other}");
AppError::internal("database operation failed") tracing::error!(error = ?other, message);
AppError::internal(message)
} }
} }
} }
+1
View File
@@ -0,0 +1 @@
pub mod responders;
+157
View File
@@ -0,0 +1,157 @@
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))
}
+167
View File
@@ -0,0 +1,167 @@
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)
}
}
+2 -1
View File
@@ -18,8 +18,9 @@ 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_INDEX_DOCUMENT_TEXT: &str = "index-document-text";
pub const JOB_PROVISION_TENANT: &str = "provision-tenant"; pub const JOB_PROVISION_TENANT: &str = "provision-tenant";
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 {
+5
View File
@@ -3,15 +3,20 @@ 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;
+3
View File
@@ -0,0 +1,3 @@
use diesel_migrations::{embed_migrations, EmbeddedMigrations};
pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
+203 -50
View File
@@ -14,7 +14,7 @@ use uuid::Uuid;
use utoipa::ToSchema; use utoipa::ToSchema;
use crate::schema::sql_types::{ use crate::schema::sql_types::{
ApiTokenCapability as ApiTokenCapabilitySql, MagicTokenKind as MagicTokenKindSql, ApiCapability as ApiCapabilitySql, MagicTokenKind as MagicTokenKindSql,
TenantStatus as TenantStatusSql, TenantStatus as TenantStatusSql,
}; };
use crate::schema::*; use crate::schema::*;
@@ -29,6 +29,7 @@ pub struct UserMembership {
pub tenant_id: Uuid, pub tenant_id: Uuid,
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)]
@@ -37,6 +38,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>,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, AsExpression, FromSqlRow)] #[derive(Debug, Clone, Copy, PartialEq, Eq, AsExpression, FromSqlRow)]
@@ -57,13 +59,64 @@ pub enum MagicTokenKind {
} }
#[derive( #[derive(
Debug, Clone, Copy, PartialEq, Eq, AsExpression, FromSqlRow, Serialize, Deserialize, ToSchema, Debug,
Clone,
Copy,
PartialEq,
Eq,
Hash,
AsExpression,
FromSqlRow,
Serialize,
Deserialize,
ToSchema,
)] )]
#[diesel(sql_type = ApiTokenCapabilitySql)] #[diesel(sql_type = ApiCapabilitySql)]
#[serde(rename_all = "snake_case")] pub enum ApiCapability {
pub enum ApiTokenCapability { #[serde(rename = "documents:read")]
Api, DocumentsRead,
Webdav, #[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 { impl MagicTokenKind {
@@ -79,16 +132,59 @@ impl MagicTokenKind {
} }
} }
impl ApiTokenCapability { impl ApiCapability {
pub fn as_str(&self) -> &'static str { pub fn as_str(&self) -> &'static str {
match self { match self {
ApiTokenCapability::Api => "api", ApiCapability::DocumentsRead => "documents:read",
ApiTokenCapability::Webdav => "webdav", 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] { pub fn variants() -> &'static [&'static str] {
&["api", "webdav"] &[
"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",
]
} }
} }
@@ -98,7 +194,7 @@ impl fmt::Display for MagicTokenKind {
} }
} }
impl fmt::Display for ApiTokenCapability { impl fmt::Display for ApiCapability {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.as_str()) write!(f, "{}", self.as_str())
} }
@@ -111,7 +207,7 @@ impl ToSql<MagicTokenKindSql, Pg> for MagicTokenKind {
} }
} }
impl ToSql<ApiTokenCapabilitySql, Pg> for ApiTokenCapability { impl ToSql<ApiCapabilitySql, Pg> for ApiCapability {
fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result { fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result {
out.write_all(self.as_str().as_bytes())?; out.write_all(self.as_str().as_bytes())?;
Ok(IsNull::No) Ok(IsNull::No)
@@ -131,14 +227,34 @@ impl FromSql<MagicTokenKindSql, Pg> for MagicTokenKind {
} }
} }
impl FromSql<ApiTokenCapabilitySql, Pg> for ApiTokenCapability { impl FromSql<ApiCapabilitySql, Pg> for ApiCapability {
fn from_sql(bytes: PgValue<'_>) -> deserialize::Result<Self> { fn from_sql(bytes: PgValue<'_>) -> deserialize::Result<Self> {
match std::str::from_utf8(bytes.as_bytes())? { match std::str::from_utf8(bytes.as_bytes())? {
"api" => Ok(ApiTokenCapability::Api), "documents:read" => Ok(ApiCapability::DocumentsRead),
"webdav" => Ok(ApiTokenCapability::Webdav), "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( other => Err(Box::new(std::io::Error::new(
std::io::ErrorKind::InvalidData, std::io::ErrorKind::InvalidData,
format!("invalid api_token_capability '{other}'"), format!("invalid api_capability '{other}'"),
))), ))),
} }
} }
@@ -156,14 +272,34 @@ impl str::FromStr for MagicTokenKind {
} }
} }
impl str::FromStr for ApiTokenCapability { impl str::FromStr for ApiCapability {
type Err = &'static str; type Err = &'static str;
fn from_str(value: &str) -> Result<Self, Self::Err> { fn from_str(value: &str) -> Result<Self, Self::Err> {
match value { match value {
"api" => Ok(ApiTokenCapability::Api), "documents:read" => Ok(ApiCapability::DocumentsRead),
"webdav" => Ok(ApiTokenCapability::Webdav), "documents:edit" => Ok(ApiCapability::DocumentsEdit),
_ => Err("unsupported api token capability"), "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"),
} }
} }
} }
@@ -322,7 +458,46 @@ pub struct ApiToken {
pub last_used_at: Option<NaiveDateTime>, pub last_used_at: Option<NaiveDateTime>,
pub expires_at: Option<NaiveDateTime>, pub expires_at: Option<NaiveDateTime>,
pub revoked_at: Option<NaiveDateTime>, pub revoked_at: Option<NaiveDateTime>,
pub capabilities: Vec<ApiTokenCapability>, 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)] #[derive(Debug, Insertable)]
@@ -335,7 +510,7 @@ pub struct NewApiToken {
pub token_hash: String, pub token_hash: String,
pub label: Option<String>, pub label: Option<String>,
pub expires_at: Option<NaiveDateTime>, pub expires_at: Option<NaiveDateTime>,
pub capabilities: Vec<ApiTokenCapability>, pub capability_set_id: Uuid,
} }
#[derive(Debug, Clone, Queryable, Identifiable)] #[derive(Debug, Clone, Queryable, Identifiable)]
@@ -365,7 +540,7 @@ pub struct Document {
pub id: Uuid, pub id: Uuid,
pub filename: String, pub filename: String,
pub original_name: String, pub original_name: String,
pub content_type: Option<String>, pub mime_type: Option<String>,
pub folder_id: Option<Uuid>, pub folder_id: Option<Uuid>,
pub created_at: NaiveDateTime, pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime, pub updated_at: NaiveDateTime,
@@ -383,7 +558,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 content_type: Option<String>, pub mime_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,
@@ -446,7 +621,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 cardinality: Option<i32>, pub s3_key: String,
pub tenant_id: Uuid, pub tenant_id: Uuid,
} }
@@ -458,30 +633,7 @@ 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,
} }
@@ -497,7 +649,8 @@ pub struct Job {
pub last_error: Option<String>, pub last_error: Option<String>,
pub created_at: NaiveDateTime, pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime, pub updated_at: NaiveDateTime,
pub tenant_id: Uuid, pub tenant_id: Option<Uuid>,
pub result: Option<serde_json::Value>,
} }
#[derive(Debug, Insertable)] #[derive(Debug, Insertable)]
+44 -20
View File
@@ -12,6 +12,8 @@ impl OpenApi for ApiDoc {
doc.merge(crate::routes::tags::TagsApiDoc::openapi()); doc.merge(crate::routes::tags::TagsApiDoc::openapi());
doc.merge(crate::routes::correspondents::CorrespondentsApiDoc::openapi()); doc.merge(crate::routes::correspondents::CorrespondentsApiDoc::openapi());
doc.merge(crate::routes::profile::ProfileApiDoc::openapi()); doc.merge(crate::routes::profile::ProfileApiDoc::openapi());
doc.merge(crate::routes::capability_sets::CapabilitySetsApiDoc::openapi());
doc.merge(crate::routes::tenants::TenantsApiDoc::openapi());
doc.info = InfoBuilder::new() doc.info = InfoBuilder::new()
.title("Papercrate API") .title("Papercrate API")
@@ -51,6 +53,14 @@ impl OpenApi for ApiDoc {
.name("Profile") .name("Profile")
.description(Some("User profile and WebDAV tokens")) .description(Some("User profile and WebDAV tokens"))
.build(), .build(),
TagBuilder::new()
.name("Capability Sets")
.description(Some("Capability set management"))
.build(),
TagBuilder::new()
.name("Tenants")
.description(Some("Tenant catalog"))
.build(),
]); ]);
doc doc
@@ -64,38 +74,52 @@ pub mod schemas {
}; };
pub use crate::auth::AuthenticatedUser; pub use crate::auth::AuthenticatedUser;
pub use crate::documents::asset::{ pub use crate::documents::asset::{
DocumentAssetDetailResponse, DocumentAssetObjectResponse, DocumentAssetResponse, DocumentAssetDetailResponse, DocumentAssetResponse, DocumentVersionDetailResponse,
DocumentVersionDetailResponse, DocumentVersionResponse, DocumentVersionResponse,
}; };
pub use crate::documents::correspondents::DocumentCorrespondentResponse; pub use crate::documents::correspondents::DocumentCorrespondentResponse;
pub use crate::models::ApiTokenCapability; pub use crate::error::ApiErrorResponse;
pub use crate::routes::auth::{ pub use crate::models::ApiCapability;
pub use crate::routes::correspondents::{
CorrespondentSummary, CreateCorrespondentRequest,
UpdateCorrespondentRequest,
};
pub use crate::routes::documents::{
AssetRequestQuery, DocumentCheckQuery, MoveDocumentRequest, RestoreDocumentRequest,
UploadDocumentForm,
};
pub use crate::routes::folders::FolderContentsResponse;
pub use crate::routes::tags::{CreateTagRequest, TagCatalogEntry, UpdateTagRequest};
pub use crate::services::auth::{
ApiTokenExchangeRequest, LoginRequest, LoginResponse, LoginResponseVariants, ApiTokenExchangeRequest, LoginRequest, LoginResponse, LoginResponseVariants,
SignupFinishRequest, SignupStartRequest, SignupStartResponse, TenantListResponse, SignupFinishRequest, SignupStartRequest, SignupStartResponse, TenantListResponse,
TenantSelectionRequest, TenantSelectionResponse, TenantSnippet, TenantSelectionRequest, TenantSelectionResponse, TenantSnippet,
}; };
pub use crate::routes::correspondents::{ pub use crate::services::capability_sets::{
CorrespondentSummary, CorrespondentUsage, CreateCorrespondentRequest, CapabilitySetResponse, CreateCapabilitySetRequest, UpdateCapabilitySetRequest,
UpdateCorrespondentRequest,
}; };
pub use crate::routes::documents::{ pub use crate::services::correspondents::{
AssetObjectsQuery, AssetRequestQuery, AssignCorrespondentsRequest, AssignTagsRequest, AssignCorrespondentsRequest, BulkCorrespondentAction, BulkCorrespondentResponse,
BulkCorrespondentAction, BulkCorrespondentResponse, BulkCorrespondentsRequest, BulkCorrespondentsRequest, CorrespondentAssignmentInput,
};
pub use crate::services::documents::{
BulkMoveRequest, BulkMoveResponse, BulkReanalyzeResponse, BulkReanalyzeSelectionRequest, BulkMoveRequest, BulkMoveResponse, BulkReanalyzeResponse, BulkReanalyzeSelectionRequest,
BulkTagAction, BulkTagRequest, BulkTagResponse, CorrespondentAssignmentInput, DocumentCheckResponse, DocumentDetailResponse, DocumentListQuery, DocumentMetadataUpdate,
DocumentCheckQuery, DocumentCheckResponse, DocumentDetailResponse, DocumentListQuery, DocumentResponse, DocumentStatusFilter, TagResponse, UpdateDocumentRequest,
DocumentMetadataUpdate, DocumentResponse, DocumentStatusFilter, MoveDocumentRequest,
RestoreDocumentRequest, TagResponse, UpdateDocumentRequest, UploadDocumentForm,
}; };
pub use crate::routes::folders::{ pub use crate::services::folders::{
CreateFolderRequest, EnsureFolderPathRequest, FolderContentsQuery, FolderContentsResponse, CreateFolderRequest, EnsureFolderPathRequest, FolderContentsQuery, FolderInfo,
FolderInfo, FolderResponse, UpdateFolderRequest, UpdateFolderRequest,
}; };
pub use crate::routes::profile::{ pub use crate::services::profile::{
ApiTokenCreatedResponse, ApiTokenResponse, CreateApiTokenRequest, RevokePasskeyQuery, ApiTokenCreatedResponse, ApiTokenResponse, CreateApiTokenRequest, RevokePasskeyQuery,
UpdateApiTokenCapabilitiesRequest,
}; };
pub use crate::routes::tags::{CreateTagRequest, TagCatalogEntry, UpdateTagRequest}; pub use crate::services::tags::{
AssignTagsRequest, BulkTagAction, BulkTagRequest, BulkTagResponse,
};
pub use crate::services::tenants::{
TenantUserListResponse, TenantUserSummary, UpdateTenantRequest, UpdateTenantUserRequest,
};
} }
#[cfg(test)] #[cfg(test)]
+39 -740
View File
@@ -1,129 +1,33 @@
use axum::{ use axum::{
extract::State, extract::State,
http::{header::SET_COOKIE, HeaderMap, HeaderValue, StatusCode}, http::{HeaderMap, StatusCode},
response::{IntoResponse, Response}, response::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 chrono::{Duration as ChronoDuration, Utc}; use utoipa::OpenApi;
use diesel::{pg::PgConnection, prelude::*};
use rand::{rngs::OsRng, RngCore};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use utoipa::{OpenApi, ToSchema};
use uuid::Uuid;
use crate::{ use crate::{
auth::{ auth::{
api_tokens::{find_active_token_by_secret, touch_api_token},
passkeys::{ passkeys::{
AuthenticationChallengeResponse, PasskeyLoginFinishPayload, PasskeyLoginStartPayload, AuthenticationChallengeResponse, PasskeyLoginFinishPayload, PasskeyLoginStartPayload,
PasskeyRegistrationFinishPayload, PasskeySummary, RegistrationChallengeResponse, PasskeyRegistrationFinishPayload, PasskeySummary, RegistrationChallengeResponse,
}, },
AuthenticatedUser, AuthenticatedUser, TenantScopedConn,
}, },
error::{AppError, AppResult}, error::{AppError, AppResult},
models::{ http::responders::JsonResponse,
ApiTokenCapability, MagicToken, MagicTokenKind, NewUser, NewUserSession, TenantStatus, services::auth::{
User, UserSession, ApiTokenExchangeRequest, AuthService, LoginRequest, LoginResponse, LoginResponseVariants,
}, SignupFinishRequest, SignupStartRequest, SignupStartResponse, TenantListResponse,
schema::{ TenantSelectionRequest, TenantSelectionResponse, TenantSnippet, SESSION_COOKIE_NAME,
magic_tokens::dsl as magic_dsl, tenants::dsl as tenant_dsl,
user_memberships::dsl as memberships_dsl, user_passkeys::dsl as passkey_dsl, user_sessions,
users::dsl,
}, },
state::AppState, state::AppState,
tenants::{
apply_tenant_guc, apply_user_guc, apply_user_session_hash, clear_user_guc,
clear_user_session_hash,
},
}; };
use crate::schema::user_sessions::dsl as session_dsl;
use webauthn_rs::prelude::RegisterPublicKeyCredential;
const SESSION_COOKIE_NAME: &str = "refresh_token";
#[derive(Deserialize, ToSchema)]
pub struct LoginRequest {
#[serde(default)]
pub username: String,
#[serde(default)]
#[schema(nullable)]
pub password: Option<String>,
#[serde(default)]
#[schema(nullable)]
pub magic_token: Option<String>,
#[serde(default)]
#[schema(nullable)]
pub preferred_tenant_id: Option<Uuid>,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct ApiTokenExchangeRequest {
pub api_token: String,
}
#[derive(Deserialize, Serialize, ToSchema)]
pub struct LoginResponse {
pub access_token: String,
pub token_type: String,
pub expires_in: i64,
pub tenant: TenantSnippet,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct TenantSnippet {
pub id: Uuid,
pub name: String,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct TenantSelectionResponse {
pub access_token: String,
pub tenants: Vec<TenantSnippet>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct TenantListResponse {
pub tenants: Vec<TenantSnippet>,
}
#[derive(Deserialize, ToSchema)]
pub struct TenantSelectionRequest {
pub tenant_id: Uuid,
}
#[derive(Deserialize, ToSchema)]
pub struct SignupStartRequest {
pub username: String,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct SignupStartResponse {
pub signup_token: String,
pub challenge: RegistrationChallengeResponse,
}
#[derive(Deserialize, ToSchema)]
pub struct SignupFinishRequest {
pub signup_token: String,
#[schema(value_type = Object)]
pub credential: RegisterPublicKeyCredential,
#[schema(nullable)]
pub nickname: Option<String>,
}
#[derive(Serialize, Deserialize, ToSchema)]
#[serde(untagged)]
pub enum LoginResponseVariants {
Token(LoginResponse),
Selection(TenantSelectionResponse),
}
#[derive(OpenApi)] #[derive(OpenApi)]
#[openapi( #[openapi(
paths( paths(
@@ -134,7 +38,6 @@ pub enum LoginResponseVariants {
refresh, refresh,
logout, logout,
me, me,
list_tenants,
select_tenant, select_tenant,
passkey_register_start, passkey_register_start,
passkey_register_finish, passkey_register_finish,
@@ -160,7 +63,7 @@ pub enum LoginResponseVariants {
crate::auth::passkeys::PasskeyRegistrationFinishPayload, crate::auth::passkeys::PasskeyRegistrationFinishPayload,
crate::auth::passkeys::PasskeyLoginStartPayload, crate::auth::passkeys::PasskeyLoginStartPayload,
crate::auth::passkeys::PasskeyLoginFinishPayload, crate::auth::passkeys::PasskeyLoginFinishPayload,
crate::models::ApiTokenCapability, crate::models::ApiCapability,
)) ))
)] )]
pub struct AuthApiDoc; pub struct AuthApiDoc;
@@ -179,37 +82,7 @@ pub async fn login(
State(state): State<AppState>, State(state): State<AppState>,
Json(payload): Json<LoginRequest>, Json(payload): Json<LoginRequest>,
) -> AppResult<Response> { ) -> AppResult<Response> {
let magic_token = payload AuthService::new(&state).login(payload)
.magic_token
.as_ref()
.map(|value| value.trim())
.filter(|value| !value.is_empty());
if magic_token.is_none() {
if payload.password.is_some() {
return Err(AppError::bad_request(
"password authentication is no longer supported",
));
}
return Err(AppError::bad_request(
"magic_token is required for passwordless login",
));
}
let token_value = magic_token.unwrap();
let mut conn = state.db_unscoped()?;
let username_hint = payload.username.trim();
let preferred_tenant_id = payload.preferred_tenant_id;
magic_token_login(
&state,
&mut conn,
token_value,
(!username_hint.is_empty()).then_some(username_hint),
preferred_tenant_id,
)
} }
#[utoipa::path( #[utoipa::path(
@@ -222,57 +95,8 @@ pub async fn login(
pub async fn api_token_exchange( pub async fn api_token_exchange(
State(state): State<AppState>, State(state): State<AppState>,
Json(payload): Json<ApiTokenExchangeRequest>, Json(payload): Json<ApiTokenExchangeRequest>,
) -> AppResult<Json<LoginResponse>> { ) -> AppResult<JsonResponse<LoginResponse>> {
let secret = payload.api_token.trim(); AuthService::new(&state).exchange_api_token(payload)
if secret.is_empty() {
return Err(AppError::bad_request("api_token must not be empty"));
}
let mut conn = state.db_unscoped()?;
let token = find_active_token_by_secret(&mut conn, None, secret, ApiTokenCapability::Api)?
.ok_or_else(AppError::unauthorized)?;
let user: User = dsl::users.find(token.user_id).first(&mut conn)?;
apply_user_guc(&mut conn, user.id)?;
let membership = memberships_dsl::user_memberships
.filter(memberships_dsl::user_id.eq(user.id))
.filter(memberships_dsl::tenant_id.eq(token.tenant_id))
.select(memberships_dsl::tenant_id)
.first::<Uuid>(&mut conn)
.optional()?;
clear_user_guc(&mut conn)?;
if membership.is_none() {
return Err(AppError::unauthorized());
}
apply_tenant_guc(&mut conn, token.tenant_id)?;
touch_api_token(&mut conn, token.id)?;
let access_token = state
.jwt
.generate_token(user.id, token.tenant_id, &user.username)
.map_err(AppError::from)?;
let tenant_name: String = tenant_dsl::tenants
.find(token.tenant_id)
.select(tenant_dsl::name)
.first(&mut conn)
.map_err(AppError::from)?;
let response = LoginResponse {
access_token,
token_type: "Bearer".to_string(),
expires_in: state.config.jwt_expiry_minutes * 60,
tenant: TenantSnippet {
id: token.tenant_id,
name: tenant_name,
},
};
Ok(Json(response))
} }
#[utoipa::path( #[utoipa::path(
@@ -289,38 +113,8 @@ pub async fn api_token_exchange(
pub async fn signup_start( pub async fn signup_start(
State(state): State<AppState>, State(state): State<AppState>,
Json(payload): Json<SignupStartRequest>, Json(payload): Json<SignupStartRequest>,
) -> AppResult<Json<SignupStartResponse>> { ) -> AppResult<JsonResponse<SignupStartResponse>> {
let username = payload.username.trim(); AuthService::new(&state).signup_start(payload)
if username.is_empty() {
return Err(AppError::bad_request("username must not be empty"));
}
let mut conn = state.db_unscoped()?;
let exists: bool = dsl::users
.filter(dsl::username.eq(username))
.first::<User>(&mut conn)
.optional()?
.is_some();
if exists {
return Err(AppError::conflict("username already exists"));
}
let user_id = Uuid::new_v4();
let challenge = state
.passkeys
.as_ref()
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?
.start_signup_registration(&mut conn, user_id, username)?;
let signup_token = state
.jwt
.generate_signup_token(user_id, challenge.challenge_id, username.to_owned())
.map_err(AppError::from)?;
Ok(Json(SignupStartResponse {
signup_token,
challenge,
}))
} }
#[utoipa::path( #[utoipa::path(
@@ -338,57 +132,7 @@ pub async fn signup_finish(
State(state): State<AppState>, State(state): State<AppState>,
Json(payload): Json<SignupFinishRequest>, Json(payload): Json<SignupFinishRequest>,
) -> AppResult<Response> { ) -> AppResult<Response> {
let claims = state AuthService::new(&state).signup_finish(payload)
.jwt
.verify_signup_token(&payload.signup_token)
.map_err(|_| AppError::unauthorized())?;
let mut conn = state.db_unscoped()?;
let exists: bool = dsl::users
.filter(dsl::username.eq(&claims.username))
.first::<User>(&mut conn)
.optional()?
.is_some();
if exists {
return Err(AppError::conflict("username already exists"));
}
let service = state
.passkeys
.as_ref()
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
let prepared_passkey =
service.consume_signup_challenge(&mut conn, claims.challenge_id, &payload.credential)?;
let state_clone = state.clone();
let response = conn.transaction::<Response, AppError, _>(|conn| {
insert_user(conn, claims.sub, &claims.username)?;
let tenant = state_clone.tenants.create_tenant_with_conn(
conn,
&claims.username,
None,
None,
TenantStatus::Creating,
&[claims.sub],
Some(claims.sub),
)?;
let passkey_insert =
prepared_passkey.into_new_user_passkey(claims.sub, payload.nickname.clone());
diesel::insert_into(passkey_dsl::user_passkeys)
.values(&passkey_insert)
.execute(conn)
.map_err(AppError::from)?;
let user: User = dsl::users.find(claims.sub).first(conn)?;
issue_session(&state_clone, conn, &user, tenant.id)
})?;
Ok(response)
} }
#[utoipa::path( #[utoipa::path(
@@ -409,53 +153,7 @@ pub async fn refresh(
.get(SESSION_COOKIE_NAME) .get(SESSION_COOKIE_NAME)
.ok_or_else(AppError::unauthorized)?; .ok_or_else(AppError::unauthorized)?;
let hashed = hash_session_token(refresh_value); AuthService::new(&state).refresh(refresh_value)
let mut conn = state.db_unscoped()?;
let now = Utc::now();
let now_naive = now.naive_utc();
apply_user_session_hash(&mut conn, &hashed)?;
let token = match session_dsl::user_sessions
.filter(session_dsl::token_hash.eq(&hashed))
.filter(session_dsl::revoked_at.is_null())
.filter(session_dsl::expires_at.gt(now_naive))
.first::<UserSession>(&mut conn)
{
Ok(token) => token,
Err(diesel::result::Error::NotFound) => return Err(AppError::unauthorized()),
Err(err) => return Err(AppError::from(err)),
};
clear_user_session_hash(&mut conn)?;
apply_tenant_guc(&mut conn, token.tenant_id)?;
clear_user_guc(&mut conn)?;
diesel::update(session_dsl::user_sessions.filter(session_dsl::id.eq(token.id)))
.set((
session_dsl::revoked_at.eq(now_naive),
session_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)
}
fn insert_user(conn: &mut PgConnection, id: Uuid, username: &str) -> AppResult<()> {
let new_user = NewUser {
id,
username: username.to_string(),
};
diesel::insert_into(dsl::users)
.values(&new_user)
.execute(conn)
.map(|_| ())
.map_err(AppError::from)
} }
#[utoipa::path( #[utoipa::path(
@@ -470,37 +168,7 @@ pub async fn select_tenant(
TypedHeader(Authorization(bearer)): TypedHeader<Authorization<Bearer>>, TypedHeader(Authorization(bearer)): TypedHeader<Authorization<Bearer>>,
Json(payload): Json<TenantSelectionRequest>, Json(payload): Json<TenantSelectionRequest>,
) -> AppResult<Response> { ) -> AppResult<Response> {
let user_id = match state.jwt.verify_tenant_selector_token(bearer.token()) { AuthService::new(&state).select_tenant(bearer.token(), payload.tenant_id)
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()?;
apply_user_guc(&mut conn, user_id)?;
let membership_exists = memberships_dsl::user_memberships
.filter(memberships_dsl::user_id.eq(user_id))
.filter(memberships_dsl::tenant_id.eq(payload.tenant_id))
.select(memberships_dsl::tenant_id)
.first::<Uuid>(&mut conn)
.optional()?;
clear_user_guc(&mut conn)?;
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( #[utoipa::path(
@@ -511,203 +179,73 @@ pub async fn select_tenant(
)] )]
pub async fn logout( pub async fn logout(
State(state): State<AppState>, State(state): State<AppState>,
user: AuthenticatedUser, TenantScopedConn { mut conn, user, .. }: TenantScopedConn,
jar: Option<TypedHeader<Cookie>>, jar: Option<TypedHeader<Cookie>>,
) -> AppResult<(HeaderMap, StatusCode)> { ) -> AppResult<(HeaderMap, StatusCode)> {
let mut conn = state.db_for_tenant(user.tenant_id)?; let refresh_cookie = jar.as_ref().and_then(|cookies| {
let now = Utc::now().naive_utc(); cookies
let mut rows_affected = 0; .get(SESSION_COOKIE_NAME)
.map(|value| value.to_owned())
if let Some(cookies) = jar { });
if let Some(value) = cookies.get(SESSION_COOKIE_NAME) { AuthService::new(&state).logout(&mut conn, &user, refresh_cookie.as_deref())
let hashed = hash_session_token(value);
rows_affected = diesel::update(
session_dsl::user_sessions
.filter(session_dsl::token_hash.eq(hashed))
.filter(session_dsl::user_id.eq(user.user_id))
.filter(session_dsl::revoked_at.is_null()),
)
.set((
session_dsl::revoked_at.eq(now),
session_dsl::updated_at.eq(now),
))
.execute(&mut conn)
.unwrap_or(0);
}
}
if rows_affected == 0 {
let _ = diesel::update(
session_dsl::user_sessions
.filter(session_dsl::user_id.eq(user.user_id))
.filter(session_dsl::revoked_at.is_null()),
)
.set((
session_dsl::revoked_at.eq(now),
session_dsl::updated_at.eq(now),
))
.execute(&mut conn);
}
let mut headers = HeaderMap::new();
headers.insert(SET_COOKIE, build_clear_session_cookie(&state));
Ok((headers, StatusCode::NO_CONTENT))
} }
#[utoipa::path( #[utoipa::path(
get, get,
path = "/api/auth/me", path = "/api/auth/me",
responses((status = 200, description = "Authenticated principal", body = AuthenticatedUser)), responses((status = 200, description = "Current session", body = crate::auth::AuthenticatedUser)),
tag = "Auth" tag = "Auth"
)] )]
pub async fn me(user: AuthenticatedUser) -> Json<AuthenticatedUser> { pub async fn me(user: AuthenticatedUser) -> Json<AuthenticatedUser> {
Json(user) Json(user)
} }
#[utoipa::path(
get,
path = "/api/auth/tenants",
responses((status = 200, description = "Available tenants", body = TenantListResponse)),
tag = "Auth"
)]
pub async fn list_tenants(
State(state): State<AppState>,
auth: Option<TypedHeader<Authorization<Bearer>>>,
) -> AppResult<Json<TenantListResponse>> {
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()?;
apply_user_guc(&mut conn, user_id)?;
let tenant_ids: Vec<Uuid> = memberships_dsl::user_memberships
.filter(memberships_dsl::user_id.eq(user_id))
.select(memberships_dsl::tenant_id)
.load(&mut conn)?;
clear_user_guc(&mut conn)?;
drop(conn);
let mut tenants = Vec::with_capacity(tenant_ids.len());
for tenant_id in tenant_ids {
let mut tenant_conn = state.db_for_tenant(tenant_id)?;
let name: String = tenant_dsl::tenants
.find(tenant_id)
.select(tenant_dsl::name)
.first(&mut tenant_conn)
.map_err(AppError::from)?;
tenants.push(TenantSnippet {
id: tenant_id,
name,
});
}
Ok(Json(TenantListResponse { tenants }))
}
#[utoipa::path( #[utoipa::path(
post, post,
path = "/api/auth/passkeys/register/start", path = "/api/auth/passkeys/register/start",
responses((status = 200, description = "Passkey registration challenge", body = RegistrationChallengeResponse)), responses((status = 200, body = crate::auth::passkeys::RegistrationChallengeResponse)),
tag = "Auth" tag = "Auth"
)] )]
pub async fn passkey_register_start( pub async fn passkey_register_start(
State(state): State<AppState>, State(state): State<AppState>,
user: AuthenticatedUser, user: AuthenticatedUser,
) -> AppResult<Json<RegistrationChallengeResponse>> { ) -> AppResult<JsonResponse<RegistrationChallengeResponse>> {
let service = state AuthService::new(&state).passkey_register_start(user)
.passkeys
.as_ref()
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
let mut conn = state.db_unscoped()?;
let current_user: User = dsl::users.find(user.user_id).first(&mut conn)?;
let challenge = service.start_registration(&mut conn, &current_user)?;
Ok(Json(challenge))
} }
#[utoipa::path( #[utoipa::path(
post, post,
path = "/api/auth/passkeys/register/finish", path = "/api/auth/passkeys/register/finish",
request_body = PasskeyRegistrationFinishPayload, request_body = crate::auth::passkeys::PasskeyRegistrationFinishPayload,
responses((status = 200, description = "Passkey registered", body = PasskeySummary)), responses((status = 201, body = crate::auth::passkeys::PasskeySummary)),
tag = "Auth" tag = "Auth"
)] )]
pub async fn passkey_register_finish( pub async fn passkey_register_finish(
State(state): State<AppState>, State(state): State<AppState>,
user: AuthenticatedUser, user: AuthenticatedUser,
Json(payload): Json<PasskeyRegistrationFinishPayload>, Json(payload): Json<PasskeyRegistrationFinishPayload>,
) -> AppResult<Json<PasskeySummary>> { ) -> AppResult<JsonResponse<PasskeySummary>> {
let service = state AuthService::new(&state).passkey_register_finish(user, payload)
.passkeys
.as_ref()
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
let mut conn = state.db_unscoped()?;
let current_user: User = dsl::users.find(user.user_id).first(&mut conn)?;
let PasskeyRegistrationFinishPayload {
challenge_id,
credential,
nickname,
} = payload;
let passkey = service.finish_registration(
&mut conn,
&current_user,
challenge_id,
credential,
nickname,
)?;
Ok(Json(PasskeySummary::from(passkey)))
} }
#[utoipa::path( #[utoipa::path(
post, post,
path = "/api/auth/passkeys/login/start", path = "/api/auth/passkeys/login/start",
request_body = PasskeyLoginStartPayload, request_body = crate::auth::passkeys::PasskeyLoginStartPayload,
responses((status = 200, description = "Passkey authentication challenge", body = AuthenticationChallengeResponse)), responses((status = 200, body = crate::auth::passkeys::AuthenticationChallengeResponse)),
tag = "Auth" tag = "Auth"
)] )]
pub async fn passkey_login_start( pub async fn passkey_login_start(
State(state): State<AppState>, State(state): State<AppState>,
Json(payload): Json<PasskeyLoginStartPayload>, Json(payload): Json<PasskeyLoginStartPayload>,
) -> AppResult<Json<AuthenticationChallengeResponse>> { ) -> AppResult<JsonResponse<AuthenticationChallengeResponse>> {
let service = state AuthService::new(&state).passkey_login_start(&payload.username)
.passkeys
.as_ref()
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
let username = payload.username.trim();
if username.is_empty() {
return Err(AppError::bad_request("username must not be empty"));
}
let mut conn = state.db_unscoped()?;
let user: User = dsl::users
.filter(dsl::username.eq(username))
.first(&mut conn)?;
let challenge = service.start_authentication(&mut conn, &user)?;
Ok(Json(challenge))
} }
#[utoipa::path( #[utoipa::path(
post, post,
path = "/api/auth/passkeys/login/finish", path = "/api/auth/passkeys/login/finish",
request_body = PasskeyLoginFinishPayload, request_body = crate::auth::passkeys::PasskeyLoginFinishPayload,
responses( responses(
(status = 200, description = "Passkey login successful", body = LoginResponseVariants), (status = 200, description = "Passkey login successful", body = LoginResponseVariants),
(status = 401, description = "Authentication failed") (status = 401, description = "Authentication failed")
@@ -718,244 +256,5 @@ pub async fn passkey_login_finish(
State(state): State<AppState>, State(state): State<AppState>,
Json(payload): Json<PasskeyLoginFinishPayload>, Json(payload): Json<PasskeyLoginFinishPayload>,
) -> AppResult<Response> { ) -> AppResult<Response> {
let service = state AuthService::new(&state).passkey_login_finish(payload)
.passkeys
.as_ref()
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
let mut conn = state.db_unscoped()?;
let (user, _passkey, auth_result) =
service.finish_authentication(&mut conn, payload.challenge_id, payload.credential)?;
if !auth_result.user_verified() {
return Err(AppError::unauthorized());
}
complete_login(&state, &mut conn, &user, None)
}
fn complete_login(
state: &AppState,
conn: &mut PgConnection,
user: &User,
preferred_tenant_id: Option<Uuid>,
) -> AppResult<Response> {
apply_user_guc(conn, user.id)?;
let tenant_ids: Vec<Uuid> = memberships_dsl::user_memberships
.filter(memberships_dsl::user_id.eq(user.id))
.select(memberships_dsl::tenant_id)
.load(conn)?;
clear_user_guc(conn)?;
tracing::debug!(user_id = %user.id, tenants = tenant_ids.len(), "passkey login memberships");
if tenant_ids.is_empty() {
return Err(AppError::unauthorized());
}
if let Some(preferred_id) = preferred_tenant_id {
if tenant_ids.iter().any(|id| *id == preferred_id) {
return issue_session(state, conn, user, preferred_id);
}
}
if tenant_ids.len() == 1 {
return issue_session(state, conn, user, tenant_ids[0]);
}
let selection_token = state
.jwt
.generate_tenant_selector_token(user.id)
.map_err(AppError::from)?;
let mut tenants = Vec::with_capacity(tenant_ids.len());
for tenant_id in tenant_ids {
let mut tenant_conn = state.db_for_tenant(tenant_id)?;
let name: String = tenant_dsl::tenants
.find(tenant_id)
.select(tenant_dsl::name)
.first(&mut tenant_conn)
.map_err(AppError::from)?;
tenants.push(TenantSnippet {
id: tenant_id,
name,
});
}
Ok(Json(TenantSelectionResponse {
access_token: selection_token,
tenants,
})
.into_response())
}
fn magic_token_login(
state: &AppState,
conn: &mut PgConnection,
token_value: &str,
username_hint: Option<&str>,
preferred_tenant_id: Option<Uuid>,
) -> AppResult<Response> {
if token_value.is_empty() {
return Err(AppError::bad_request("magic_token must not be empty"));
}
let token_hash = hash_magic_token(token_value);
let now = Utc::now();
let now_naive = now.naive_utc();
conn.transaction::<Response, AppError, _>(|conn| {
let magic = magic_dsl::magic_tokens
.filter(magic_dsl::token_hash.eq(&token_hash))
.filter(magic_dsl::expires_at.gt(now_naive))
.first::<MagicToken>(conn)
.map_err(|err| match err {
diesel::result::Error::NotFound => AppError::unauthorized(),
_ => AppError::from(err),
})?;
if let Some(limit) = magic.max_uses {
if magic.used_count >= limit {
return Err(AppError::unauthorized());
}
}
match magic.kind {
MagicTokenKind::EmailLogin | MagicTokenKind::DemoLogin => {}
}
let user: User = dsl::users
.find(magic.user_id)
.first(conn)
.map_err(AppError::from)?;
if let Some(expected) = username_hint {
if expected != user.username {
return Err(AppError::unauthorized());
}
}
diesel::update(magic_dsl::magic_tokens.filter(magic_dsl::id.eq(magic.id)))
.set((
magic_dsl::used_count.eq(magic.used_count + 1),
magic_dsl::last_used_at.eq(Some(now_naive)),
))
.execute(conn)?;
complete_login(state, conn, &user, preferred_tenant_id)
})
}
fn issue_session(
state: &AppState,
conn: &mut PgConnection,
user: &User,
tenant_id: Uuid,
) -> AppResult<Response> {
apply_tenant_guc(conn, tenant_id)?;
clear_user_guc(conn)?;
clear_user_session_hash(conn)?;
let now = Utc::now();
let access_token = state
.jwt
.generate_token(user.id, tenant_id, &user.username)
.map_err(AppError::from)?;
let tenant_name: String = tenant_dsl::tenants
.find(tenant_id)
.select(tenant_dsl::name)
.first(conn)
.map_err(AppError::from)?;
let session_value = generate_session_token();
let session_hash = hash_session_token(&session_value);
let refresh_expires_at = now + ChronoDuration::days(state.config.refresh_token_expiry_days);
let new_session = NewUserSession {
id: Uuid::new_v4(),
user_id: user.id,
token_hash: session_hash,
issued_at: now.naive_utc(),
expires_at: refresh_expires_at.naive_utc(),
tenant_id,
};
diesel::insert_into(user_sessions::table)
.values(&new_session)
.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,
name: tenant_name,
},
})
.into_response();
response.headers_mut().insert(
SET_COOKIE,
build_session_cookie(state, &session_value, refresh_expires_at),
);
Ok(response)
}
fn hash_session_token(token: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
hex::encode(hasher.finalize())
}
fn hash_magic_token(token: &str) -> String {
hash_session_token(token)
}
fn generate_session_token() -> String {
let mut bytes = [0u8; 32];
OsRng.fill_bytes(&mut bytes);
hex::encode(bytes)
}
fn build_session_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!("{}={}", SESSION_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 session cookie")
}
fn build_clear_session_cookie(state: &AppState) -> HeaderValue {
let mut parts = vec![format!("{}=", SESSION_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 session cookie")
} }
+134
View File
@@ -0,0 +1,134 @@
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;
+21 -26
View File
@@ -11,20 +11,15 @@ 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::{
db::{no_content, EnsureEntity, IntoJsonResponse},
named_entity::{ensure_name_available, normalize_name}, named_entity::{ensure_name_available, normalize_name},
time::to_iso, time::to_iso,
}, },
}; };
#[derive(Serialize, ToSchema)]
pub struct CorrespondentUsage {
pub total: i64,
}
#[derive(Serialize, ToSchema)] #[derive(Serialize, ToSchema)]
pub struct CorrespondentSummary { pub struct CorrespondentSummary {
pub id: Uuid, pub id: Uuid,
@@ -33,7 +28,7 @@ pub struct CorrespondentSummary {
pub metadata: Value, pub metadata: Value,
pub created_at: String, pub created_at: String,
pub updated_at: String, pub updated_at: String,
pub usage: CorrespondentUsage, pub usage_count: i64,
} }
#[derive(Deserialize, ToSchema)] #[derive(Deserialize, ToSchema)]
@@ -71,7 +66,7 @@ pub async fn list_correspondents(
tenant_id, tenant_id,
.. ..
}: TenantScopedConn, }: TenantScopedConn,
) -> AppResult<Json<Vec<CorrespondentSummary>>> { ) -> AppResult<JsonResponse<Vec<CorrespondentSummary>>> {
let correspondents_list: Vec<Correspondent> = correspondents::table let correspondents_list: Vec<Correspondent> = correspondents::table
.filter(correspondents::tenant_id.eq(tenant_id)) .filter(correspondents::tenant_id.eq(tenant_id))
.order(correspondents::name.asc()) .order(correspondents::name.asc())
@@ -94,7 +89,7 @@ pub async fn list_correspondents(
response.push(build_summary(correspondent, total)); response.push(build_summary(correspondent, total));
} }
response.into_json() ok_json(response)
} }
#[utoipa::path( #[utoipa::path(
@@ -111,7 +106,7 @@ pub async fn create_correspondent(
.. ..
}: TenantScopedConn, }: TenantScopedConn,
Json(payload): Json<CreateCorrespondentRequest>, Json(payload): Json<CreateCorrespondentRequest>,
) -> AppResult<Json<CorrespondentSummary>> { ) -> AppResult<JsonResponse<CorrespondentSummary>> {
let name = normalize_name(&payload.name, || { let name = normalize_name(&payload.name, || {
AppError::bad_request("name must not be empty") AppError::bad_request("name must not be empty")
})?; })?;
@@ -140,9 +135,9 @@ 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)
.one()?; .into_app_result()?;
build_summary(correspondent, 0).into_json() ok_json(build_summary(correspondent, 0))
} }
#[utoipa::path( #[utoipa::path(
@@ -161,12 +156,12 @@ pub async fn update_correspondent(
.. ..
}: TenantScopedConn, }: TenantScopedConn,
Json(payload): Json<UpdateCorrespondentRequest>, Json(payload): Json<UpdateCorrespondentRequest>,
) -> AppResult<Json<CorrespondentSummary>> { ) -> AppResult<JsonResponse<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)
.one()?; .into_app_result()?;
let mut new_name: Option<String> = None; let mut new_name: Option<String> = None;
if let Some(ref candidate) = payload.name { if let Some(ref candidate) = payload.name {
@@ -199,7 +194,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 build_summary(existing.clone(), usage).into_json(); return ok_json(build_summary(existing.clone(), usage));
} }
let mut changeset = CorrespondentChangeset::default(); let mut changeset = CorrespondentChangeset::default();
@@ -217,15 +212,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)
.one()?; .into_app_result()?;
let usage = load_usage_for_correspondent(&mut conn, tenant_id, correspondent_id)?; let usage = load_usage_for_correspondent(&mut conn, tenant_id, correspondent_id)?;
build_summary(updated, usage).into_json() ok_json(build_summary(updated, usage))
} }
#[utoipa::path( #[utoipa::path(
@@ -255,26 +252,25 @@ pub async fn delete_correspondent(
)); ));
} }
let deleted = diesel::delete( 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)
if deleted == 0 { .into_app_result()?
return Err(AppError::not_found()); .or_not_found()?;
}
no_content() no_content()
} }
fn build_summary(correspondent: Correspondent, total: i64) -> CorrespondentSummary { fn build_summary(correspondent: Correspondent, usage_count: 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: CorrespondentUsage { total }, usage_count,
} }
} }
@@ -309,7 +305,6 @@ fn load_usage_for_correspondent(
), ),
components(schemas( components(schemas(
crate::routes::correspondents::CorrespondentSummary, crate::routes::correspondents::CorrespondentSummary,
crate::routes::correspondents::CorrespondentUsage,
crate::routes::correspondents::CreateCorrespondentRequest, crate::routes::correspondents::CreateCorrespondentRequest,
crate::routes::correspondents::UpdateCorrespondentRequest crate::routes::correspondents::UpdateCorrespondentRequest
)) ))
File diff suppressed because it is too large Load Diff
+85 -471
View File
@@ -2,42 +2,28 @@ use axum::{
extract::{Json, Path, Query, State}, extract::{Json, Path, Query, State},
http::StatusCode, http::StatusCode,
}; };
use diesel::{dsl::exists, prelude::*, PgConnection}; use utoipa::OpenApi;
use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
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 super::documents::{hydrate_documents, DocumentResponse}; use crate::services::documents::DocumentResponse;
use crate::utils::{json::deserialize_patch_field, time::to_iso};
#[derive(Deserialize, ToSchema)] #[derive(utoipa::ToSchema, serde::Serialize)]
pub struct CreateFolderRequest {
pub name: String,
#[schema(nullable)]
pub parent_id: Option<Uuid>,
}
#[derive(Deserialize, ToSchema)]
pub struct EnsureFolderPathRequest {
#[schema(nullable)]
pub parent_id: Option<Uuid>,
pub segments: Vec<String>,
}
#[derive(Serialize, ToSchema)]
pub struct FolderResponse { pub struct FolderResponse {
pub folder: FolderInfo, pub folder: FolderInfo,
} }
#[derive(Serialize, ToSchema)] #[derive(utoipa::ToSchema, serde::Serialize)]
pub struct FolderContentsResponse { pub struct FolderContentsResponse {
#[schema(nullable)] #[schema(nullable)]
pub folder: Option<FolderInfo>, pub folder: Option<FolderInfo>,
@@ -45,63 +31,6 @@ pub struct FolderContentsResponse {
pub documents: Vec<DocumentResponse>, pub documents: Vec<DocumentResponse>,
} }
#[derive(Deserialize, IntoParams, ToSchema)]
#[into_params(parameter_in = Query)]
pub struct FolderContentsQuery {
#[serde(default = "default_include_documents")]
#[schema(default = true)]
pub include_documents: bool,
}
const fn default_include_documents() -> bool {
true
}
#[derive(Serialize, ToSchema)]
pub struct FolderInfo {
pub id: Uuid,
pub name: String,
#[schema(nullable)]
pub parent_id: Option<Uuid>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Default, Deserialize, ToSchema)]
pub struct UpdateFolderRequest {
#[serde(default, deserialize_with = "deserialize_patch_field")]
#[schema(nullable, value_type = Option<Uuid>)]
pub parent_id: Option<Option<Uuid>>,
#[serde(default, deserialize_with = "deserialize_patch_field")]
#[schema(nullable)]
pub name: Option<Option<String>>,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn update_folder_request_deserializes_null_parent() {
let req: UpdateFolderRequest =
serde_json::from_value(json!({ "parent_id": null })).unwrap();
assert!(matches!(req.parent_id, Some(None)));
}
#[test]
fn update_folder_request_deserializes_absent_parent() {
let req: UpdateFolderRequest = serde_json::from_value(json!({})).unwrap();
assert!(req.parent_id.is_none());
}
#[test]
fn update_folder_request_deserializes_null_name() {
let req: UpdateFolderRequest = serde_json::from_value(json!({ "name": null })).unwrap();
assert!(matches!(req.name, Some(None)));
}
}
#[utoipa::path( #[utoipa::path(
get, get,
path = "/api/folders/{id}", path = "/api/folders/{id}",
@@ -110,21 +39,17 @@ mod tests {
tag = "Folders" tag = "Folders"
)] )]
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<Json<FolderResponse>> { ) -> AppResult<JsonResponse<FolderResponse>> {
let folder: Folder = folders::table let service = FolderService::new(&state);
.find(folder_id) let folder = service.get_folder(&mut conn, tenant_id, folder_id)?;
.filter(folders::tenant_id.eq(tenant_id)) ok_json(FolderResponse { folder })
.first(&mut conn)?;
Ok(Json(FolderResponse {
folder: folder_to_info(folder),
}))
} }
#[utoipa::path( #[utoipa::path(
@@ -135,89 +60,17 @@ pub async fn get_folder(
tag = "Folders" 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<Json<FolderResponse>> { ) -> AppResult<JsonResponse<FolderResponse>> {
if payload.segments.is_empty() { let service = FolderService::new(&state);
return Err(AppError::bad_request("segments must not be empty")); let folder = service.ensure_folder_path(&mut conn, tenant_id, payload)?;
} 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"))
})?;
Ok(Json(FolderResponse {
folder: folder_to_info(target_folder),
}))
} }
#[utoipa::path( #[utoipa::path(
@@ -231,96 +84,28 @@ pub async fn ensure_folder_path(
tag = "Folders" 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<(StatusCode, Json<FolderResponse>)> { ) -> AppResult<JsonResponse<FolderResponse>> {
if payload.name.trim().is_empty() { let service = FolderService::new(&state);
return Err(AppError::bad_request("name must not be empty")); let (folder, created) = service.create_folder(&mut conn, tenant_id, payload)?;
} let response = FolderResponse { folder };
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, created): (Folder, bool) = if let Some(folder) = existing {
(folder, false)
} 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)?,
true,
)
} 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)?,
false,
)
} else {
(
folders::table
.filter(folders::tenant_id.eq(tenant_id))
.filter(folders::parent_id.is_null())
.filter(folders::name.eq(name))
.first(&mut conn)?,
false,
)
}
};
let response = Json(FolderResponse {
folder: folder_to_info(folder),
});
if created { if created {
Ok((StatusCode::CREATED, response)) created_json(response)
} else { } else {
Ok((StatusCode::OK, response)) ok_json(response)
} }
} }
#[utoipa::path( #[utoipa::path(
get, get,
path = "/api/folders/{id}/contents", path = "/api/folders/{id}/contents",
params(("id" = Uuid, Path, description = "Folder ID"), FolderContentsQuery), params(("id" = String, Path, description = "Folder ID or 'root'"), FolderContentsQuery),
responses((status = 200, description = "Folder contents", body = FolderContentsResponse)), responses((status = 200, description = "Folder contents", body = FolderContentsResponse)),
tag = "Folders" tag = "Folders"
)] )]
@@ -334,7 +119,13 @@ pub async fn list_folder_contents(
user_id, user_id,
.. ..
}: TenantScopedConn, }: TenantScopedConn,
) -> AppResult<Json<FolderContentsResponse>> { ) -> AppResult<JsonResponse<FolderContentsResponse>> {
let FolderContentsQuery {
include_documents,
sort,
dir,
} = query;
let folder_id = if folder_identifier.eq_ignore_ascii_case("root") { let folder_id = if folder_identifier.eq_ignore_ascii_case("root") {
None None
} else { } else {
@@ -344,57 +135,50 @@ pub async fn list_folder_contents(
) )
}; };
let folder = match folder_id { let service = FolderService::new(&state);
Some(id) => Some(folder_to_info( let FolderContentsData {
folders::table folder,
.find(id) subfolders,
.filter(folders::tenant_id.eq(tenant_id)) documents,
.first::<Folder>(&mut conn)?, } = service.list_folder_contents(
)), &mut conn,
None => None, tenant_id,
}; folder_id,
sort,
dir,
include_documents,
)?;
let child_folders: Vec<Folder> = if let Some(parent_id) = folder_id { let documents = if include_documents {
folders::table service.hydrate_documents(&mut conn, tenant_id, user_id, documents)?
.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::created_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)?
};
hydrate_documents(&state, &mut conn, tenant_id, user_id, docs)?
} 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( #[utoipa::path(
@@ -405,6 +189,7 @@ pub async fn list_folder_contents(
tag = "Folders" 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,
@@ -412,50 +197,8 @@ pub async fn delete_folder(
.. ..
}: TenantScopedConn, }: TenantScopedConn,
) -> AppResult<StatusCode> { ) -> AppResult<StatusCode> {
conn.transaction::<_, AppError, _>(|conn| { FolderService::new(&state).delete_folder(&mut conn, tenant_id, folder_id)?;
folders::table no_content()
.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( #[utoipa::path(
@@ -467,6 +210,7 @@ pub async fn delete_folder(
tag = "Folders" 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,
@@ -475,160 +219,30 @@ pub async fn update_folder(
}: TenantScopedConn, }: TenantScopedConn,
Json(payload): Json<UpdateFolderRequest>, Json(payload): Json<UpdateFolderRequest>,
) -> AppResult<StatusCode> { ) -> AppResult<StatusCode> {
conn.transaction::<(), AppError, _>(|conn| { FolderService::new(&state).update_folder(&mut conn, tenant_id, folder_id, payload)?;
let folder: Folder = folders::table no_content()
.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 payload.parent_id {
None => {}
Some(None) => {
if folder.parent_id.is_some() {
parent_changed = true;
}
next_parent = None;
}
Some(Some(parent_id)) => {
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 payload.name {
None => {}
Some(None) => {
return Err(AppError::bad_request("name cannot be null"));
}
Some(Some(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)
} }
fn folder_to_info(folder: Folder) -> FolderInfo { #[derive(OpenApi)]
FolderInfo {
id: folder.id,
name: folder.name,
parent_id: folder.parent_id,
created_at: to_iso(folder.created_at),
updated_at: to_iso(folder.updated_at),
}
}
pub(super) fn gather_descendant_folder_ids(
conn: &mut PgConnection,
tenant_id: Uuid,
folder_id: Uuid,
) -> AppResult<Vec<Uuid>> {
let mut ids = vec![folder_id];
let mut queue = vec![folder_id];
while let Some(current) = queue.pop() {
let child_ids: Vec<Uuid> = folders::table
.filter(folders::parent_id.eq(Some(current)))
.filter(folders::tenant_id.eq(tenant_id))
.select(folders::id)
.load(conn)?;
queue.extend(child_ids.iter().copied());
ids.extend(child_ids);
}
Ok(ids)
}
#[derive(utoipa::OpenApi)]
#[openapi( #[openapi(
paths( paths(
crate::routes::folders::create_folder, crate::routes::folders::create_folder,
crate::routes::folders::ensure_folder_path, crate::routes::folders::ensure_folder_path,
crate::routes::folders::get_folder, crate::routes::folders::get_folder,
crate::routes::folders::list_folder_contents, crate::routes::folders::list_folder_contents,
crate::routes::folders::list_folder_tree,
crate::routes::folders::delete_folder, crate::routes::folders::delete_folder,
crate::routes::folders::update_folder crate::routes::folders::update_folder
), ),
components(schemas( components(schemas(
crate::routes::folders::CreateFolderRequest, crate::services::folders::CreateFolderRequest,
crate::routes::folders::EnsureFolderPathRequest, crate::services::folders::EnsureFolderPathRequest,
crate::routes::folders::FolderResponse, crate::routes::folders::FolderResponse,
crate::routes::folders::FolderInfo, crate::services::folders::FolderInfo,
crate::routes::folders::FolderContentsQuery, crate::services::folders::FolderContentsQuery,
crate::routes::folders::FolderContentsResponse, crate::routes::folders::FolderContentsResponse,
crate::routes::folders::UpdateFolderRequest crate::services::folders::FolderTreeNode,
crate::services::folders::UpdateFolderRequest
)) ))
)] )]
pub struct FoldersApiDoc; pub struct FoldersApiDoc;
+307 -50
View File
@@ -13,15 +13,22 @@ use tower_http::{
}; };
use utoipa::OpenApi; use utoipa::OpenApi;
use crate::{auth::AuthenticatedUser, openapi::ApiDoc, state::AppState}; use crate::{
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 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,7 +68,6 @@ pub fn create_router(state: AppState) -> Router<()> {
.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("/tenants", get(auth::list_tenants))
.route( .route(
"/passkeys/register/start", "/passkeys/register/start",
post(auth::passkey_register_start), post(auth::passkey_register_start),
@@ -75,93 +81,341 @@ pub fn create_router(state: AppState) -> Router<()> {
.route("/me", get(auth::me)); .route("/me", get(auth::me));
let documents_routes = Router::new() let documents_routes = Router::new()
.route("/check", get(documents::check_document)) .route(
"/check",
get(documents::check_document).layer(RequireCapabilitiesLayer::all([
ApiCapability::DocumentsRead,
])),
)
.route( .route(
"/", "/",
get(documents::list_documents).post(documents::upload_document), get(documents::list_documents).layer(RequireCapabilitiesLayer::all([
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), post(documents::bulk_assign_correspondents).layer(RequireCapabilitiesLayer::all([
ApiCapability::DocumentsEdit,
])),
) )
.route( .route(
"/bulk/reanalyze", "/bulk/reanalyze",
post(documents::reanalyze_selected_documents), post(documents::reanalyze_selected_documents).layer(RequireCapabilitiesLayer::all([
ApiCapability::DocumentsWrite,
])),
) )
.route( .route(
"/:id", "/{id}",
get(documents::get_document) get(documents::get_document).layer(RequireCapabilitiesLayer::all([
.delete(documents::delete_document) ApiCapability::DocumentsRead,
.patch(documents::update_document), ])),
) )
.route( .route(
"/:id/assets", "/{id}/download",
get(documents::list_document_assets).post(documents::request_document_assets), post(documents::refresh_document_download).layer(RequireCapabilitiesLayer::all([
) 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/correspondents/:correspondent_id", "/{id}/trash",
delete(documents::remove_correspondent), post(documents::trash_document).layer(RequireCapabilitiesLayer::all([
ApiCapability::DocumentsWrite,
])),
)
.route(
"/{id}",
delete(documents::delete_document).layer(RequireCapabilitiesLayer::all([
ApiCapability::DocumentsWrite,
])),
)
.route(
"/{id}",
patch(documents::update_document).layer(RequireCapabilitiesLayer::all([
ApiCapability::DocumentsEdit,
])),
)
.route(
"/{id}/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("/download/:token", get(documents::download_with_token)); Router::new().route("/api/download/{token}", get(documents::download_with_token));
let folders_routes = Router::new() let folders_routes = Router::new()
.route("/", post(folders::create_folder)) .route(
.route("/path", post(folders::ensure_folder_path)) "/",
.route("/:id", get(folders::get_folder)) post(folders::create_folder)
.route("/:id", delete(folders::delete_folder)) .layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersWrite])),
.route("/:id", patch(folders::update_folder)) )
.route("/:id/contents", get(folders::list_folder_contents)); .route(
"/path",
post(folders::ensure_folder_path)
.layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersWrite])),
)
.route(
"/tree",
get(folders::list_folder_tree)
.layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersRead])),
)
.route(
"/{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("/", get(tags::list_tags).post(tags::create_tag)) .route(
.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).post(correspondents::create_correspondent), get(correspondents::list_correspondents).layer(RequireCapabilitiesLayer::all([
ApiCapability::CorrespondentsRead,
])),
) )
.route( .route(
"/:id", "/",
patch(correspondents::update_correspondent) post(correspondents::create_correspondent).layer(RequireCapabilitiesLayer::all([
.delete(correspondents::delete_correspondent), ApiCapability::CorrespondentsWrite,
])),
)
.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() let profile_routes = Router::new()
.route( .route(
"/api-tokens", "/api-tokens",
get(profile::list_api_tokens).post(profile::create_api_token), get(profile::list_api_tokens)
.layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileRead])),
) )
.route( .route(
"/api-tokens/:id/regenerate", "/api-tokens",
post(profile::regenerate_api_token), post(profile::create_api_token)
.layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileWrite])),
) )
.route( .route(
"/api-tokens/:id", "/api-tokens/{id}/regenerate",
patch(profile::update_api_token).delete(profile::delete_api_token), post(profile::regenerate_api_token)
.layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileWrite])),
) )
.route("/passkeys", get(profile::list_passkeys)) .route(
.route("/passkeys/:id", delete(profile::delete_passkey)); "/api-tokens/{id}",
delete(profile::delete_api_token)
.layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileWrite])),
)
.route(
"/passkeys",
get(profile::list_passkeys)
.layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileRead])),
)
.route(
"/passkeys/{id}",
delete(profile::delete_passkey)
.layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileWrite])),
);
let capability_sets_routes = Router::new()
.route(
"/",
get(capability_sets::list_capability_sets).layer(RequireCapabilitiesLayer::all([
ApiCapability::CapabilitySetsRead,
])),
)
.route(
"/",
post(capability_sets::create_capability_set).layer(RequireCapabilitiesLayer::all([
ApiCapability::CapabilitySetsWrite,
])),
)
.route(
"/{id}",
get(capability_sets::get_capability_set).layer(RequireCapabilitiesLayer::all([
ApiCapability::CapabilitySetsRead,
])),
)
.route(
"/{id}",
patch(capability_sets::update_capability_set).layer(RequireCapabilitiesLayer::all([
ApiCapability::CapabilitySetsWrite,
])),
)
.route(
"/{id}",
delete(capability_sets::delete_capability_set).layer(RequireCapabilitiesLayer::all([
ApiCapability::CapabilitySetsWrite,
])),
);
let capabilities_routes = Router::new().route(
"/",
get(capability_sets::list_capabilities).layer(RequireCapabilitiesLayer::all([
ApiCapability::CapabilitySetsRead,
])),
);
let protected_state = state.clone(); let protected_state = state.clone();
let assets_routes = Router::new().route("/:asset_id", get(documents::get_document_asset)); let assets_routes = Router::new()
.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)
@@ -169,7 +423,10 @@ pub fn create_router(state: AppState) -> Router<()> {
.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/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 upload_limit = state.config.upload_body_limit_bytes;
+29 -188
View File
@@ -3,69 +3,19 @@ use axum::{
http::StatusCode, http::StatusCode,
Json, Json,
}; };
use chrono::{DateTime, NaiveDateTime}; use utoipa::OpenApi;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid; use uuid::Uuid;
use crate::auth::{ use crate::{
api_tokens::{ auth::{passkeys::PasskeySummary, TenantScopedConn},
create_api_token as issue_token, list_api_tokens as load_tokens, error::AppResult,
regenerate_api_token as rotate_token, revoke_api_token as revoke_token, http::responders::JsonResponse,
update_api_token_capabilities as update_capabilities, services::profile::{
ApiTokenCreatedResponse, ApiTokenResponse, CreateApiTokenRequest, ProfileService,
RevokePasskeyQuery,
}, },
passkeys::PasskeySummary, state::AppState,
TenantScopedConn,
}; };
use crate::error::{AppError, AppResult};
use crate::models::{ApiToken, ApiTokenCapability};
use crate::state::AppState;
use crate::utils::{db::no_content, time::to_iso};
#[derive(Debug, Serialize, ToSchema)]
pub struct ApiTokenResponse {
pub id: Uuid,
pub tenant_id: Uuid,
#[schema(nullable)]
pub label: Option<String>,
pub capabilities: Vec<ApiTokenCapability>,
pub created_at: String,
#[schema(nullable)]
pub last_used_at: Option<String>,
#[schema(nullable)]
pub expires_at: Option<String>,
#[schema(nullable)]
pub revoked_at: Option<String>,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct ApiTokenCreatedResponse {
pub token: String,
pub token_info: ApiTokenResponse,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct CreateApiTokenRequest {
#[schema(nullable)]
pub label: Option<String>,
#[schema(nullable)]
pub expires_at: Option<String>,
#[serde(default)]
#[schema(nullable)]
pub capabilities: Option<Vec<ApiTokenCapability>>,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct UpdateApiTokenCapabilitiesRequest {
pub capabilities: Vec<ApiTokenCapability>,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct RevokePasskeyQuery {
#[serde(default)]
#[schema(nullable)]
pub reason: Option<String>,
}
#[utoipa::path( #[utoipa::path(
get, get,
@@ -78,14 +28,8 @@ pub async fn list_passkeys(
TenantScopedConn { TenantScopedConn {
mut conn, user_id, .. mut conn, user_id, ..
}: TenantScopedConn, }: TenantScopedConn,
) -> AppResult<Json<Vec<PasskeySummary>>> { ) -> AppResult<JsonResponse<Vec<PasskeySummary>>> {
let service = state ProfileService::new(&state).list_passkeys(&mut conn, user_id)
.passkeys
.as_ref()
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
let passkeys = service.list_for_user(&mut conn, user_id)?;
Ok(Json(passkeys))
} }
#[utoipa::path( #[utoipa::path(
@@ -95,16 +39,15 @@ pub async fn list_passkeys(
tag = "Profile" tag = "Profile"
)] )]
pub async fn list_api_tokens( pub async fn list_api_tokens(
State(state): State<AppState>,
TenantScopedConn { TenantScopedConn {
mut conn, mut conn,
tenant_id, tenant_id,
user_id, user_id,
.. ..
}: TenantScopedConn, }: TenantScopedConn,
) -> AppResult<Json<Vec<ApiTokenResponse>>> { ) -> AppResult<JsonResponse<Vec<ApiTokenResponse>>> {
let tokens = load_tokens(&mut conn, user_id, Some(tenant_id))?; ProfileService::new(&state).list_api_tokens(&mut conn, tenant_id, user_id)
let responses = tokens.into_iter().map(api_token_to_response).collect();
Ok(Json(responses))
} }
#[utoipa::path( #[utoipa::path(
@@ -115,6 +58,7 @@ pub async fn list_api_tokens(
tag = "Profile" tag = "Profile"
)] )]
pub async fn create_api_token( pub async fn create_api_token(
State(state): State<AppState>,
TenantScopedConn { TenantScopedConn {
mut conn, mut conn,
tenant_id, tenant_id,
@@ -122,31 +66,8 @@ pub async fn create_api_token(
.. ..
}: TenantScopedConn, }: TenantScopedConn,
Json(payload): Json<CreateApiTokenRequest>, Json(payload): Json<CreateApiTokenRequest>,
) -> AppResult<(StatusCode, Json<ApiTokenCreatedResponse>)> { ) -> AppResult<JsonResponse<ApiTokenCreatedResponse>> {
let expires_at = match payload.expires_at { ProfileService::new(&state).create_api_token(&mut conn, tenant_id, user_id, payload)
Some(ref value) => Some(parse_timestamp(value)?),
None => None,
};
let capabilities = payload
.capabilities
.unwrap_or_else(|| vec![ApiTokenCapability::Webdav]);
let issued = issue_token(
&mut conn,
user_id,
tenant_id,
payload.label.clone(),
expires_at,
capabilities,
)?;
let response = ApiTokenCreatedResponse {
token: issued.token,
token_info: api_token_to_response(issued.record),
};
Ok((StatusCode::CREATED, Json(response)))
} }
#[utoipa::path( #[utoipa::path(
@@ -157,6 +78,7 @@ pub async fn create_api_token(
tag = "Profile" tag = "Profile"
)] )]
pub async fn regenerate_api_token( pub async fn regenerate_api_token(
State(state): State<AppState>,
TenantScopedConn { TenantScopedConn {
mut conn, mut conn,
tenant_id, tenant_id,
@@ -164,43 +86,8 @@ pub async fn regenerate_api_token(
.. ..
}: TenantScopedConn, }: TenantScopedConn,
Path(token_id): Path<Uuid>, Path(token_id): Path<Uuid>,
) -> AppResult<Json<ApiTokenCreatedResponse>> { ) -> AppResult<JsonResponse<ApiTokenCreatedResponse>> {
let issued = rotate_token(&mut conn, token_id, user_id, Some(tenant_id))?; ProfileService::new(&state).regenerate_api_token(&mut conn, tenant_id, user_id, token_id)
let response = ApiTokenCreatedResponse {
token: issued.token,
token_info: api_token_to_response(issued.record),
};
Ok(Json(response))
}
#[utoipa::path(
patch,
path = "/api/profile/api-tokens/{id}",
params(("id" = Uuid, Path, description = "API token ID")),
request_body = UpdateApiTokenCapabilitiesRequest,
responses((status = 200, description = "API token updated", body = ApiTokenResponse)),
tag = "Profile"
)]
pub async fn update_api_token(
TenantScopedConn {
mut conn,
tenant_id,
user_id,
..
}: TenantScopedConn,
Path(token_id): Path<Uuid>,
Json(payload): Json<UpdateApiTokenCapabilitiesRequest>,
) -> AppResult<Json<ApiTokenResponse>> {
let updated = update_capabilities(
&mut conn,
token_id,
user_id,
Some(tenant_id),
payload.capabilities,
)?;
Ok(Json(api_token_to_response(updated)))
} }
#[utoipa::path( #[utoipa::path(
@@ -211,13 +98,13 @@ pub async fn update_api_token(
tag = "Profile" tag = "Profile"
)] )]
pub async fn delete_api_token( pub async fn delete_api_token(
State(state): State<AppState>,
TenantScopedConn { TenantScopedConn {
mut conn, user_id, .. mut conn, user_id, ..
}: TenantScopedConn, }: TenantScopedConn,
Path(token_id): Path<Uuid>, Path(token_id): Path<Uuid>,
) -> AppResult<StatusCode> { ) -> AppResult<StatusCode> {
revoke_token(&mut conn, token_id, user_id)?; ProfileService::new(&state).delete_api_token(&mut conn, user_id, token_id)
no_content()
} }
#[utoipa::path( #[utoipa::path(
@@ -238,71 +125,25 @@ pub async fn delete_passkey(
Path(passkey_id): Path<Uuid>, Path(passkey_id): Path<Uuid>,
Query(query): Query<RevokePasskeyQuery>, Query(query): Query<RevokePasskeyQuery>,
) -> AppResult<StatusCode> { ) -> AppResult<StatusCode> {
let service = state ProfileService::new(&state).delete_passkey(&mut conn, user_id, passkey_id, query.reason)
.passkeys
.as_ref()
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
let active_count = service.active_passkey_count(&mut conn, user_id)?;
if active_count <= 1 {
return Err(AppError::bad_request(
"cannot revoke the last remaining passkey",
));
}
service.revoke_passkey(&mut conn, user_id, passkey_id, query.reason)?;
no_content()
} }
fn api_token_to_response(token: ApiToken) -> ApiTokenResponse { #[derive(OpenApi)]
let ApiToken {
id,
tenant_id,
label,
created_at,
last_used_at,
expires_at,
revoked_at,
capabilities,
..
} = token;
ApiTokenResponse {
id,
tenant_id,
label,
capabilities,
created_at: to_iso(created_at),
last_used_at: last_used_at.map(to_iso),
expires_at: expires_at.map(to_iso),
revoked_at: revoked_at.map(to_iso),
}
}
fn parse_timestamp(value: &str) -> AppResult<NaiveDateTime> {
let dt = DateTime::parse_from_rfc3339(value)
.map_err(|_| AppError::bad_request("invalid expires_at timestamp"))?;
Ok(dt.naive_utc())
}
#[derive(utoipa::OpenApi)]
#[openapi( #[openapi(
paths( paths(
crate::routes::profile::list_api_tokens, crate::routes::profile::list_api_tokens,
crate::routes::profile::create_api_token, crate::routes::profile::create_api_token,
crate::routes::profile::regenerate_api_token, crate::routes::profile::regenerate_api_token,
crate::routes::profile::update_api_token,
crate::routes::profile::delete_api_token, crate::routes::profile::delete_api_token,
crate::routes::profile::list_passkeys, crate::routes::profile::list_passkeys,
crate::routes::profile::delete_passkey crate::routes::profile::delete_passkey
), ),
components(schemas( components(schemas(
crate::models::ApiTokenCapability, crate::models::ApiCapability,
crate::routes::profile::ApiTokenResponse, crate::services::profile::ApiTokenResponse,
crate::routes::profile::ApiTokenCreatedResponse, crate::services::profile::ApiTokenCreatedResponse,
crate::routes::profile::CreateApiTokenRequest, crate::services::profile::CreateApiTokenRequest,
crate::routes::profile::UpdateApiTokenCapabilitiesRequest, crate::services::profile::RevokePasskeyQuery,
crate::routes::profile::RevokePasskeyQuery,
crate::auth::passkeys::PasskeySummary crate::auth::passkeys::PasskeySummary
)) ))
)] )]
+32 -32
View File
@@ -5,14 +5,16 @@ use std::collections::HashMap;
use utoipa::ToSchema; use utoipa::ToSchema;
use uuid::Uuid; use uuid::Uuid;
use crate::auth::TenantScopedConn; use crate::{
use crate::error::{AppError, AppResult}; auth::TenantScopedConn,
use crate::models::{NewTag, Tag}; error::{AppError, AppResult},
use crate::schema::{document_tags, tags}; http::responders::{no_content, ok_json, IntoAppResult, JsonResponse, RowsAffectedExt},
use crate::utils::{ models::{NewTag, Tag},
db::{no_content, EnsureEntity, IntoJsonResponse}, schema::{document_tags, tags},
json::deserialize_patch_field, utils::{
named_entity::{ensure_name_available, normalize_name}, json::deserialize_patch_field,
named_entity::{ensure_name_available, normalize_name},
},
}; };
#[derive(Deserialize, ToSchema)] #[derive(Deserialize, ToSchema)]
@@ -84,7 +86,7 @@ pub async fn list_tags(
tenant_id, tenant_id,
.. ..
}: TenantScopedConn, }: TenantScopedConn,
) -> AppResult<Json<Vec<TagCatalogEntry>>> { ) -> AppResult<JsonResponse<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())
@@ -108,7 +110,7 @@ pub async fn list_tags(
}) })
.collect(); .collect();
response.into_json() ok_json(response)
} }
#[utoipa::path( #[utoipa::path(
@@ -125,7 +127,7 @@ pub async fn create_tag(
.. ..
}: TenantScopedConn, }: TenantScopedConn,
Json(payload): Json<CreateTagRequest>, Json(payload): Json<CreateTagRequest>,
) -> AppResult<Json<TagCatalogEntry>> { ) -> AppResult<JsonResponse<TagCatalogEntry>> {
let label = normalize_name(&payload.label, || { let label = normalize_name(&payload.label, || {
AppError::bad_request("label must not be empty") AppError::bad_request("label must not be empty")
})?; })?;
@@ -155,15 +157,14 @@ 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)
.one()?; .into_app_result()?;
TagCatalogEntry { ok_json(TagCatalogEntry {
id: tag.id, id: tag.id,
label: tag.label, label: tag.label,
color: tag.color, color: tag.color,
usage_count: 0, usage_count: 0,
} })
.into_json()
} }
#[utoipa::path( #[utoipa::path(
@@ -182,12 +183,12 @@ pub async fn update_tag(
.. ..
}: TenantScopedConn, }: TenantScopedConn,
Json(payload): Json<UpdateTagRequest>, Json(payload): Json<UpdateTagRequest>,
) -> AppResult<Json<TagCatalogEntry>> { ) -> AppResult<JsonResponse<TagCatalogEntry>> {
let existing: Tag = tags::table let existing: Tag = tags::table
.find(tag_id) .find(tag_id)
.filter(tags::tenant_id.eq(tenant_id)) .filter(tags::tenant_id.eq(tenant_id))
.first(&mut conn) .first(&mut conn)
.one()?; .into_app_result()?;
let UpdateTagRequest { label, color } = payload; let UpdateTagRequest { label, color } = payload;
if label.is_none() && color.is_none() { if label.is_none() && color.is_none() {
@@ -195,13 +196,12 @@ pub async fn update_tag(
.filter(document_tags::tag_id.eq(tag_id)) .filter(document_tags::tag_id.eq(tag_id))
.select(count_star()) .select(count_star())
.first(&mut conn)?; .first(&mut conn)?;
return TagCatalogEntry { return ok_json(TagCatalogEntry {
id: existing.id, id: existing.id,
label: existing.label.clone(), label: existing.label.clone(),
color: existing.color.clone(), color: existing.color.clone(),
usage_count, usage_count,
} });
.into_json();
} }
let mut new_label: Option<String> = None; let mut new_label: Option<String> = None;
@@ -258,12 +258,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,26 +279,27 @@ 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)
.one()?; .into_app_result()?;
let usage_count: i64 = document_tags::table let usage_count: i64 = document_tags::table
.filter(document_tags::tag_id.eq(tag_id)) .filter(document_tags::tag_id.eq(tag_id))
.filter(document_tags::tenant_id.eq(tenant_id)) .filter(document_tags::tenant_id.eq(tenant_id))
.select(count_star()) .select(count_star())
.first(&mut conn)?; .first(&mut conn)?;
TagCatalogEntry { ok_json(TagCatalogEntry {
id: updated.id, id: updated.id,
label: updated.label, label: updated.label,
color: updated.color, color: updated.color,
usage_count, usage_count,
} })
.into_json()
} }
#[utoipa::path( #[utoipa::path(
@@ -328,15 +329,14 @@ pub async fn delete_tag(
)); ));
} }
let deleted = diesel::delete( 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)
if deleted == 0 { .into_app_result()?
return Err(AppError::not_found()); .or_not_found()?;
}
no_content() no_content()
} }
+154
View File
@@ -0,0 +1,154 @@
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;
+38 -31
View File
@@ -16,25 +16,28 @@ 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::api_tokens::{find_active_token_by_secret, touch_api_token}; use crate::auth::{
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::{ApiTokenCapability, Document, DocumentVersion, Folder, User}; use crate::models::{ApiCapability, 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, user_memberships::dsl as memberships_dsl, users::dsl as users_dsl,
}; };
use crate::state::AppState; use crate::state::{AppState, PgPooledConnection};
use crate::tenants::{apply_tenant_guc, apply_user_guc, clear_user_guc}; use crate::tenants::{apply_tenant_guc, apply_user_guc, clear_user_guc};
use crate::utils::{error::StorageResultExt, http::inline_content_disposition, time::to_http_date}; use crate::utils::{error::StorageResultExt, http::inline_content_disposition, time::to_http_date};
const REALM: &str = "Papercrate WebDAV"; const REALM: &str = "Papercrate WebDAV";
const DOWNLOAD_URL_TTL_SECONDS: u64 = 300; const DOWNLOAD_URL_TTL_SECONDS: u64 = 300;
#[derive(Clone, Debug)]
struct WebDavContext { struct WebDavContext {
tenant_id: Uuid, tenant_id: Uuid,
_user_id: Uuid, _user_id: Uuid,
_username: String, _username: String,
conn: PgPooledConnection,
} }
pub fn create_router() -> Router<AppState> { pub fn create_router() -> Router<AppState> {
@@ -72,7 +75,7 @@ async fn handle_propfind(
path: &str, path: &str,
headers: HeaderMap, headers: HeaderMap,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
let context = match authenticate(state, &headers)? { let mut context = match authenticate(state, &headers)? {
Some(user) => user, Some(user) => user,
None => return Ok(unauthorized_response()), None => return Ok(unauthorized_response()),
}; };
@@ -87,17 +90,18 @@ async fn handle_propfind(
let tenant_id = context.tenant_id; let tenant_id = context.tenant_id;
let resources = if segments.is_empty() { let resources = if segments.is_empty() {
let contents = fetch_folder_contents(state, tenant_id, None)?; let contents = fetch_folder_contents(&mut context.conn, tenant_id, None)?;
build_resources_for_folder(None, &[], &contents, depth) build_resources_for_folder(None, &[], &contents, depth)
} else { } else {
let resolution = match resolve_path(state, tenant_id, &segments)? { let resolution = match resolve_path(&mut context.conn, tenant_id, &segments)? {
Some(resolved) => resolved, Some(resolved) => resolved,
None => return Ok(not_found_response()), None => return Ok(not_found_response()),
}; };
match resolution { match resolution {
ResolvedPath::Folder { folder, chain } => { ResolvedPath::Folder { folder, chain } => {
let contents = fetch_folder_contents(state, tenant_id, Some(folder.id))?; let contents =
fetch_folder_contents(&mut context.conn, 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 {
@@ -128,7 +132,7 @@ async fn handle_get_or_head(
headers: HeaderMap, headers: HeaderMap,
method: Method, method: Method,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
let context = match authenticate(state, &headers)? { let mut context = match authenticate(state, &headers)? {
Some(user) => user, Some(user) => user,
None => return Ok(unauthorized_response()), None => return Ok(unauthorized_response()),
}; };
@@ -139,7 +143,7 @@ async fn handle_get_or_head(
return Ok(method_not_allowed()); return Ok(method_not_allowed());
} }
let resolution = match resolve_path(state, tenant_id, &segments)? { let resolution = match resolve_path(&mut context.conn, tenant_id, &segments)? {
Some(resolved) => resolved, Some(resolved) => resolved,
None => return Ok(not_found_response()), None => return Ok(not_found_response()),
}; };
@@ -233,18 +237,16 @@ fn parse_segments(path: &str) -> AppResult<Vec<String>> {
} }
fn fetch_folder_contents( fn fetch_folder_contents(
state: &AppState, conn: &mut PgPooledConnection,
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>(&mut conn)?, .first::<Folder>(conn)?,
), ),
None => None, None => None,
}; };
@@ -254,12 +256,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(&mut conn)?, .load(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(&mut conn)?, .load(conn)?,
}; };
let mut docs_query = documents_dsl::documents let mut docs_query = documents_dsl::documents
@@ -274,7 +276,7 @@ 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::created_at.desc())
.load(&mut conn)?; .load(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() {
@@ -282,7 +284,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(&mut conn)? .load(conn)?
}; };
let mut version_map = versions let mut version_map = versions
@@ -349,7 +351,7 @@ async fn stream_document(
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.content_type { } else if let Some(ref typ) = document.mime_type {
builder = builder.header(header::CONTENT_TYPE, typ); builder = builder.header(header::CONTENT_TYPE, typ);
} }
@@ -438,7 +440,7 @@ fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavCo
&mut conn, &mut conn,
None, None,
secret, secret,
ApiTokenCapability::Webdav, Some(ApiCapability::WebdavRead),
)? { )? {
Some(token) => token, Some(token) => token,
None => { None => {
@@ -484,6 +486,11 @@ fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavCo
} }
}; };
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);
}
apply_tenant_guc(&mut conn, tenant_id)?; apply_tenant_guc(&mut conn, tenant_id)?;
touch_api_token(&mut conn, token.id)?; touch_api_token(&mut conn, token.id)?;
@@ -498,6 +505,7 @@ fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavCo
tenant_id, tenant_id,
_user_id: user.id, _user_id: user.id,
_username: user.username, _username: user.username,
conn,
})) }))
} }
@@ -521,7 +529,7 @@ fn build_resources_for_folder(
display_name, display_name,
is_collection: true, is_collection: true,
content_length: None, content_length: None,
content_type: None, mime_type: None,
last_modified, last_modified,
}); });
@@ -537,7 +545,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,
content_type: None, mime_type: None,
last_modified: Some(to_http_date(subfolder.updated_at)), last_modified: Some(to_http_date(subfolder.updated_at)),
}); });
} }
@@ -575,7 +583,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),
content_type: document.content_type.clone(), mime_type: document.mime_type.clone(),
last_modified: Some(to_http_date(document.updated_at)), last_modified: Some(to_http_date(document.updated_at)),
} }
} }
@@ -631,7 +639,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.content_type { if let Some(content_type) = &resource.mime_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")))?;
@@ -673,7 +681,7 @@ struct DavResource {
display_name: String, display_name: String,
is_collection: bool, is_collection: bool,
content_length: Option<i64>, content_length: Option<i64>,
content_type: Option<String>, mime_type: Option<String>,
last_modified: Option<String>, last_modified: Option<String>,
} }
enum ResolvedPath { enum ResolvedPath {
@@ -689,11 +697,10 @@ enum ResolvedPath {
} }
fn resolve_path( fn resolve_path(
state: &AppState, conn: &mut PgPooledConnection,
tenant_id: Uuid, tenant_id: Uuid,
segments: &[String], segments: &[String],
) -> AppResult<Option<ResolvedPath>> { ) -> AppResult<Option<ResolvedPath>> {
let mut conn = state.db_for_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::new();
let mut current_folder: Option<Folder> = None; let mut current_folder: Option<Folder> = None;
@@ -701,7 +708,7 @@ fn resolve_path(
for (index, segment) in segments.iter().enumerate() { for (index, segment) in segments.iter().enumerate() {
let is_last = index == segments.len() - 1; let is_last = index == segments.len() - 1;
if let Some(folder) = find_folder_by_name(&mut conn, tenant_id, parent_id, segment)? { if let Some(folder) = find_folder_by_name(conn, 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 }));
@@ -713,7 +720,7 @@ fn resolve_path(
if is_last { if is_last {
if let Some((document, version)) = if let Some((document, version)) =
find_document_by_filename(&mut conn, tenant_id, parent_id, segment)? find_document_by_filename(conn, 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 {
@@ -725,7 +732,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(&mut conn, tenant_id, uuid)? { if let Some(folder) = find_folder_by_id(conn, tenant_id, uuid)? {
if folder.parent_id != parent_id { if folder.parent_id != parent_id {
return Ok(None); return Ok(None);
} }
@@ -738,7 +745,7 @@ fn resolve_path(
continue; continue;
} }
if let Some((document, version)) = find_document_by_id(&mut conn, tenant_id, uuid)? { if let Some((document, version)) = find_document_by_id(conn, tenant_id, uuid)? {
if document.folder_id != parent_id { if document.folder_id != parent_id {
return Ok(None); return Ok(None);
} }
+26 -30
View File
@@ -1,38 +1,34 @@
use anyhow::Result; use anyhow::{anyhow, Context, Result};
use aws_config::meta::region::RegionProviderChain; use s3::{bucket::Bucket, creds::Credentials, region::Region};
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 async fn build_client(config: &AppConfig) -> Result<S3Client> { pub fn build_bucket(config: &AppConfig) -> Result<Bucket> {
let region = Region::new(config.aws_region.clone()); let region = if let Some(endpoint) = &config.aws_endpoint_url {
let region_provider = RegionProviderChain::first_try(Some(region)) Region::Custom {
.or_default_provider() region: config.aws_region.clone(),
.or_else("us-east-1"); endpoint: endpoint.clone(),
}
} else {
config
.aws_region
.parse::<Region>()
.context("invalid AWS region")?
};
#[allow(deprecated)] let credentials = if let (Some(access_key), Some(secret_key)) = (
let mut loader = aws_config::from_env().region(region_provider); config.aws_access_key_id.as_deref(),
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(),
) { ) {
let credentials = Credentials::new(access_key, secret_key, None, None, "static"); Credentials::new(Some(access_key), Some(secret_key), None, None, None)
loader = loader.credentials_provider(credentials); .context("failed to create static AWS credentials")?
} } else {
Credentials::default().context("failed to load AWS credentials")?
};
let base_config = loader.load().await; let bucket = Bucket::new(&config.s3_bucket, region, credentials)
let s3_config = S3ConfigBuilder::from(&base_config) .map_err(|err| anyhow!("failed to create S3 bucket client: {err}"))?;
.force_path_style(true) let bucket = bucket.with_path_style();
.build();
Ok(S3Client::from_conf(s3_config)) Ok(*bucket)
} }
+38 -22
View File
@@ -10,8 +10,8 @@ pub mod sql_types {
pub struct TenantStatus; pub struct TenantStatus;
#[derive(diesel::query_builder::QueryId, Clone, diesel::sql_types::SqlType)] #[derive(diesel::query_builder::QueryId, Clone, diesel::sql_types::SqlType)]
#[diesel(postgres_type(name = "api_token_capability"))] #[diesel(postgres_type(name = "api_capability"))]
pub struct ApiTokenCapability; pub struct ApiCapability;
} }
diesel::table! { diesel::table! {
@@ -26,17 +26,6 @@ diesel::table! {
} }
} }
diesel::table! {
document_asset_objects (id) {
id -> Uuid,
asset_id -> Uuid,
ordinal -> Int4,
s3_key -> Text,
metadata -> Jsonb,
tenant_id -> Uuid,
}
}
diesel::table! { diesel::table! {
document_assets (id) { document_assets (id) {
id -> Uuid, id -> Uuid,
@@ -45,7 +34,7 @@ diesel::table! {
mime_type -> Text, mime_type -> Text,
metadata -> Jsonb, metadata -> Jsonb,
created_at -> Timestamptz, created_at -> Timestamptz,
cardinality -> Nullable<Int4>, s3_key -> Text,
tenant_id -> Uuid, tenant_id -> Uuid,
} }
} }
@@ -94,7 +83,7 @@ diesel::table! {
#[max_length = 255] #[max_length = 255]
original_name -> Varchar, original_name -> Varchar,
#[max_length = 100] #[max_length = 100]
content_type -> Nullable<Varchar>, mime_type -> Nullable<Varchar>,
folder_id -> Nullable<Uuid>, folder_id -> Nullable<Uuid>,
created_at -> Timestamptz, created_at -> Timestamptz,
updated_at -> Timestamptz, updated_at -> Timestamptz,
@@ -131,7 +120,8 @@ diesel::table! {
last_error -> Nullable<Text>, last_error -> Nullable<Text>,
created_at -> Timestamptz, created_at -> Timestamptz,
updated_at -> Timestamptz, updated_at -> Timestamptz,
tenant_id -> Uuid, tenant_id -> Nullable<Uuid>,
result -> Nullable<Jsonb>,
} }
} }
@@ -204,6 +194,7 @@ diesel::table! {
tenant_id -> Uuid, tenant_id -> Uuid,
created_at -> Timestamptz, created_at -> Timestamptz,
updated_at -> Timestamptz, updated_at -> Timestamptz,
capability_set_id -> Nullable<Uuid>,
} }
} }
@@ -251,8 +242,29 @@ diesel::table! {
diesel::table! { diesel::table! {
use diesel::sql_types::*; use diesel::sql_types::*;
use super::sql_types::ApiTokenCapability;
capability_sets (id) {
id -> Uuid,
tenant_id -> Uuid,
slug -> Text,
cap_version -> Int4,
is_system -> Bool,
created_at -> Timestamptz,
updated_at -> Timestamptz,
}
}
diesel::table! {
use diesel::sql_types::*;
use super::sql_types::ApiCapability;
capability_set_capabilities (capability_set_id, capability) {
capability_set_id -> Uuid,
capability -> ApiCapability,
}
}
diesel::table! {
api_tokens (id) { api_tokens (id) {
id -> Uuid, id -> Uuid,
user_id -> Uuid, user_id -> Uuid,
@@ -264,13 +276,13 @@ diesel::table! {
last_used_at -> Nullable<Timestamptz>, last_used_at -> Nullable<Timestamptz>,
expires_at -> Nullable<Timestamptz>, expires_at -> Nullable<Timestamptz>,
revoked_at -> Nullable<Timestamptz>, revoked_at -> Nullable<Timestamptz>,
capabilities -> Array<ApiTokenCapability>, capability_set_id -> Uuid,
} }
} }
diesel::joinable!(correspondents -> tenants (tenant_id)); diesel::joinable!(correspondents -> tenants (tenant_id));
diesel::joinable!(document_asset_objects -> document_assets (asset_id)); diesel::joinable!(capability_set_capabilities -> capability_sets (capability_set_id));
diesel::joinable!(document_asset_objects -> tenants (tenant_id)); diesel::joinable!(capability_sets -> tenants (tenant_id));
diesel::joinable!(document_assets -> document_versions (document_version_id)); diesel::joinable!(document_assets -> document_versions (document_version_id));
diesel::joinable!(document_assets -> tenants (tenant_id)); diesel::joinable!(document_assets -> tenants (tenant_id));
diesel::joinable!(document_correspondents -> correspondents (correspondent_id)); diesel::joinable!(document_correspondents -> correspondents (correspondent_id));
@@ -286,19 +298,24 @@ diesel::joinable!(documents -> folders (folder_id));
diesel::joinable!(documents -> tenants (tenant_id)); diesel::joinable!(documents -> tenants (tenant_id));
diesel::joinable!(folders -> tenants (tenant_id)); diesel::joinable!(folders -> tenants (tenant_id));
diesel::joinable!(jobs -> tenants (tenant_id)); diesel::joinable!(jobs -> tenants (tenant_id));
diesel::joinable!(magic_tokens -> users (user_id));
diesel::joinable!(user_sessions -> tenants (tenant_id)); diesel::joinable!(user_sessions -> tenants (tenant_id));
diesel::joinable!(user_sessions -> users (user_id)); diesel::joinable!(user_sessions -> users (user_id));
diesel::joinable!(tags -> tenants (tenant_id)); diesel::joinable!(tags -> tenants (tenant_id));
diesel::joinable!(user_memberships -> capability_sets (capability_set_id));
diesel::joinable!(user_memberships -> tenants (tenant_id)); diesel::joinable!(user_memberships -> tenants (tenant_id));
diesel::joinable!(user_memberships -> users (user_id)); diesel::joinable!(user_memberships -> users (user_id));
diesel::joinable!(user_passkeys -> users (user_id)); diesel::joinable!(user_passkeys -> users (user_id));
diesel::joinable!(webauthn_challenges -> users (user_id)); diesel::joinable!(webauthn_challenges -> users (user_id));
diesel::joinable!(api_tokens -> tenants (tenant_id)); diesel::joinable!(api_tokens -> tenants (tenant_id));
diesel::joinable!(api_tokens -> capability_sets (capability_set_id));
diesel::joinable!(api_tokens -> users (user_id)); diesel::joinable!(api_tokens -> users (user_id));
diesel::allow_tables_to_appear_in_same_query!( diesel::allow_tables_to_appear_in_same_query!(
api_tokens,
correspondents, correspondents,
document_asset_objects, capability_set_capabilities,
capability_sets,
document_assets, document_assets,
document_correspondents, document_correspondents,
document_tags, document_tags,
@@ -314,5 +331,4 @@ diesel::allow_tables_to_appear_in_same_query!(
user_passkeys, user_passkeys,
users, users,
webauthn_challenges, webauthn_challenges,
api_tokens,
); );
+884
View File
@@ -0,0 +1,884 @@
use axum::http::{header::SET_COOKIE, HeaderMap, HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
use chrono::{Duration as ChronoDuration, TimeZone, Utc};
use diesel::{pg::PgConnection, prelude::*, Connection, OptionalExtension};
use rand::{rngs::OsRng, TryRngCore};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use utoipa::ToSchema;
use uuid::Uuid;
use webauthn_rs::prelude::RegisterPublicKeyCredential;
use crate::auth::{
api_tokens::{find_active_token_by_secret, touch_api_token},
capability_sets::load_capability_set,
jwt::{AccessTokenContext, PrincipalKind},
passkeys::{
AuthenticationChallengeResponse, PasskeyLoginFinishPayload,
PasskeyRegistrationFinishPayload, PasskeySummary, RegistrationChallengeResponse,
},
AuthenticatedUser,
};
use crate::error::{AppError, AppResult};
use crate::http::responders::{ok_json, JsonResponse};
use crate::models::{
MagicToken, MagicTokenKind, NewUser, NewUserSession, TenantStatus, User, UserMembership,
UserSession,
};
use crate::schema::{
magic_tokens::dsl as magic_dsl,
tenants::dsl as tenant_dsl,
user_memberships::dsl as memberships_dsl,
user_passkeys::dsl as passkey_dsl,
user_sessions::{self, dsl as session_dsl},
users::dsl,
};
use crate::state::{AppState, PgPooledConnection};
use crate::tenants::{
apply_tenant_guc, apply_user_guc, apply_user_session_hash, clear_user_guc,
clear_user_session_hash,
};
use crate::utils::text::normalize_identifier;
pub const SESSION_COOKIE_NAME: &str = "refresh_token";
#[derive(Deserialize, ToSchema)]
pub struct LoginRequest {
#[serde(default)]
pub username: String,
#[serde(default)]
#[schema(nullable)]
pub password: Option<String>,
#[serde(default)]
#[schema(nullable)]
pub magic_token: Option<String>,
#[serde(default)]
#[schema(nullable)]
pub preferred_tenant_id: Option<Uuid>,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct ApiTokenExchangeRequest {
pub api_token: String,
}
#[derive(Serialize, Deserialize, ToSchema, Clone)]
pub struct LoginResponse {
pub access_token: String,
pub token_type: String,
pub expires_in: i64,
pub tenant: TenantSnippet,
}
#[derive(Serialize, Deserialize, ToSchema, Clone)]
pub struct TenantSnippet {
pub id: Uuid,
pub name: String,
}
#[derive(Serialize, Deserialize, ToSchema, Clone)]
pub struct TenantSelectionResponse {
pub access_token: String,
pub tenants: Vec<TenantSnippet>,
}
#[derive(Serialize, Deserialize, ToSchema, Clone)]
pub struct TenantListResponse {
pub tenants: Vec<TenantSnippet>,
}
#[derive(Deserialize, ToSchema)]
pub struct TenantSelectionRequest {
pub tenant_id: Uuid,
}
#[derive(Deserialize, ToSchema)]
pub struct SignupStartRequest {
pub username: String,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct SignupStartResponse {
pub signup_token: String,
pub challenge: RegistrationChallengeResponse,
}
#[derive(Deserialize, ToSchema)]
pub struct SignupFinishRequest {
pub signup_token: String,
#[schema(value_type = Object)]
pub credential: RegisterPublicKeyCredential,
#[schema(nullable)]
pub nickname: Option<String>,
}
#[derive(Serialize, Deserialize, ToSchema)]
#[serde(untagged)]
pub enum LoginResponseVariants {
Token(LoginResponse),
Selection(TenantSelectionResponse),
}
pub struct AuthService<'a> {
state: &'a AppState,
}
impl<'a> AuthService<'a> {
pub fn new(state: &'a AppState) -> Self {
Self { state }
}
pub fn login(&self, payload: LoginRequest) -> AppResult<Response> {
let magic_token = payload
.magic_token
.as_ref()
.map(|value| value.trim())
.filter(|value| !value.is_empty());
if magic_token.is_none() {
if payload.password.is_some() {
return Err(AppError::bad_request(
"password authentication is no longer supported",
));
}
return Err(AppError::bad_request(
"magic_token is required for passwordless login",
));
}
let token_value = magic_token.unwrap();
let mut conn = self.state.db_unscoped()?;
let username_hint = payload.username.trim();
let preferred_tenant_id = payload.preferred_tenant_id;
self.magic_token_login(
&mut conn,
token_value,
(!username_hint.is_empty()).then_some(username_hint),
preferred_tenant_id,
)
}
pub fn exchange_api_token(
&self,
payload: ApiTokenExchangeRequest,
) -> AppResult<JsonResponse<LoginResponse>> {
let secret = payload.api_token.trim();
if secret.is_empty() {
return Err(AppError::bad_request("api_token must not be empty"));
}
let mut conn = self.state.db_unscoped()?;
let token = find_active_token_by_secret(&mut conn, None, secret, None)?
.ok_or_else(AppError::unauthorized)?;
let user: User = dsl::users.find(token.user_id).first(&mut conn)?;
apply_user_guc(&mut conn, user.id)?;
let membership = memberships_dsl::user_memberships
.filter(memberships_dsl::user_id.eq(user.id))
.filter(memberships_dsl::tenant_id.eq(token.tenant_id))
.first::<UserMembership>(&mut conn)
.optional()?;
clear_user_guc(&mut conn)?;
let membership = membership.ok_or_else(AppError::unauthorized)?;
let membership_capability_set = membership.capability_set_id.ok_or_else(|| {
AppError::new(
StatusCode::FORBIDDEN,
"membership has no capability set assigned",
)
})?;
let token_capability_set = load_capability_set(&mut conn, token.capability_set_id)?;
let _membership_set = load_capability_set(&mut conn, membership_capability_set)?;
apply_tenant_guc(&mut conn, token.tenant_id)?;
touch_api_token(&mut conn, token.id)?;
let access_token = self
.state
.jwt
.generate_token(AccessTokenContext {
user_id: user.id,
tenant_id: token.tenant_id,
username: user.username.clone(),
principal_kind: PrincipalKind::ApiToken,
principal_id: token.id,
capability_set_id: token_capability_set.id,
cap_version: token_capability_set.cap_version,
})
.map_err(AppError::from)?;
let tenant_name: String = tenant_dsl::tenants
.find(token.tenant_id)
.select(tenant_dsl::name)
.first(&mut conn)
.map_err(AppError::from)?;
ok_json(LoginResponse {
access_token,
token_type: "Bearer".to_string(),
expires_in: self.state.config.jwt_expiry_minutes * 60,
tenant: TenantSnippet {
id: token.tenant_id,
name: tenant_name,
},
})
}
pub fn signup_start(
&self,
payload: SignupStartRequest,
) -> AppResult<JsonResponse<SignupStartResponse>> {
let username = normalize_username(&payload.username)?;
let mut conn = self.state.db_unscoped()?;
let exists: bool = dsl::users
.filter(dsl::username.eq(&username))
.first::<User>(&mut conn)
.optional()?
.is_some();
if exists {
return Err(AppError::conflict("username already exists"));
}
let user_id = Uuid::new_v4();
let challenge = self
.state
.passkeys
.as_ref()
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?
.start_signup_registration(&mut conn, user_id, username.as_str())?;
let signup_token = self
.state
.jwt
.generate_signup_token(user_id, challenge.challenge_id, username.clone())
.map_err(AppError::from)?;
ok_json(SignupStartResponse {
signup_token,
challenge,
})
}
pub fn signup_finish(&self, payload: SignupFinishRequest) -> AppResult<Response> {
let claims = self
.state
.jwt
.verify_signup_token(&payload.signup_token)
.map_err(|_| AppError::unauthorized())?;
let mut conn = self.state.db_unscoped()?;
let exists: bool = dsl::users
.filter(dsl::username.eq(&claims.username))
.first::<User>(&mut conn)
.optional()?
.is_some();
if exists {
return Err(AppError::conflict("username already exists"));
}
let service = self
.state
.passkeys
.as_ref()
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
let prepared_passkey = service.consume_signup_challenge(
&mut conn,
claims.challenge_id,
&payload.credential,
)?;
let state_clone = self.state.clone();
let response = conn.transaction::<Response, AppError, _>(|conn| {
insert_user(conn, claims.sub, &claims.username)?;
let tenant = state_clone.tenants.create_tenant_with_conn(
conn,
&claims.username,
None,
None,
TenantStatus::Creating,
&[claims.sub],
Some(claims.sub),
)?;
let passkey_insert =
prepared_passkey.into_new_user_passkey(claims.sub, payload.nickname.clone());
diesel::insert_into(passkey_dsl::user_passkeys)
.values(&passkey_insert)
.execute(conn)
.map_err(AppError::from)?;
let user: User = dsl::users.find(claims.sub).first(conn)?;
self.issue_session(conn, &user, tenant.id)
})?;
Ok(response)
}
pub fn refresh(&self, refresh_value: &str) -> AppResult<Response> {
let hashed = hash_session_token(refresh_value);
let mut conn = self.state.db_unscoped()?;
let now = Utc::now();
let now_naive = now.naive_utc();
apply_user_session_hash(&mut conn, &hashed)?;
let token = match session_dsl::user_sessions
.filter(session_dsl::token_hash.eq(&hashed))
.filter(session_dsl::revoked_at.is_null())
.filter(session_dsl::expires_at.gt(now_naive))
.first::<UserSession>(&mut conn)
{
Ok(token) => token,
Err(diesel::result::Error::NotFound) => return Err(AppError::unauthorized()),
Err(err) => return Err(AppError::from(err)),
};
clear_user_session_hash(&mut conn)?;
apply_tenant_guc(&mut conn, token.tenant_id)?;
clear_user_guc(&mut conn)?;
diesel::update(session_dsl::user_sessions.filter(session_dsl::id.eq(token.id)))
.set((
session_dsl::revoked_at.eq(now_naive),
session_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)?;
self.issue_session(&mut conn, &user, token.tenant_id)
}
pub fn select_tenant(&self, token: &str, tenant_id: Uuid) -> AppResult<Response> {
let user_id = match self.state.jwt.verify_tenant_selector_token(token) {
Ok(claims) => claims.sub,
Err(_) => self
.state
.jwt
.verify_token(token)
.map(|claims| claims.sub)
.map_err(|_| AppError::unauthorized())?,
};
let mut conn = self.state.db_unscoped()?;
apply_user_guc(&mut conn, user_id)?;
let membership_exists = memberships_dsl::user_memberships
.filter(memberships_dsl::user_id.eq(user_id))
.filter(memberships_dsl::tenant_id.eq(tenant_id))
.select(memberships_dsl::tenant_id)
.first::<Uuid>(&mut conn)
.optional()?;
clear_user_guc(&mut conn)?;
if membership_exists.is_none() {
return Err(AppError::unauthorized());
}
let user: User = dsl::users
.find(user_id)
.first(&mut conn)
.map_err(AppError::from)?;
self.issue_session(&mut conn, &user, tenant_id)
}
pub fn logout(
&self,
conn: &mut PgPooledConnection,
user: &AuthenticatedUser,
refresh_cookie: Option<&str>,
) -> AppResult<(HeaderMap, StatusCode)> {
let now = Utc::now().naive_utc();
let revoked = if let Some(value) = refresh_cookie {
let hashed = hash_session_token(value);
diesel::update(
session_dsl::user_sessions
.filter(session_dsl::token_hash.eq(hashed))
.filter(session_dsl::user_id.eq(user.user_id))
.filter(session_dsl::revoked_at.is_null()),
)
.set((
session_dsl::revoked_at.eq(now),
session_dsl::updated_at.eq(now),
))
.execute(conn)?
} else {
0
};
if revoked == 0 {
diesel::update(
session_dsl::user_sessions
.filter(session_dsl::user_id.eq(user.user_id))
.filter(session_dsl::tenant_id.eq(user.tenant_id))
.filter(session_dsl::revoked_at.is_null()),
)
.set((
session_dsl::revoked_at.eq(now),
session_dsl::updated_at.eq(now),
))
.execute(conn)?;
}
let mut headers = HeaderMap::new();
headers.insert(SET_COOKIE, build_clear_session_cookie(self.state));
Ok((headers, StatusCode::NO_CONTENT))
}
pub fn list_tenants(&self, user_id: Uuid) -> AppResult<JsonResponse<TenantListResponse>> {
let mut conn = self.state.db_unscoped()?;
apply_user_guc(&mut conn, user_id)?;
let tenant_ids: Vec<Uuid> = memberships_dsl::user_memberships
.filter(memberships_dsl::user_id.eq(user_id))
.select(memberships_dsl::tenant_id)
.load(&mut conn)?;
clear_user_guc(&mut conn)?;
let mut tenants = Vec::with_capacity(tenant_ids.len());
for tenant_id in tenant_ids {
let (name, status): (String, TenantStatus) = tenant_dsl::tenants
.find(tenant_id)
.select((tenant_dsl::name, tenant_dsl::status))
.first(&mut conn)
.map_err(AppError::from)?;
if status != TenantStatus::Active {
continue;
}
tenants.push(TenantSnippet {
id: tenant_id,
name,
});
}
ok_json(TenantListResponse { tenants })
}
pub fn get_tenant(
&self,
user_id: Uuid,
tenant_id: Uuid,
) -> AppResult<JsonResponse<TenantSnippet>> {
let mut conn = self.state.db_unscoped()?;
apply_user_guc(&mut conn, user_id)?;
let is_member: bool = diesel::select(diesel::dsl::exists(
memberships_dsl::user_memberships
.filter(memberships_dsl::user_id.eq(user_id))
.filter(memberships_dsl::tenant_id.eq(tenant_id)),
))
.get_result(&mut conn)?;
clear_user_guc(&mut conn)?;
if !is_member {
return Err(AppError::not_found());
}
let (name, status): (String, TenantStatus) = tenant_dsl::tenants
.find(tenant_id)
.select((tenant_dsl::name, tenant_dsl::status))
.first(&mut conn)
.map_err(AppError::from)?;
if status != TenantStatus::Active {
return Err(AppError::not_found());
}
ok_json(TenantSnippet {
id: tenant_id,
name,
})
}
pub fn passkey_register_start(
&self,
user: AuthenticatedUser,
) -> AppResult<JsonResponse<RegistrationChallengeResponse>> {
let service = self
.state
.passkeys
.as_ref()
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
let mut conn = self.state.db_unscoped()?;
let current_user: User = dsl::users.find(user.user_id).first(&mut conn)?;
let challenge = service.start_registration(&mut conn, &current_user)?;
ok_json(challenge)
}
pub fn passkey_register_finish(
&self,
user: AuthenticatedUser,
payload: PasskeyRegistrationFinishPayload,
) -> AppResult<JsonResponse<PasskeySummary>> {
let service = self
.state
.passkeys
.as_ref()
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
let mut conn = self.state.db_unscoped()?;
let current_user: User = dsl::users.find(user.user_id).first(&mut conn)?;
let PasskeyRegistrationFinishPayload {
challenge_id,
credential,
nickname,
} = payload;
let passkey = service.finish_registration(
&mut conn,
&current_user,
challenge_id,
credential,
nickname,
)?;
ok_json(PasskeySummary::from(passkey))
}
pub fn passkey_login_start(
&self,
username: &str,
) -> AppResult<JsonResponse<AuthenticationChallengeResponse>> {
let username = normalize_username(username)?;
let service = self
.state
.passkeys
.as_ref()
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
let mut conn = self.state.db_unscoped()?;
let user: User = dsl::users
.filter(dsl::username.eq(&username))
.first(&mut conn)?;
let challenge = service.start_authentication(&mut conn, &user)?;
ok_json(challenge)
}
pub fn passkey_login_finish(&self, payload: PasskeyLoginFinishPayload) -> AppResult<Response> {
let service = self
.state
.passkeys
.as_ref()
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
let mut conn = self.state.db_unscoped()?;
let (user, _passkey, auth_result) =
service.finish_authentication(&mut conn, payload.challenge_id, payload.credential)?;
if !auth_result.user_verified() {
return Err(AppError::unauthorized());
}
self.complete_login(&mut conn, &user, None)
}
fn complete_login(
&self,
conn: &mut PgConnection,
user: &User,
preferred_tenant_id: Option<Uuid>,
) -> AppResult<Response> {
apply_user_guc(conn, user.id)?;
let tenant_ids: Vec<Uuid> = memberships_dsl::user_memberships
.filter(memberships_dsl::user_id.eq(user.id))
.select(memberships_dsl::tenant_id)
.load(conn)?;
clear_user_guc(conn)?;
if tenant_ids.is_empty() {
return Err(AppError::unauthorized());
}
let mut active_tenants = Vec::new();
for tenant_id in tenant_ids {
let (name, status): (String, TenantStatus) = tenant_dsl::tenants
.find(tenant_id)
.select((tenant_dsl::name, tenant_dsl::status))
.first(conn)
.map_err(AppError::from)?;
if status == TenantStatus::Active {
active_tenants.push((tenant_id, name));
}
}
if active_tenants.is_empty() {
return Err(AppError::new(
StatusCode::FORBIDDEN,
"no active tenants available",
));
}
if let Some(preferred_id) = preferred_tenant_id {
if active_tenants.iter().any(|(id, _)| *id == preferred_id) {
return self.issue_session(conn, user, preferred_id);
}
}
if active_tenants.len() == 1 {
return self.issue_session(conn, user, active_tenants[0].0);
}
let selection_token = self
.state
.jwt
.generate_tenant_selector_token(user.id)
.map_err(AppError::from)?;
let tenants = active_tenants
.into_iter()
.map(|(id, name)| TenantSnippet { id, name })
.collect();
let response = ok_json(LoginResponseVariants::Selection(TenantSelectionResponse {
access_token: selection_token,
tenants,
}))?;
Ok(response.into_response())
}
fn magic_token_login(
&self,
conn: &mut PgConnection,
token_value: &str,
username_hint: Option<&str>,
preferred_tenant_id: Option<Uuid>,
) -> AppResult<Response> {
if token_value.is_empty() {
return Err(AppError::bad_request("magic_token must not be empty"));
}
let token_hash = hash_magic_token(token_value);
let now = Utc::now();
let now_naive = now.naive_utc();
conn.transaction::<Response, AppError, _>(|conn| {
let magic = magic_dsl::magic_tokens
.filter(magic_dsl::token_hash.eq(&token_hash))
.filter(magic_dsl::expires_at.gt(now_naive))
.first::<MagicToken>(conn)
.map_err(|err| match err {
diesel::result::Error::NotFound => AppError::unauthorized(),
_ => AppError::from(err),
})?;
if let Some(limit) = magic.max_uses {
if magic.used_count >= limit {
return Err(AppError::unauthorized());
}
}
match magic.kind {
MagicTokenKind::EmailLogin | MagicTokenKind::DemoLogin => {}
}
let user: User = dsl::users
.find(magic.user_id)
.first(conn)
.map_err(AppError::from)?;
if let Some(expected) = username_hint {
if expected != user.username {
return Err(AppError::unauthorized());
}
}
diesel::update(magic_dsl::magic_tokens.filter(magic_dsl::id.eq(magic.id)))
.set((
magic_dsl::used_count.eq(magic.used_count + 1),
magic_dsl::last_used_at.eq(Some(now_naive)),
))
.execute(conn)?;
self.complete_login(conn, &user, preferred_tenant_id)
})
}
fn issue_session(
&self,
conn: &mut PgConnection,
user: &User,
tenant_id: Uuid,
) -> AppResult<Response> {
crate::auth::ensure_active_tenant_with_conn(conn, tenant_id)?;
apply_tenant_guc(conn, tenant_id)?;
clear_user_guc(conn)?;
clear_user_session_hash(conn)?;
let membership: UserMembership = memberships_dsl::user_memberships
.filter(memberships_dsl::user_id.eq(user.id))
.filter(memberships_dsl::tenant_id.eq(tenant_id))
.first(conn)?;
let capability_set_id = membership.capability_set_id.ok_or_else(|| {
AppError::new(
StatusCode::FORBIDDEN,
"membership has no capability set assigned",
)
})?;
let capability_set = load_capability_set(conn, capability_set_id)?;
let now = Utc::now();
let session_id = Uuid::new_v4();
let access_token = self
.state
.jwt
.generate_token(AccessTokenContext {
user_id: user.id,
tenant_id,
username: user.username.clone(),
principal_kind: PrincipalKind::UserSession,
principal_id: session_id,
capability_set_id,
cap_version: capability_set.cap_version,
})
.map_err(AppError::from)?;
let tenant_name: String = tenant_dsl::tenants
.find(tenant_id)
.select(tenant_dsl::name)
.first(conn)
.map_err(AppError::from)?;
let session_value = generate_session_token();
let session_hash = hash_session_token(&session_value);
let refresh_expires_at =
now + ChronoDuration::days(self.state.config.refresh_token_expiry_days);
let new_session = NewUserSession {
id: session_id,
user_id: user.id,
token_hash: session_hash,
issued_at: now.naive_utc(),
expires_at: refresh_expires_at.naive_utc(),
tenant_id,
};
diesel::insert_into(user_sessions::table)
.values(&new_session)
.execute(conn)?;
let json = ok_json(LoginResponseVariants::Token(LoginResponse {
access_token,
token_type: "Bearer".to_string(),
expires_in: self.state.config.jwt_expiry_minutes * 60,
tenant: TenantSnippet {
id: tenant_id,
name: tenant_name,
},
}))?;
let mut response = json.into_response();
response.headers_mut().insert(
SET_COOKIE,
build_session_cookie(self.state, &session_value, refresh_expires_at),
);
Ok(response)
}
}
fn insert_user(conn: &mut PgConnection, id: Uuid, username: &str) -> AppResult<()> {
let new_user = NewUser {
id,
username: username.to_string(),
};
diesel::insert_into(dsl::users)
.values(&new_user)
.execute(conn)
.map(|_| ())
.map_err(AppError::from)
}
fn hash_session_token(token: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
hex::encode(hasher.finalize())
}
fn hash_magic_token(token: &str) -> String {
hash_session_token(token)
}
fn generate_session_token() -> String {
let mut bytes = [0u8; 32];
OsRng
.try_fill_bytes(&mut bytes)
.expect("failed to read random bytes");
hex::encode(bytes)
}
fn build_cookie(
state: &AppState,
token: Option<&str>,
expires_at: Option<chrono::DateTime<Utc>>,
max_age: i64,
) -> HeaderValue {
let mut parts = vec![format!("{}={}", SESSION_COOKIE_NAME, token.unwrap_or(""))];
parts.push("Path=/".into());
parts.push("HttpOnly".into());
parts.push("SameSite=Strict".into());
parts.push(format!("Max-Age={}", max_age));
if let Some(expires) = expires_at {
parts.push(format!("Expires={}", expires.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 session cookie")
}
fn build_session_cookie(
state: &AppState,
token: &str,
expires_at: chrono::DateTime<Utc>,
) -> HeaderValue {
let max_age = ChronoDuration::days(state.config.refresh_token_expiry_days).num_seconds();
build_cookie(state, Some(token), Some(expires_at), max_age)
}
fn build_clear_session_cookie(state: &AppState) -> HeaderValue {
let epoch = Utc.timestamp_opt(0, 0).single().unwrap();
build_cookie(state, None, Some(epoch), 0)
}
fn normalize_username(value: &str) -> AppResult<String> {
normalize_identifier(
value,
100,
"username must not be empty",
"username must not exceed 100 characters",
Some("username may only contain printable characters"),
|ch| !ch.is_control(),
)
}
+293
View File
@@ -0,0 +1,293 @@
use axum::http::StatusCode;
use chrono::Utc;
use diesel::{pg::PgConnection, prelude::*, Connection, OptionalExtension};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::{
auth::capability_sets::{
compute_slug, create_capability_set as create_capability_set_record, is_system_slug,
load_capabilities_for_set, normalize_capabilities, refresh_capability_set,
},
error::{AppError, AppResult},
http::responders::{
created_json, no_content, ok_json, IntoAppResult, JsonResponse, RowsAffectedExt,
},
models::{ApiCapability, CapabilitySet},
schema::{
api_tokens,
capability_sets::{self, dsl as cs_dsl},
user_memberships,
},
utils::text::normalize_identifier,
};
#[derive(Serialize, utoipa::ToSchema)]
pub struct CapabilitySetResponse {
pub id: Uuid,
pub slug: String,
pub is_system: bool,
pub cap_version: i32,
pub capabilities: Vec<ApiCapability>,
}
#[derive(Deserialize, utoipa::ToSchema)]
pub struct CreateCapabilitySetRequest {
#[serde(default)]
#[serde(rename = "slug")]
pub slug: Option<String>,
pub capabilities: Vec<ApiCapability>,
}
#[derive(Deserialize, utoipa::ToSchema)]
pub struct UpdateCapabilitySetRequest {
#[serde(default)]
#[serde(rename = "slug")]
pub slug: Option<String>,
#[serde(default)]
pub capabilities: Option<Vec<ApiCapability>>,
}
pub struct CapabilitySetService;
impl CapabilitySetService {
pub fn new() -> Self {
Self
}
pub fn list(
&self,
conn: &mut PgConnection,
tenant_id: Uuid,
) -> AppResult<JsonResponse<Vec<CapabilitySetResponse>>> {
let sets = cs_dsl::capability_sets
.filter(cs_dsl::tenant_id.eq(tenant_id))
.order(cs_dsl::slug.asc())
.load::<CapabilitySet>(conn)?;
let mut responses = Vec::with_capacity(sets.len());
for set in sets {
let capabilities = load_capabilities_for_set(conn, set.id)?;
responses.push(to_response(set, capabilities));
}
ok_json(responses)
}
pub fn list_capabilities(&self) -> AppResult<JsonResponse<Vec<ApiCapability>>> {
let capabilities = ApiCapability::variants()
.iter()
.map(|value| value.parse::<ApiCapability>().expect("valid capability"))
.collect();
ok_json(capabilities)
}
pub fn get(
&self,
conn: &mut PgConnection,
tenant_id: Uuid,
id: Uuid,
) -> AppResult<JsonResponse<CapabilitySetResponse>> {
let set = cs_dsl::capability_sets
.filter(cs_dsl::tenant_id.eq(tenant_id))
.find(id)
.first::<CapabilitySet>(conn)
.into_app_result()?;
let capabilities = load_capabilities_for_set(conn, set.id)?;
ok_json(to_response(set, capabilities))
}
pub fn create(
&self,
conn: &mut PgConnection,
tenant_id: Uuid,
payload: CreateCapabilitySetRequest,
) -> AppResult<JsonResponse<CapabilitySetResponse>> {
let original_caps = payload.capabilities;
let normalized_caps = normalize_capabilities(original_caps.clone())?;
if normalized_caps.is_empty() {
return Err(AppError::bad_request("at least one capability is required"));
}
let slug = if let Some(raw) = payload.slug {
let normalized = normalize_slug(&raw)?;
if is_system_slug(&normalized) {
return Err(AppError::conflict("slug is reserved"));
}
normalized
} else {
let generated = compute_slug(&normalized_caps);
if is_system_slug(&generated) {
return Err(AppError::conflict(
"capabilities match a reserved system capability set",
));
}
generated
};
let set = create_capability_set_record(conn, tenant_id, &slug, original_caps)?;
let response = to_response(set, normalized_caps);
created_json(response)
}
pub fn update(
&self,
conn: &mut PgConnection,
tenant_id: Uuid,
id: Uuid,
payload: UpdateCapabilitySetRequest,
) -> AppResult<JsonResponse<CapabilitySetResponse>> {
let set = cs_dsl::capability_sets
.filter(cs_dsl::tenant_id.eq(tenant_id))
.find(id)
.first::<CapabilitySet>(conn)
.into_app_result()?;
if set.is_system {
if payload.slug.is_some() || payload.capabilities.is_some() {
return Err(AppError::conflict(
"system capability sets cannot be modified",
));
}
let capabilities = load_capabilities_for_set(conn, set.id)?;
return ok_json(to_response(set, capabilities));
}
let set = conn.transaction::<CapabilitySet, AppError, _>(|conn| {
let mut working = set.clone();
if let Some(slug) = &payload.slug {
let normalized = normalize_slug(slug)?;
if is_system_slug(&normalized) {
return Err(AppError::conflict("slug is reserved"));
}
if cs_dsl::capability_sets
.filter(cs_dsl::tenant_id.eq(tenant_id))
.filter(cs_dsl::slug.eq(&normalized))
.filter(cs_dsl::id.ne(working.id))
.first::<CapabilitySet>(conn)
.optional()
.into_app_result()?
.is_some()
{
return Err(AppError::conflict("slug already exists"));
}
diesel::update(cs_dsl::capability_sets.find(working.id))
.set((
cs_dsl::slug.eq(&normalized),
cs_dsl::updated_at.eq(Utc::now().naive_utc()),
))
.execute(conn)
.into_app_result()?;
working.slug = normalized;
}
if let Some(capabilities) = &payload.capabilities {
let normalized = normalize_capabilities(capabilities.clone())?;
if normalized.is_empty() {
return Err(AppError::bad_request("at least one capability is required"));
}
let updated = refresh_capability_set(conn, &working, &normalized)?;
working = updated;
}
capability_sets::table
.find(working.id)
.first::<CapabilitySet>(conn)
.into_app_result()
})?;
let capabilities = load_capabilities_for_set(conn, set.id)?;
ok_json(to_response(set, capabilities))
}
pub fn delete(
&self,
conn: &mut PgConnection,
tenant_id: Uuid,
id: Uuid,
) -> AppResult<StatusCode> {
let set = cs_dsl::capability_sets
.filter(cs_dsl::tenant_id.eq(tenant_id))
.find(id)
.first::<CapabilitySet>(conn)
.into_app_result()?;
if set.is_system {
return Err(AppError::conflict(
"system capability sets cannot be deleted",
));
}
let in_use_memberships: i64 = user_memberships::table
.filter(user_memberships::capability_set_id.eq(Some(set.id)))
.count()
.get_result(conn)?;
if in_use_memberships > 0 {
return Err(AppError::conflict(
"capability set is assigned to user memberships",
));
}
let in_use_tokens: i64 = api_tokens::table
.filter(api_tokens::capability_set_id.eq(set.id))
.count()
.get_result(conn)?;
if in_use_tokens > 0 {
return Err(AppError::conflict(
"capability set is assigned to API tokens",
));
}
diesel::delete(cs_dsl::capability_sets.find(set.id))
.execute(conn)
.into_app_result()?
.or_not_found()?;
no_content()
}
}
fn normalize_slug(value: &str) -> AppResult<String> {
let base = normalize_identifier(
value,
64,
"slug must not be empty",
"slug must not exceed 64 characters",
Some("slug may only contain alphanumeric characters, hyphen, underscore, or whitespace"),
|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch.is_whitespace(),
)?;
let mut normalized = String::with_capacity(base.len());
for ch in base.chars() {
if ch.is_whitespace() {
normalized.push('-');
} else {
normalized.push(ch.to_ascii_lowercase());
}
}
if normalized.is_empty() {
return Err(AppError::bad_request("slug must not be empty"));
}
Ok(normalized)
}
fn to_response(set: CapabilitySet, capabilities: Vec<ApiCapability>) -> CapabilitySetResponse {
CapabilitySetResponse {
id: set.id,
slug: set.slug,
is_system: set.is_system,
cap_version: set.cap_version,
capabilities,
}
}
+261
View File
@@ -0,0 +1,261 @@
use chrono::Utc;
use diesel::{dsl::not, prelude::*, Connection};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
use crate::documents::correspondents::{
insert_document_correspondents, normalize_correspondent_ids,
};
use crate::error::{AppError, AppResult};
use crate::schema::{document_correspondents, documents};
use crate::services::helpers::load_active_document;
use crate::state::{AppState, PgPooledConnection};
use crate::utils::db::validate_bulk_ids;
#[derive(Serialize, ToSchema)]
pub struct BulkCorrespondentResponse {
pub assigned: usize,
pub removed: usize,
}
#[derive(Deserialize, ToSchema)]
pub struct CorrespondentAssignmentInput {
pub correspondent_id: Uuid,
}
#[derive(Deserialize, ToSchema)]
pub struct AssignCorrespondentsRequest {
pub assignments: Vec<CorrespondentAssignmentInput>,
#[serde(default)]
#[schema(default = false)]
pub replace: bool,
}
#[derive(Deserialize, Copy, Clone, PartialEq, Eq, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum BulkCorrespondentAction {
Add,
Remove,
}
fn default_bulk_correspondent_action() -> BulkCorrespondentAction {
BulkCorrespondentAction::Add
}
#[derive(Deserialize, ToSchema)]
pub struct BulkCorrespondentsRequest {
pub document_ids: Vec<Uuid>,
pub assignments: Vec<CorrespondentAssignmentInput>,
#[serde(default = "default_bulk_correspondent_action")]
pub action: BulkCorrespondentAction,
}
pub struct CorrespondentsService<'a> {
_state: &'a AppState,
}
impl<'a> CorrespondentsService<'a> {
pub fn new(state: &'a AppState) -> Self {
Self { _state: state }
}
pub fn assign_to_document(
&self,
conn: &mut PgPooledConnection,
tenant_id: Uuid,
user_id: Uuid,
document_id: Uuid,
request: &AssignCorrespondentsRequest,
) -> AppResult<()> {
if request.assignments.is_empty() {
return Err(AppError::bad_request("assignments must not be empty"));
}
let raw_ids: Vec<Uuid> = request
.assignments
.iter()
.map(|assignment| assignment.correspondent_id)
.collect();
let correspondent_ids = normalize_correspondent_ids(&raw_ids)?;
conn.transaction::<_, AppError, _>(|conn| {
let document = load_active_document(conn, tenant_id, document_id)?;
let mut updated = false;
if request.replace {
let base = document_correspondents::table
.filter(document_correspondents::document_id.eq(document_id))
.filter(document_correspondents::tenant_id.eq(tenant_id));
let removed = if correspondent_ids.is_empty() {
diesel::delete(base).execute(conn)?
} else {
diesel::delete(base.filter(not(
document_correspondents::correspondent_id.eq_any(&correspondent_ids),
)))
.execute(conn)?
};
if removed > 0 {
updated = true;
}
}
let inserted = insert_document_correspondents(
conn,
tenant_id,
document.id,
user_id,
&correspondent_ids,
)?;
if inserted > 0 {
updated = true;
}
if updated && inserted == 0 {
diesel::update(
documents::table
.find(document_id)
.filter(documents::tenant_id.eq(tenant_id)),
)
.set(documents::updated_at.eq(Utc::now().naive_utc()))
.execute(conn)?;
}
Ok(())
})
}
pub fn bulk_update(
&self,
conn: &mut PgPooledConnection,
tenant_id: Uuid,
user_id: Uuid,
mut payload: BulkCorrespondentsRequest,
) -> AppResult<BulkCorrespondentResponse> {
if payload.assignments.is_empty() {
return Err(AppError::bad_request("assignments must not be empty"));
}
validate_bulk_ids(&mut payload.document_ids, "document_ids")?;
let raw_ids: Vec<Uuid> = payload
.assignments
.iter()
.map(|assignment| assignment.correspondent_id)
.collect();
let correspondent_ids = normalize_correspondent_ids(&raw_ids)?;
let action = payload.action;
conn.transaction::<_, AppError, _>(|conn| {
let docs: Vec<(Uuid, Option<chrono::NaiveDateTime>)> = documents::table
.filter(documents::id.eq_any(&payload.document_ids))
.filter(documents::tenant_id.eq(tenant_id))
.select((documents::id, documents::deleted_at))
.load(conn)?;
if docs.len() != payload.document_ids.len() {
return Err(AppError::bad_request(
"one or more documents do not exist or are inaccessible",
));
}
if docs.iter().any(|(_, deleted)| deleted.is_some()) {
return Err(AppError::bad_request(
"cannot assign correspondents to deleted documents",
));
}
match action {
BulkCorrespondentAction::Add => {
let mut assigned_total = 0;
for (doc_id, _) in &docs {
assigned_total += insert_document_correspondents(
conn,
tenant_id,
*doc_id,
user_id,
&correspondent_ids,
)?;
}
Ok(BulkCorrespondentResponse {
assigned: assigned_total,
removed: 0,
})
}
BulkCorrespondentAction::Remove => {
if correspondent_ids.is_empty() {
return Ok(BulkCorrespondentResponse {
assigned: 0,
removed: 0,
});
}
let removed = diesel::delete(
document_correspondents::table
.filter(
document_correspondents::document_id.eq_any(&payload.document_ids),
)
.filter(document_correspondents::tenant_id.eq(tenant_id))
.filter(
document_correspondents::correspondent_id
.eq_any(&correspondent_ids),
),
)
.execute(conn)?;
if removed > 0 {
diesel::update(
documents::table
.filter(documents::id.eq_any(&payload.document_ids))
.filter(documents::tenant_id.eq(tenant_id)),
)
.set(documents::updated_at.eq(Utc::now().naive_utc()))
.execute(conn)?;
}
Ok(BulkCorrespondentResponse {
assigned: 0,
removed,
})
}
}
})
}
pub fn remove_from_document(
&self,
conn: &mut PgPooledConnection,
tenant_id: Uuid,
document_id: Uuid,
correspondent_id: Uuid,
) -> AppResult<()> {
load_active_document(conn, tenant_id, document_id)?;
let deleted = diesel::delete(
document_correspondents::table
.filter(document_correspondents::document_id.eq(document_id))
.filter(document_correspondents::tenant_id.eq(tenant_id))
.filter(document_correspondents::correspondent_id.eq(correspondent_id)),
)
.execute(conn)?;
if deleted == 0 {
return Err(AppError::not_found());
}
diesel::update(
documents::table
.find(document_id)
.filter(documents::tenant_id.eq(tenant_id)),
)
.set(documents::updated_at.eq(Utc::now().naive_utc()))
.execute(conn)?;
Ok(())
}
}
File diff suppressed because it is too large Load Diff
+635
View File
@@ -0,0 +1,635 @@
use std::collections::HashMap;
use diesel::pg::PgConnection;
use diesel::{
dsl::{exists, sql},
prelude::*,
sql_types::Text,
Connection,
};
use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use uuid::Uuid;
use crate::documents::ordering::{ordering_clauses, DocumentSortField, SortDirection};
use crate::error::{AppError, AppResult};
use crate::http::responders::{IntoAppResult, RowsAffectedExt};
use crate::models::{Document, Folder, NewFolder};
use crate::schema::{documents, folders};
use crate::services::documents::{DocumentResponse, DocumentsService};
use crate::state::{AppState, PgPooledConnection};
use crate::utils::{json::deserialize_patch_field, text::normalize_identifier, time::to_iso};
const MAX_FOLDER_NAME_LEN: usize = 255;
#[derive(Deserialize, ToSchema)]
pub struct CreateFolderRequest {
pub name: String,
#[schema(nullable)]
pub parent_id: Option<Uuid>,
}
#[derive(Deserialize, ToSchema)]
pub struct EnsureFolderPathRequest {
#[schema(nullable)]
pub parent_id: Option<Uuid>,
pub segments: Vec<String>,
}
#[derive(Serialize, ToSchema, Clone, Debug)]
pub struct FolderInfo {
pub id: Uuid,
pub name: String,
#[schema(nullable)]
pub parent_id: Option<Uuid>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Clone, Serialize, ToSchema)]
pub struct FolderTreeNode {
pub id: Uuid,
pub name: String,
#[schema(nullable)]
pub parent_id: Option<Uuid>,
pub created_at: String,
pub updated_at: String,
#[serde(default)]
pub children: Vec<FolderTreeNode>,
}
#[derive(Deserialize, IntoParams, ToSchema)]
#[into_params(parameter_in = Query)]
pub struct FolderContentsQuery {
#[serde(default = "default_include_documents")]
#[schema(default = true)]
pub include_documents: bool,
#[serde(default)]
#[schema(default = "title")]
pub sort: DocumentSortField,
#[serde(default)]
#[schema(default = "asc")]
pub dir: SortDirection,
}
#[derive(Default, Deserialize, ToSchema)]
pub struct UpdateFolderRequest {
#[serde(default, deserialize_with = "deserialize_patch_field")]
#[schema(nullable, value_type = Option<Uuid>)]
pub parent_id: Option<Option<Uuid>>,
#[serde(default, deserialize_with = "deserialize_patch_field")]
#[schema(nullable)]
pub name: Option<Option<String>>,
}
pub struct FolderContentsData {
pub folder: Option<FolderInfo>,
pub subfolders: Vec<FolderInfo>,
pub documents: Vec<Document>,
}
pub struct FolderService<'a> {
state: &'a AppState,
}
impl<'a> FolderService<'a> {
pub fn new(state: &'a AppState) -> Self {
Self { state }
}
pub fn get_folder(
&self,
conn: &mut PgPooledConnection,
tenant_id: Uuid,
folder_id: Uuid,
) -> AppResult<FolderInfo> {
let folder: Folder = folders::table
.find(folder_id)
.filter(folders::tenant_id.eq(tenant_id))
.first(conn)?;
Ok(folder_to_info(folder))
}
pub fn ensure_folder_path(
&self,
conn: &mut PgPooledConnection,
tenant_id: Uuid,
payload: EnsureFolderPathRequest,
) -> AppResult<FolderInfo> {
if payload.segments.is_empty() {
return Err(AppError::bad_request("segments must not be empty"));
}
let 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 = normalize_folder_name(raw_name, "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.clone(),
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"))
})?;
Ok(folder_to_info(folder))
}
pub fn create_folder(
&self,
conn: &mut PgPooledConnection,
tenant_id: Uuid,
payload: CreateFolderRequest,
) -> AppResult<(FolderInfo, bool)> {
let name = normalize_folder_name(&payload.name, "name must not be empty")?;
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(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, created) = if let Some(folder) = existing {
(folder, false)
} else {
let new_folder = NewFolder {
id: Uuid::new_v4(),
name: name.clone(),
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(conn)
.optional()?;
if let Some(id) = inserted_id {
(
folders::table
.find(id)
.filter(folders::tenant_id.eq(tenant_id))
.first(conn)?,
true,
)
} 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(conn)?,
false,
)
} else {
(
folders::table
.filter(folders::tenant_id.eq(tenant_id))
.filter(folders::parent_id.is_null())
.filter(folders::name.eq(&name))
.first(conn)?,
false,
)
}
};
Ok((folder_to_info(folder), created))
}
pub fn list_folder_contents(
&self,
conn: &mut PgPooledConnection,
tenant_id: Uuid,
folder_id: Option<Uuid>,
sort: DocumentSortField,
dir: SortDirection,
include_documents: bool,
) -> AppResult<FolderContentsData> {
let folder = match folder_id {
Some(id) => Some(folder_to_info(
folders::table
.find(id)
.filter(folders::tenant_id.eq(tenant_id))
.first::<Folder>(conn)?,
)),
None => None,
};
let child_folders: Vec<Folder> = if let Some(parent_id) = folder_id {
folders::table
.filter(folders::parent_id.eq(parent_id))
.filter(folders::tenant_id.eq(tenant_id))
.order(sql::<Text>("name COLLATE \"unicode_ci\" ASC"))
.load(conn)?
} else {
folders::table
.filter(folders::parent_id.is_null())
.filter(folders::tenant_id.eq(tenant_id))
.order(sql::<Text>("name COLLATE \"unicode_ci\" ASC"))
.load(conn)?
};
let subfolders = child_folders.into_iter().map(folder_to_info).collect();
let documents = if include_documents {
let mut docs_query = documents::table
.filter(documents::deleted_at.is_null())
.filter(documents::tenant_id.eq(tenant_id))
.into_boxed();
let (primary_sql, secondary_sql) = ordering_clauses(sort, dir);
docs_query = docs_query.order(sql::<Text>(primary_sql));
if let Some(second) = secondary_sql {
docs_query = docs_query.then_order_by(sql::<Text>(second));
}
if let Some(current_folder) = folder_id {
docs_query
.filter(documents::folder_id.eq(current_folder))
.load::<Document>(conn)?
} else {
docs_query
.filter(documents::folder_id.is_null())
.load::<Document>(conn)?
}
} else {
Vec::new()
};
Ok(FolderContentsData {
folder,
subfolders,
documents,
})
}
pub fn list_folder_tree(
&self,
conn: &mut PgPooledConnection,
tenant_id: Uuid,
) -> AppResult<Vec<FolderTreeNode>> {
let folders: Vec<Folder> = folders::table
.filter(folders::tenant_id.eq(tenant_id))
.order(sql::<Text>("name COLLATE \"unicode_ci\" ASC"))
.load(conn)?;
let mut node_map: HashMap<Uuid, FolderTreeNode> = HashMap::with_capacity(folders.len());
let mut children_map: HashMap<Uuid, Vec<Uuid>> = HashMap::new();
let mut roots: Vec<Uuid> = Vec::new();
for folder in folders {
let id = folder.id;
let parent_id = folder.parent_id;
let node = FolderTreeNode {
id,
name: folder.name,
parent_id,
created_at: to_iso(folder.created_at),
updated_at: to_iso(folder.updated_at),
children: Vec::new(),
};
if let Some(parent) = parent_id {
children_map.entry(parent).or_default().push(id);
} else {
roots.push(id);
}
node_map.insert(id, node);
}
fn build_node(
id: Uuid,
nodes: &HashMap<Uuid, FolderTreeNode>,
child_map: &HashMap<Uuid, Vec<Uuid>>,
) -> FolderTreeNode {
let mut node = nodes.get(&id).cloned().expect("folder node must exist");
if let Some(children) = child_map.get(&id) {
node.children = children
.iter()
.map(|child_id| build_node(*child_id, nodes, child_map))
.collect();
}
node
}
let tree = roots
.iter()
.map(|root_id| build_node(*root_id, &node_map, &children_map))
.collect();
Ok(tree)
}
pub fn delete_folder(
&self,
conn: &mut PgPooledConnection,
tenant_id: Uuid,
folder_id: Uuid,
) -> AppResult<()> {
conn.transaction::<_, AppError, _>(|conn| {
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)
.into_app_result()?
.or_not_found()?;
Ok(())
})
}
pub fn update_folder(
&self,
conn: &mut PgPooledConnection,
tenant_id: Uuid,
folder_id: Uuid,
payload: UpdateFolderRequest,
) -> AppResult<()> {
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 payload.parent_id {
None => {}
Some(None) => {
if folder.parent_id.is_some() {
parent_changed = true;
}
next_parent = None;
}
Some(Some(parent_id)) => {
if parent_id == folder_id {
return Err(AppError::bad_request("folder cannot be its own parent"));
}
folders::table
.find(parent_id)
.filter(folders::tenant_id.eq(tenant_id))
.first::<Folder>(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 payload.name {
None => {}
Some(None) => {
return Err(AppError::bad_request("name cannot be null"));
}
Some(Some(value)) => {
let normalized = normalize_folder_name(&value, "name must not be empty")?;
if normalized != folder.name {
new_name = normalized;
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)
.into_app_result()?
.or_not_found()?;
Ok(())
})
}
pub fn hydrate_documents(
&self,
conn: &mut PgPooledConnection,
tenant_id: Uuid,
user_id: Uuid,
docs: Vec<Document>,
) -> AppResult<Vec<DocumentResponse>> {
DocumentsService::new(self.state).hydrate_documents(conn, tenant_id, user_id, docs)
}
}
pub fn gather_descendant_folder_ids(
conn: &mut PgConnection,
tenant_id: Uuid,
folder_id: Uuid,
) -> AppResult<Vec<Uuid>> {
let mut ids = vec![folder_id];
let mut queue = vec![folder_id];
while let Some(current) = queue.pop() {
let child_ids: Vec<Uuid> = folders::table
.filter(folders::parent_id.eq(Some(current)))
.filter(folders::tenant_id.eq(tenant_id))
.select(folders::id)
.load(conn)?;
queue.extend(child_ids.iter().copied());
ids.extend(child_ids);
}
Ok(ids)
}
fn folder_to_info(folder: Folder) -> FolderInfo {
FolderInfo {
id: folder.id,
name: folder.name,
parent_id: folder.parent_id,
created_at: to_iso(folder.created_at),
updated_at: to_iso(folder.updated_at),
}
}
fn normalize_folder_name(value: &str, empty_message: &str) -> AppResult<String> {
normalize_identifier(
value,
MAX_FOLDER_NAME_LEN,
empty_message,
"folder name must not exceed 255 characters",
Some("folder name may only contain printable characters"),
|ch| !ch.is_control(),
)
}
const fn default_include_documents() -> bool {
true
}
#[cfg(test)]
mod tests {
use super::UpdateFolderRequest;
use serde_json::json;
#[test]
fn update_folder_request_deserializes_null_parent() {
let req: UpdateFolderRequest =
serde_json::from_value(json!({ "parent_id": null })).unwrap();
assert!(matches!(req.parent_id, Some(None)));
}
#[test]
fn update_folder_request_deserializes_absent_parent() {
let req: UpdateFolderRequest = serde_json::from_value(json!({})).unwrap();
assert!(req.parent_id.is_none());
}
#[test]
fn update_folder_request_deserializes_null_name() {
let req: UpdateFolderRequest = serde_json::from_value(json!({ "name": null })).unwrap();
assert!(matches!(req.name, Some(None)));
}
}
+25
View File
@@ -0,0 +1,25 @@
use diesel::prelude::*;
use uuid::Uuid;
use crate::error::{AppError, AppResult};
use crate::models::Document;
use crate::schema::documents;
use crate::state::PgPooledConnection;
/// Load a document that belongs to the tenant and is not soft-deleted.
pub fn load_active_document(
conn: &mut PgPooledConnection,
tenant_id: Uuid,
document_id: Uuid,
) -> AppResult<Document> {
let doc: Document = documents::table
.find(document_id)
.filter(documents::tenant_id.eq(tenant_id))
.first(conn)?;
if doc.deleted_at.is_some() {
return Err(AppError::not_found());
}
Ok(doc)
}
+9
View File
@@ -0,0 +1,9 @@
pub mod auth;
pub mod capability_sets;
pub mod correspondents;
pub mod documents;
pub mod folders;
pub mod helpers;
pub mod profile;
pub mod tags;
pub mod tenants;
+222
View File
@@ -0,0 +1,222 @@
use axum::http::StatusCode;
use chrono::{DateTime, NaiveDateTime};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
use crate::auth::{
api_tokens::{
create_api_token as issue_token, list_api_tokens as load_tokens,
regenerate_api_token as rotate_token, revoke_api_token as revoke_token,
},
capability_sets::load_capability_set,
passkeys::PasskeySummary,
};
use crate::error::{AppError, AppResult};
use crate::http::responders::{created_json, no_content, ok_json, JsonResponse};
use crate::models::ApiToken;
use crate::state::{AppState, PgPooledConnection};
use crate::utils::time::to_iso;
#[derive(Debug, Serialize, ToSchema)]
pub struct ApiTokenResponse {
pub id: Uuid,
pub tenant_id: Uuid,
#[schema(nullable)]
pub label: Option<String>,
pub capability_set_id: Uuid,
pub created_at: String,
#[schema(nullable)]
pub last_used_at: Option<String>,
#[schema(nullable)]
pub expires_at: Option<String>,
#[schema(nullable)]
pub revoked_at: Option<String>,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct ApiTokenCreatedResponse {
pub token: String,
pub token_info: ApiTokenResponse,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct CreateApiTokenRequest {
#[schema(nullable)]
pub label: Option<String>,
#[schema(nullable)]
pub expires_at: Option<String>,
pub capability_set_id: Uuid,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct RevokePasskeyQuery {
#[serde(default)]
#[schema(nullable)]
pub reason: Option<String>,
}
pub struct ProfileService<'a> {
state: &'a AppState,
}
impl<'a> ProfileService<'a> {
pub fn new(state: &'a AppState) -> Self {
Self { state }
}
pub fn list_passkeys(
&self,
conn: &mut PgPooledConnection,
user_id: Uuid,
) -> AppResult<JsonResponse<Vec<PasskeySummary>>> {
let service = self
.state
.passkeys
.as_ref()
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
let passkeys = service.list_for_user(conn, user_id)?;
ok_json(passkeys)
}
pub fn list_api_tokens(
&self,
conn: &mut PgPooledConnection,
tenant_id: Uuid,
user_id: Uuid,
) -> AppResult<JsonResponse<Vec<ApiTokenResponse>>> {
let tokens = load_tokens(conn, user_id, Some(tenant_id))?;
let responses = tokens.into_iter().map(api_token_to_response).collect();
ok_json(responses)
}
pub fn create_api_token(
&self,
conn: &mut PgPooledConnection,
tenant_id: Uuid,
user_id: Uuid,
payload: CreateApiTokenRequest,
) -> AppResult<JsonResponse<ApiTokenCreatedResponse>> {
let expires_at = payload
.expires_at
.as_ref()
.map(|value| parse_timestamp(value))
.transpose()?;
let capability_set_id =
validate_capability_set(conn, tenant_id, payload.capability_set_id)?;
let issued = issue_token(
conn,
user_id,
tenant_id,
payload.label.clone(),
expires_at,
capability_set_id,
)?;
let token_info = api_token_to_response(issued.record);
let response = ApiTokenCreatedResponse {
token: issued.token,
token_info,
};
created_json(response)
}
pub fn regenerate_api_token(
&self,
conn: &mut PgPooledConnection,
tenant_id: Uuid,
user_id: Uuid,
token_id: Uuid,
) -> AppResult<JsonResponse<ApiTokenCreatedResponse>> {
let issued = rotate_token(conn, token_id, user_id, Some(tenant_id))?;
let token_info = api_token_to_response(issued.record);
ok_json(ApiTokenCreatedResponse {
token: issued.token,
token_info,
})
}
pub fn delete_api_token(
&self,
conn: &mut PgPooledConnection,
user_id: Uuid,
token_id: Uuid,
) -> AppResult<StatusCode> {
revoke_token(conn, token_id, user_id)?;
no_content()
}
pub fn delete_passkey(
&self,
conn: &mut PgPooledConnection,
user_id: Uuid,
passkey_id: Uuid,
reason: Option<String>,
) -> AppResult<StatusCode> {
let service = self
.state
.passkeys
.as_ref()
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
let active_count = service.active_passkey_count(conn, user_id)?;
if active_count <= 1 {
return Err(AppError::bad_request(
"cannot revoke the last remaining passkey",
));
}
service.revoke_passkey(conn, user_id, passkey_id, reason)?;
no_content()
}
}
fn api_token_to_response(token: ApiToken) -> ApiTokenResponse {
let ApiToken {
id,
tenant_id,
label,
created_at,
last_used_at,
expires_at,
revoked_at,
capability_set_id,
..
} = token;
ApiTokenResponse {
id,
tenant_id,
label,
capability_set_id,
created_at: to_iso(created_at),
last_used_at: last_used_at.map(to_iso),
expires_at: expires_at.map(to_iso),
revoked_at: revoked_at.map(to_iso),
}
}
fn parse_timestamp(value: &str) -> AppResult<NaiveDateTime> {
let dt = DateTime::parse_from_rfc3339(value)
.map_err(|_| AppError::bad_request("invalid expires_at timestamp"))?;
Ok(dt.naive_utc())
}
fn validate_capability_set(
conn: &mut PgPooledConnection,
tenant_id: Uuid,
capability_set_id: Uuid,
) -> AppResult<Uuid> {
let set = load_capability_set(conn, capability_set_id)?;
if set.tenant_id != tenant_id {
return Err(AppError::bad_request(
"capability set does not belong to the tenant",
));
}
Ok(set.id)
}
+166
View File
@@ -0,0 +1,166 @@
use diesel::prelude::*;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
use crate::documents::tags::assign_tags as assign_tags_to_document;
use crate::error::{AppError, AppResult};
use crate::models::{Document, NewDocumentTag, Tag};
use crate::schema::{document_tags, documents, tags};
use crate::state::{AppState, PgPooledConnection};
use crate::utils::db::validate_bulk_ids;
#[derive(Deserialize, ToSchema)]
pub struct AssignTagsRequest {
pub tag_ids: Vec<Uuid>,
}
#[derive(Deserialize, Copy, Clone, PartialEq, Eq, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum BulkTagAction {
Add,
Remove,
}
#[derive(Deserialize, ToSchema)]
pub struct BulkTagRequest {
pub document_ids: Vec<Uuid>,
pub tag_ids: Vec<Uuid>,
pub action: BulkTagAction,
}
#[derive(Serialize, ToSchema)]
pub struct BulkTagResponse {
pub added: usize,
pub removed: usize,
}
pub struct TagsService<'a> {
_state: &'a AppState,
}
impl<'a> TagsService<'a> {
pub fn new(state: &'a AppState) -> Self {
Self { _state: state }
}
pub fn assign_to_document(
&self,
conn: &mut PgPooledConnection,
tenant_id: Uuid,
user_id: Uuid,
document_id: Uuid,
tag_ids: &[Uuid],
) -> AppResult<()> {
if tag_ids.is_empty() {
return Err(AppError::bad_request("tag_ids must not be empty"));
}
let document: Document = documents::table
.find(document_id)
.filter(documents::tenant_id.eq(tenant_id))
.first(conn)?;
assign_tags_to_document(conn, tenant_id, &document, tag_ids, Some(user_id))?;
Ok(())
}
pub fn bulk_update(
&self,
conn: &mut PgPooledConnection,
tenant_id: Uuid,
user_id: Uuid,
mut payload: BulkTagRequest,
) -> AppResult<BulkTagResponse> {
validate_bulk_ids(&mut payload.document_ids, "document_ids")?;
validate_bulk_ids(&mut payload.tag_ids, "tag_ids")?;
let docs: Vec<(Uuid, Option<chrono::NaiveDateTime>)> = documents::table
.filter(documents::id.eq_any(&payload.document_ids))
.filter(documents::tenant_id.eq(tenant_id))
.select((documents::id, documents::deleted_at))
.load(conn)?;
if docs.len() != payload.document_ids.len() {
return Err(AppError::bad_request(
"one or more documents do not exist or are inaccessible",
));
}
if docs.iter().any(|(_, deleted)| deleted.is_some()) {
return Err(AppError::bad_request(
"cannot assign or remove tags from deleted documents",
));
}
let existing_tags: Vec<Tag> = tags::table
.filter(tags::id.eq_any(&payload.tag_ids))
.filter(tags::tenant_id.eq(tenant_id))
.load(conn)?;
if existing_tags.len() != payload.tag_ids.len() {
return Err(AppError::bad_request("one or more tags do not exist"));
}
match payload.action {
BulkTagAction::Add => {
let mut inserts =
Vec::with_capacity(payload.document_ids.len() * payload.tag_ids.len());
for doc_id in &payload.document_ids {
for tag_id in &payload.tag_ids {
inserts.push(NewDocumentTag {
document_id: *doc_id,
tag_id: *tag_id,
assigned_by: Some(user_id),
tenant_id,
});
}
}
let added = if inserts.is_empty() {
0
} else {
diesel::insert_into(document_tags::table)
.values(&inserts)
.on_conflict_do_nothing()
.execute(conn)?
};
Ok(BulkTagResponse { added, removed: 0 })
}
BulkTagAction::Remove => {
let removed = diesel::delete(
document_tags::table
.filter(document_tags::document_id.eq_any(&payload.document_ids))
.filter(document_tags::tenant_id.eq(tenant_id))
.filter(document_tags::tag_id.eq_any(&payload.tag_ids)),
)
.execute(conn)?;
Ok(BulkTagResponse { added: 0, removed })
}
}
}
pub fn remove_from_document(
&self,
conn: &mut PgPooledConnection,
tenant_id: Uuid,
document_id: Uuid,
tag_id: Uuid,
) -> AppResult<()> {
let deleted = diesel::delete(
document_tags::table
.filter(document_tags::document_id.eq(document_id))
.filter(document_tags::tenant_id.eq(tenant_id))
.filter(document_tags::tag_id.eq(tag_id)),
)
.execute(conn)?;
if deleted == 0 {
return Err(AppError::not_found());
}
Ok(())
}
}
+260
View File
@@ -0,0 +1,260 @@
use chrono::Utc;
use diesel::prelude::*;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
use crate::auth::AuthenticatedUser;
use crate::error::{AppError, AppResult};
use crate::http::responders::{ok_json, JsonResponse};
use crate::models::ApiCapability;
use crate::schema::{
capability_sets::dsl as cs_dsl, user_memberships::dsl as memberships_dsl,
user_sessions::dsl as session_dsl, users::dsl as users_dsl,
};
use crate::state::{AppState, PgPooledConnection};
#[derive(Deserialize, ToSchema)]
pub struct UpdateTenantRequest {
#[schema(example = "Acme Inc.")]
pub name: String,
}
#[derive(Deserialize, ToSchema)]
pub struct UpdateTenantUserRequest {
#[schema(example = "a2f1bc73-4c90-4bb9-9da9-1c5d04be12ac")]
pub capability_set_id: Uuid,
}
#[derive(Serialize, ToSchema)]
#[schema(example = json!({
"user_id": "11111111-2222-3333-4444-555555555555",
"username": "cfo",
"capability_set_id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"capability_set_slug": "owner"
}))]
pub struct TenantUserSummary {
pub user_id: Uuid,
pub username: String,
pub capability_set_id: Option<Uuid>,
pub capability_set_slug: Option<String>,
}
#[derive(Serialize, ToSchema)]
#[schema(example = json!({
"users": [
{
"user_id": "11111111-2222-3333-4444-555555555555",
"username": "alice",
"capability_set_id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"capability_set_slug": "owner"
},
{
"user_id": "66666666-7777-8888-9999-000000000000",
"username": "bob",
"capability_set_id": "bbbbbbbb-cccc-dddd-eeee-ffffffffffff",
"capability_set_slug": "user"
}
]
}))]
pub struct TenantUserListResponse {
pub users: Vec<TenantUserSummary>,
}
pub struct TenantApiService<'a> {
state: &'a AppState,
}
impl<'a> TenantApiService<'a> {
pub fn new(state: &'a AppState) -> Self {
Self { state }
}
pub fn update_name(
&self,
user: AuthenticatedUser,
tenant_id: Uuid,
payload: UpdateTenantRequest,
) -> AppResult<JsonResponse<crate::services::auth::TenantSnippet>> {
self.ensure_can_manage(&user, tenant_id)?;
let tenant = self.state.tenants.update_name(tenant_id, &payload.name)?;
ok_json(crate::services::auth::TenantSnippet {
id: tenant.id,
name: tenant.name,
})
}
pub fn list_users(
&self,
user: &AuthenticatedUser,
tenant_id: Uuid,
) -> AppResult<JsonResponse<TenantUserListResponse>> {
self.ensure_can_manage(user, tenant_id)?;
let mut conn = self.state.db_for_tenant(tenant_id)?;
let rows: Vec<(Uuid, String, Option<Uuid>, Option<String>)> =
memberships_dsl::user_memberships
.inner_join(users_dsl::users.on(users_dsl::id.eq(memberships_dsl::user_id)))
.left_join(
cs_dsl::capability_sets
.on(cs_dsl::id.nullable().eq(memberships_dsl::capability_set_id)),
)
.select((
users_dsl::id,
users_dsl::username,
memberships_dsl::capability_set_id,
cs_dsl::slug.nullable(),
))
.order(users_dsl::username.asc())
.load(&mut conn)?;
let users = rows
.into_iter()
.map(
|(user_id, username, capability_set_id, capability_set_slug)| TenantUserSummary {
user_id,
username,
capability_set_id,
capability_set_slug,
},
)
.collect();
ok_json(TenantUserListResponse { users })
}
pub fn get_user(
&self,
user: &AuthenticatedUser,
tenant_id: Uuid,
target_user_id: Uuid,
) -> AppResult<JsonResponse<TenantUserSummary>> {
self.ensure_can_manage(user, tenant_id)?;
let mut conn = self.state.db_for_tenant(tenant_id)?;
let summary = self.load_membership_summary(&mut conn, tenant_id, target_user_id)?;
ok_json(summary)
}
pub fn update_user(
&self,
user: &AuthenticatedUser,
tenant_id: Uuid,
target_user_id: Uuid,
payload: UpdateTenantUserRequest,
) -> AppResult<JsonResponse<TenantUserSummary>> {
self.ensure_can_manage(user, tenant_id)?;
let mut conn = self.state.db_for_tenant(tenant_id)?;
let capability_set_id = self.resolve_capability_set_id(&mut conn, tenant_id, &payload)?;
let updated = diesel::update(
memberships_dsl::user_memberships
.filter(memberships_dsl::tenant_id.eq(tenant_id))
.filter(memberships_dsl::user_id.eq(target_user_id)),
)
.set((
memberships_dsl::capability_set_id.eq(Some(capability_set_id)),
memberships_dsl::updated_at.eq(Utc::now().naive_utc()),
))
.execute(&mut conn)?;
if updated == 0 {
return Err(AppError::not_found());
}
let summary = self.load_membership_summary(&mut conn, tenant_id, target_user_id)?;
ok_json(summary)
}
pub fn remove_user(
&self,
user: &AuthenticatedUser,
tenant_id: Uuid,
target_user_id: Uuid,
) -> AppResult<()> {
self.ensure_can_manage(user, tenant_id)?;
let mut conn = self.state.db_for_tenant(tenant_id)?;
let removed = diesel::delete(
memberships_dsl::user_memberships
.filter(memberships_dsl::tenant_id.eq(tenant_id))
.filter(memberships_dsl::user_id.eq(target_user_id)),
)
.execute(&mut conn)?;
if removed == 0 {
return Err(AppError::not_found());
}
diesel::delete(
session_dsl::user_sessions
.filter(session_dsl::tenant_id.eq(tenant_id))
.filter(session_dsl::user_id.eq(target_user_id)),
)
.execute(&mut conn)?;
Ok(())
}
fn ensure_can_manage(&self, user: &AuthenticatedUser, tenant_id: Uuid) -> AppResult<()> {
if user.tenant_id != tenant_id {
return Err(AppError::forbidden("cannot manage another tenant"));
}
if !user.capabilities.contains(&ApiCapability::TenantsWrite) {
return Err(AppError::forbidden("missing tenants:write capability"));
}
Ok(())
}
fn resolve_capability_set_id(
&self,
conn: &mut PgPooledConnection,
tenant_id: Uuid,
payload: &UpdateTenantUserRequest,
) -> AppResult<Uuid> {
let exists = cs_dsl::capability_sets
.filter(cs_dsl::tenant_id.eq(tenant_id))
.filter(cs_dsl::id.eq(payload.capability_set_id))
.select(cs_dsl::id)
.first::<Uuid>(conn)
.optional()?;
exists.ok_or_else(AppError::not_found)
}
fn load_membership_summary(
&self,
conn: &mut PgPooledConnection,
tenant_id: Uuid,
user_id: Uuid,
) -> AppResult<TenantUserSummary> {
let row = memberships_dsl::user_memberships
.filter(memberships_dsl::tenant_id.eq(tenant_id))
.filter(memberships_dsl::user_id.eq(user_id))
.inner_join(users_dsl::users.on(users_dsl::id.eq(memberships_dsl::user_id)))
.left_join(
cs_dsl::capability_sets
.on(cs_dsl::id.nullable().eq(memberships_dsl::capability_set_id)),
)
.select((
users_dsl::id,
users_dsl::username,
memberships_dsl::capability_set_id,
cs_dsl::slug.nullable(),
))
.first::<(Uuid, String, Option<Uuid>, Option<String>)>(conn)
.optional()?;
match row {
Some((user_id, username, capability_set_id, capability_set_slug)) => {
Ok(TenantUserSummary {
user_id,
username,
capability_set_id,
capability_set_slug,
})
}
None => Err(AppError::not_found()),
}
}
}
+14 -5
View File
@@ -11,6 +11,7 @@ use crate::{
config::AppConfig, config::AppConfig,
db::PgPool, db::PgPool,
error::{AppError, AppResult}, error::{AppError, AppResult},
issued_at::IssuedAtSettings,
storage::{ObjectStorage, TenantStorage}, storage::{ObjectStorage, TenantStorage},
tenants::{apply_tenant_guc, clear_tenant_context, clear_user_guc, TenantService}, tenants::{apply_tenant_guc, clear_tenant_context, clear_user_guc, TenantService},
}; };
@@ -25,6 +26,7 @@ pub struct AppState {
pub jwt: JwtService, pub jwt: JwtService,
pub tenants: TenantService, pub tenants: TenantService,
pub passkeys: Option<PasskeyService>, pub passkeys: Option<PasskeyService>,
issued_at: Arc<IssuedAtSettings>,
} }
impl AppState { impl AppState {
@@ -34,11 +36,8 @@ impl AppState {
) -> anyhow::Result<Self> { ) -> anyhow::Result<Self> {
let pool_size = pool_size_override.unwrap_or(config.database_max_pool_size); let pool_size = pool_size_override.unwrap_or(config.database_max_pool_size);
let pool = crate::db::init_pool_with_size(&config.database_url, pool_size)?; let pool = crate::db::init_pool_with_size(&config.database_url, pool_size)?;
let s3_client = crate::s3::build_client(&config).await?; let bucket = crate::s3::build_bucket(&config)?;
let storage = Arc::new(crate::storage::S3Storage::new( let storage = Arc::new(crate::storage::S3Storage::new(bucket));
s3_client,
config.s3_bucket.clone(),
));
let jwt = crate::auth::jwt::JwtService::from_config(&config)?; let jwt = crate::auth::jwt::JwtService::from_config(&config)?;
Ok(Self::new(pool, config, storage, jwt)) Ok(Self::new(pool, config, storage, jwt))
@@ -50,6 +49,7 @@ impl AppState {
storage: Arc<dyn ObjectStorage>, storage: Arc<dyn ObjectStorage>,
jwt: JwtService, jwt: JwtService,
) -> Self { ) -> Self {
let issued_at = Arc::new(IssuedAtSettings::from_config(&config));
let config = Arc::new(config); let config = Arc::new(config);
let tenants = TenantService::new(pool.clone()); let tenants = TenantService::new(pool.clone());
@@ -68,12 +68,15 @@ impl AppState {
jwt, jwt,
tenants, tenants,
passkeys, passkeys,
issued_at,
} }
} }
pub fn db_for_tenant(&self, tenant_id: Uuid) -> AppResult<PgPooledConnection> { pub fn db_for_tenant(&self, tenant_id: Uuid) -> AppResult<PgPooledConnection> {
debug_assert!(!tenant_id.is_nil(), "nil tenant_id passed to db_for_tenant"); debug_assert!(!tenant_id.is_nil(), "nil tenant_id passed to db_for_tenant");
let mut conn = self.db_unscoped()?; let mut conn = self.db_unscoped()?;
let conn_ptr = &*conn as *const _;
tracing::trace!(target = "db_pool", ?conn_ptr, tenant_id = %tenant_id, "apply tenant context");
apply_tenant_guc(&mut conn, tenant_id)?; apply_tenant_guc(&mut conn, tenant_id)?;
clear_user_guc(&mut conn)?; clear_user_guc(&mut conn)?;
Ok(conn) Ok(conn)
@@ -84,6 +87,8 @@ impl AppState {
tracing::error!(error = ?err, "database pool error"); tracing::error!(error = ?err, "database pool error");
AppError::internal("database pool error") AppError::internal("database pool error")
})?; })?;
let conn_ptr = &*conn as *const _;
tracing::trace!(target = "db_pool", ?conn_ptr, "acquired connection");
clear_tenant_context(&mut conn)?; clear_tenant_context(&mut conn)?;
Ok(conn) Ok(conn)
} }
@@ -95,4 +100,8 @@ impl AppState {
AppError::internal("tenant storage error") AppError::internal("tenant storage error")
}) })
} }
pub fn issued_at_settings(&self) -> Arc<IssuedAtSettings> {
self.issued_at.clone()
}
} }
+59 -56
View File
@@ -1,12 +1,10 @@
use std::time::Duration; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration;
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
use async_trait::async_trait; use async_trait::async_trait;
use aws_sdk_s3::presigning::PresigningConfig; use s3::bucket::Bucket;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::Client as S3Client;
use crate::models::Tenant; use crate::models::Tenant;
@@ -29,20 +27,22 @@ pub trait ObjectStorage: Send + Sync + 'static {
async fn get_object(&self, key: &str) -> Result<Vec<u8>>; async fn get_object(&self, key: &str) -> Result<Vec<u8>>;
async fn get_object_range(&self, key: &str, start: u64, end: Option<u64>) -> Result<Vec<u8>>;
async fn delete_object(&self, key: &str) -> Result<()>; async fn delete_object(&self, key: &str) -> Result<()>;
} }
pub struct S3Storage { pub struct S3Storage {
client: S3Client, bucket: Bucket,
bucket: String,
} }
impl S3Storage { impl S3Storage {
pub fn new(client: S3Client, bucket: impl Into<String>) -> Self { pub fn new(bucket: Bucket) -> Self {
Self { Self { bucket }
client, }
bucket: bucket.into(),
} fn default_content_type(content_type: Option<String>) -> String {
content_type.unwrap_or_else(|| "application/octet-stream".to_string())
} }
} }
@@ -55,23 +55,19 @@ impl ObjectStorage for S3Storage {
content_type: Option<String>, content_type: Option<String>,
content_disposition: Option<String>, content_disposition: Option<String>,
) -> Result<()> { ) -> Result<()> {
let mut request = self let mut builder = self
.client .bucket
.put_object() .put_object_builder(key, &bytes)
.bucket(&self.bucket) .with_content_type(Self::default_content_type(content_type));
.key(key)
.body(ByteStream::from(bytes));
if let Some(content_type) = content_type { if let Some(disposition) = content_disposition {
request = request.content_type(content_type); builder = builder
.with_content_disposition(disposition)
.context("invalid content disposition header")?;
} }
if let Some(content_disposition) = content_disposition { builder
request = request.content_disposition(content_disposition); .execute()
}
request
.send()
.await .await
.context("failed to upload object to S3")?; .context("failed to upload object to S3")?;
@@ -84,51 +80,44 @@ impl ObjectStorage for S3Storage {
expires_in: Duration, expires_in: Duration,
response_content_disposition: Option<&str>, response_content_disposition: Option<&str>,
) -> Result<String> { ) -> Result<String> {
let presign_config = PresigningConfig::builder() let expiry_secs =
.expires_in(expires_in) u32::try_from(expires_in.as_secs()).context("presign expiry exceeds u32 range")?;
.build()
.context("failed to build S3 presigning config")?;
let mut request = self.client.get_object().bucket(&self.bucket).key(key); let mut queries = HashMap::new();
if let Some(value) = response_content_disposition { if let Some(value) = response_content_disposition {
request = request.response_content_disposition(value); queries.insert(
"response-content-disposition".to_string(),
value.to_string(),
);
} }
let presigned = request self.bucket
.presigned(presign_config) .presign_get(key, expiry_secs, (!queries.is_empty()).then_some(queries))
.await .await
.context("failed to generate presigned download URL")?; .context("failed to generate presigned download URL")
Ok(presigned.uri().to_string())
} }
async fn get_object(&self, key: &str) -> Result<Vec<u8>> { async fn get_object(&self, key: &str) -> Result<Vec<u8>> {
let response = self let data = self
.client .bucket
.get_object() .get_object(key)
.bucket(&self.bucket)
.key(key)
.send()
.await .await
.context("failed to download object from S3")?; .context("failed to download object from S3")?;
Ok(data.into_bytes().to_vec())
}
let bytes = response async fn get_object_range(&self, key: &str, start: u64, end: Option<u64>) -> Result<Vec<u8>> {
.body let data = self
.collect() .bucket
.get_object_range(key, start, end)
.await .await
.context("failed to read object stream")? .context("failed to download ranged object from S3")?;
.into_bytes() Ok(data.into_bytes().to_vec())
.to_vec();
Ok(bytes)
} }
async fn delete_object(&self, key: &str) -> Result<()> { async fn delete_object(&self, key: &str) -> Result<()> {
self.client self.bucket
.delete_object() .delete_object(key)
.bucket(&self.bucket)
.key(key)
.send()
.await .await
.context("failed to delete object from S3")?; .context("failed to delete object from S3")?;
Ok(()) Ok(())
@@ -155,6 +144,10 @@ impl TenantStorage {
format!("{}{}", self.root, key) format!("{}{}", self.root, key)
} }
pub fn root_prefix(&self) -> &str {
&self.root
}
pub async fn put_object( pub async fn put_object(
&self, &self,
key: &str, key: &str,
@@ -185,6 +178,16 @@ impl TenantStorage {
self.inner.get_object(&qualified).await self.inner.get_object(&qualified).await
} }
pub async fn get_object_range(
&self,
key: &str,
start: u64,
end: Option<u64>,
) -> Result<Vec<u8>> {
let qualified = self.qualify(key);
self.inner.get_object_range(&qualified, start, end).await
}
pub async fn delete_object(&self, key: &str) -> Result<()> { pub async fn delete_object(&self, key: &str) -> Result<()> {
let qualified = self.qualify(key); let qualified = self.qualify(key);
self.inner.delete_object(&qualified).await self.inner.delete_object(&qualified).await
+32 -1
View File
@@ -8,6 +8,7 @@ use crate::{
jobs::{enqueue_job, JOB_PROVISION_TENANT}, jobs::{enqueue_job, JOB_PROVISION_TENANT},
models::{Tenant, TenantStatus}, models::{Tenant, TenantStatus},
schema::tenants::dsl, schema::tenants::dsl,
utils::text::normalize_identifier,
}; };
pub struct TenantRepository; pub struct TenantRepository;
@@ -23,6 +24,13 @@ impl TenantRepository {
.first(conn) .first(conn)
.map_err(Into::into) .map_err(Into::into)
} }
pub fn update_name(conn: &mut PgConnection, tenant_id: Uuid, name: &str) -> AppResult<Tenant> {
diesel::update(dsl::tenants.find(tenant_id))
.set(dsl::name.eq(name))
.execute(conn)?;
Self::get_by_id(conn, tenant_id)
}
} }
#[derive(Clone)] #[derive(Clone)]
@@ -96,6 +104,8 @@ impl TenantService {
return Err(AppError::bad_request("tenant name must not be empty")); return Err(AppError::bad_request("tenant name must not be empty"));
} }
let name = normalize_tenant_name(name)?;
let id = Uuid::new_v4(); let id = Uuid::new_v4();
let storage_root = normalize_storage_root(storage_root, id); let storage_root = normalize_storage_root(storage_root, id);
let quickwit_index = normalize_quickwit_index(quickwit_index, id); let quickwit_index = normalize_quickwit_index(quickwit_index, id);
@@ -103,7 +113,7 @@ impl TenantService {
diesel::insert_into(dsl::tenants) diesel::insert_into(dsl::tenants)
.values(( .values((
dsl::id.eq(id), dsl::id.eq(id),
dsl::name.eq(name), dsl::name.eq(&name),
dsl::storage_root.eq(Some(storage_root.clone())), dsl::storage_root.eq(Some(storage_root.clone())),
dsl::quickwit_index.eq(Some(quickwit_index.clone())), dsl::quickwit_index.eq(Some(quickwit_index.clone())),
dsl::config.eq(json!({})), dsl::config.eq(json!({})),
@@ -125,6 +135,16 @@ impl TenantService {
TenantRepository::get_by_id(conn, id) TenantRepository::get_by_id(conn, id)
} }
pub fn update_name(&self, tenant_id: Uuid, name: &str) -> AppResult<Tenant> {
let mut conn = self.pool.get().map_err(|err| {
tracing::error!(error = ?err, "database pool error");
AppError::internal("database pool error")
})?;
let normalized = normalize_tenant_name(name)?;
TenantRepository::update_name(&mut conn, tenant_id, &normalized)
}
} }
pub fn apply_tenant_guc(conn: &mut PgConnection, tenant_id: Uuid) -> AppResult<()> { pub fn apply_tenant_guc(conn: &mut PgConnection, tenant_id: Uuid) -> AppResult<()> {
@@ -186,6 +206,17 @@ pub fn apply_api_token_prefix(conn: &mut PgConnection, prefix: &str) -> AppResul
.map_err(AppError::from) .map_err(AppError::from)
} }
fn normalize_tenant_name(value: &str) -> AppResult<String> {
normalize_identifier(
value,
255,
"tenant name must not be empty",
"tenant name must not exceed 255 characters",
Some("tenant name may only contain printable characters"),
|ch| !ch.is_control(),
)
}
pub fn clear_api_token_prefix(conn: &mut PgConnection) -> AppResult<()> { pub fn clear_api_token_prefix(conn: &mut PgConnection) -> AppResult<()> {
diesel::sql_query("SELECT set_config('papercrate.api_token_prefix', '', false)") diesel::sql_query("SELECT set_config('papercrate.api_token_prefix', '', false)")
.execute(conn) .execute(conn)
+935
View File
@@ -0,0 +1,935 @@
use std::collections::HashMap;
use std::env;
use std::sync::Arc;
use std::time::Duration;
use crate::auth::capability_sets::{
ensure_capability_set, owner_capabilities, readonly_capabilities, user_capabilities,
webdav_capabilities,
};
use crate::auth::jwt::{AccessTokenContext, JwtService, PrincipalKind};
use crate::config::AppConfig;
use crate::db::{self, PgPool};
use crate::migrations::MIGRATIONS;
use crate::models::{
Job, NewUser, NewUserMembership, NewUserPasskey, NewUserSession, Tenant, TenantStatus, User,
UserMembership,
};
use crate::routes;
use crate::schema::user_sessions::dsl as session_dsl;
use crate::state::AppState;
use crate::storage::ObjectStorage;
use anyhow::{anyhow, ensure, Context, Result};
use async_trait::async_trait;
use axum::body::Body;
use axum::http::{header, Method, Request};
use axum::Router;
use chrono::{Duration as ChronoDuration, Utc};
use diesel::connection::SimpleConnection;
use diesel::prelude::*;
use diesel::OptionalExtension;
use diesel::PgConnection;
use diesel_migrations::MigrationHarness;
use http_body_util::BodyExt;
use once_cell::sync::Lazy;
use rand::{rngs::OsRng, TryRngCore};
use serde::Serialize;
use serde_json::{self, json};
use sha2::{Digest, Sha256};
use tokio::sync::Mutex;
use tower::util::ServiceExt;
use uuid::Uuid;
const RESET_DATABASE_SQL: &str = "DROP SCHEMA IF EXISTS tenant CASCADE;\n\
DROP SCHEMA IF EXISTS shared CASCADE;\n\
DROP SCHEMA IF EXISTS public CASCADE;\n\
CREATE SCHEMA public;\n\
GRANT ALL ON SCHEMA public TO public;";
static DB_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
const TEST_TENANT_NAME: &str = "test_tenant";
#[derive(Clone, Copy, Debug)]
pub enum TestUserRole {
Owner,
Member,
WebDav,
}
#[derive(Clone)]
pub struct StoredObject {
pub key: String,
pub bytes: Vec<u8>,
pub content_type: Option<String>,
pub content_disposition: Option<String>,
}
#[derive(Default)]
pub struct FakeStorage {
objects: Mutex<HashMap<String, StoredObject>>,
}
#[async_trait]
impl ObjectStorage for FakeStorage {
async fn put_object(
&self,
key: &str,
bytes: Vec<u8>,
content_type: Option<String>,
content_disposition: Option<String>,
) -> Result<()> {
let stored = StoredObject {
key: key.to_string(),
bytes,
content_type,
content_disposition,
};
let mut guard = self.objects.lock().await;
guard.insert(stored.key.clone(), stored);
Ok(())
}
async fn presign_get_object(
&self,
key: &str,
expires_in: Duration,
_response_content_disposition: Option<&str>,
) -> Result<String> {
let guard = self.objects.lock().await;
ensure!(guard.contains_key(key), "object {key} missing");
Ok(format!(
"https://fake-storage/{key}?expires_in={}",
expires_in.as_secs()
))
}
async fn get_object(&self, key: &str) -> Result<Vec<u8>> {
let guard = self.objects.lock().await;
guard
.get(key)
.map(|obj| obj.bytes.clone())
.ok_or_else(|| anyhow!("object {key} missing"))
}
async fn get_object_range(
&self,
key: &str,
start: u64,
end: Option<u64>,
) -> Result<Vec<u8>> {
let guard = self.objects.lock().await;
let bytes = guard
.get(key)
.map(|obj| obj.bytes.clone())
.ok_or_else(|| anyhow!("object {key} missing"))?;
let start_idx = start as usize;
let end_idx = end.map(|idx| idx.saturating_add(1) as usize).unwrap_or(bytes.len());
if start_idx >= bytes.len() {
return Ok(Vec::new());
}
Ok(bytes[start_idx..end_idx.min(bytes.len())].to_vec())
}
async fn delete_object(&self, key: &str) -> Result<()> {
let mut guard = self.objects.lock().await;
guard.remove(key);
Ok(())
}
}
impl FakeStorage {
pub async fn get(&self, key: &str) -> Option<StoredObject> {
let guard = self.objects.lock().await;
guard.get(key).cloned()
}
pub async fn object_count(&self) -> usize {
let guard = self.objects.lock().await;
guard.len()
}
pub async fn object_count_with_prefix(&self, prefix: &str) -> usize {
let guard = self.objects.lock().await;
guard.keys().filter(|key| key.starts_with(prefix)).count()
}
pub async fn contains_key(&self, key: &str) -> bool {
let guard = self.objects.lock().await;
guard.contains_key(key)
}
pub async fn keys_with_prefix(&self, prefix: &str) -> Vec<String> {
let guard = self.objects.lock().await;
guard
.keys()
.filter(|key| key.starts_with(prefix))
.cloned()
.collect()
}
}
pub struct TestApp {
pub state: AppState,
router: Router,
storage: Arc<FakeStorage>,
}
impl TestApp {
pub async fn new() -> Result<Self> {
Self::with_config(|_| {}).await
}
pub async fn with_config<F>(configure: F) -> Result<Self>
where
F: FnOnce(&mut AppConfig),
{
let database_url = env::var("TEST_DATABASE_URL")
.context("TEST_DATABASE_URL must be set for integration tests")?;
let mut config = AppConfig {
database_url: database_url.clone(),
migrations_database_url: None,
database_max_pool_size: db::DEFAULT_MAX_POOL_SIZE,
server_host: "127.0.0.1".to_string(),
server_port: 0,
webdav_host: "127.0.0.1".to_string(),
webdav_port: 0,
jwt_secret: "test-secret".to_string(),
jwt_issuer: "test-issuer".to_string(),
jwt_audience: "test-audience".to_string(),
jwt_expiry_minutes: 60,
download_token_audience: "test-download".to_string(),
download_token_expiry_minutes: 60,
refresh_token_expiry_days: 30,
refresh_cookie_secure: false,
refresh_cookie_domain: None,
cors_allowed_origin: None,
proxy_downloads: false,
aws_endpoint_url: None,
aws_access_key_id: None,
aws_secret_access_key: None,
aws_region: "us-east-1".to_string(),
s3_bucket: "test-bucket".to_string(),
quickwit_endpoint: None,
quickwit_index: None,
worker_max_document_bytes: 200 * 1024 * 1024,
upload_body_limit_bytes: 128 * 1024 * 1024,
service_timezone: "UTC".to_string(),
issued_at_date_order: "DMY".to_string(),
issued_at_filename_date_order: None,
issued_at_date_parser_locales: Vec::new(),
issued_at_ignore_dates: Vec::new(),
webauthn_rp_id: Some("localhost".to_string()),
webauthn_origin: Some("http://localhost".to_string()),
webauthn_rp_name: "Papercrate".to_string(),
};
configure(&mut config);
let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?;
prepare_database(&pool).await?;
let storage = Arc::new(FakeStorage::default());
let storage_for_state: Arc<dyn ObjectStorage> = storage.clone();
let jwt = JwtService::from_config(&config)?;
let state = AppState::new(pool.clone(), config, storage_for_state, jwt);
let router = routes::create_router(state.clone());
let app = Self {
state,
router,
storage,
};
app.ensure_default_tenant().await?;
Ok(app)
}
pub async fn cleanup(&self) -> Result<()> {
let pool = self.state.pool.clone();
let _ = tokio::task::spawn_blocking(move || -> Result<()> {
let mut conn = pool
.get()
.map_err(|err| anyhow!("failed to get cleanup connection: {err}"))?;
truncate_all(&mut conn)?;
Ok(())
})
.await
.context("cleanup task panicked")?;
self.ensure_default_tenant().await?;
Ok(())
}
pub async fn tenant_id(&self) -> Result<Uuid> {
self.ensure_default_tenant().await
}
pub fn storage(&self) -> Arc<FakeStorage> {
self.storage.clone()
}
pub async fn storage_key_for(&self, key: &str) -> Result<String> {
self.ensure_default_tenant().await?;
let tenant = self
.state
.tenants
.get_by_name(TEST_TENANT_NAME)
.map_err(|err| anyhow!("default tenant not found: {:?}", err))?;
let root = tenant
.storage_root
.clone()
.ok_or_else(|| anyhow!("default tenant missing storage root"))?;
Ok(format!("{}{}", root, key))
}
pub async fn insert_user(&self, username: &str, role: TestUserRole) -> Result<Uuid> {
let username = username.to_string();
let tenant_id = self.ensure_default_tenant().await?;
let user_id = self
.with_conn(move |conn| {
let user = NewUser {
id: Uuid::new_v4(),
username,
};
diesel::insert_into(crate::schema::users::table)
.values(&user)
.execute(conn)
.context("failed to insert user")?;
let capabilities = match role {
TestUserRole::Owner => owner_capabilities(),
TestUserRole::Member => user_capabilities(),
TestUserRole::WebDav => webdav_capabilities(),
};
let capability_set = ensure_capability_set(conn, tenant_id, capabilities)
.map_err(|err| anyhow!("failed to ensure capability set: {:?}", err))?;
let membership = NewUserMembership {
id: Uuid::new_v4(),
user_id: user.id,
tenant_id,
capability_set_id: Some(capability_set.id),
};
diesel::insert_into(crate::schema::user_memberships::table)
.values(&membership)
.execute(conn)
.context("failed to insert user membership")?;
Ok(user.id)
})
.await?;
Ok(user_id)
}
pub async fn insert_passkey(&self, user_id: Uuid, nickname: Option<&str>) -> Result<Uuid> {
let passkey_id = Uuid::new_v4();
let nickname = nickname.map(|value| value.to_string());
self.with_conn(move |conn| {
let credential_id = passkey_id.as_bytes().to_vec();
let public_key = passkey_id.as_bytes().iter().copied().collect::<Vec<u8>>();
let passkey = NewUserPasskey {
id: passkey_id,
user_id,
credential_id,
public_key,
credential: json!({ "dummy": passkey_id.to_string() }),
sign_count: 0,
transports: vec![Some("usb".to_string())],
aaguid: None,
nickname,
};
diesel::insert_into(crate::schema::user_passkeys::table)
.values(&passkey)
.execute(conn)
.context("failed to insert passkey")?;
Ok(passkey_id)
})
.await
}
async fn ensure_default_tenant(&self) -> Result<Uuid> {
let name_value = TEST_TENANT_NAME.to_string();
let quickwit_enabled = self.state.config.quickwit_endpoint.is_some();
let tenant_id = self
.with_conn(move |conn| {
use crate::schema::tenants::dsl as tenants_dsl;
let existing = tenants_dsl::tenants
.filter(tenants_dsl::name.eq(&name_value))
.first::<Tenant>(conn)
.optional()
.context("failed to load default tenant")?;
let tenant_id = if let Some(current) = existing {
let desired_root = current
.storage_root
.clone()
.filter(|root| root.ends_with('/'))
.unwrap_or_else(|| format!("test-tenants/{}/", current.id));
if current.storage_root.as_deref() != Some(desired_root.as_str()) {
diesel::update(tenants_dsl::tenants.filter(tenants_dsl::id.eq(current.id)))
.set(tenants_dsl::storage_root.eq(Some(desired_root)))
.execute(conn)
.context("failed to update default tenant storage root")?;
}
current.id
} else {
let new_id = Uuid::new_v4();
let root = format!("test-tenants/{}/", new_id);
let quickwit_value = if quickwit_enabled {
Some(format!("documents-{}", new_id))
} else {
None
};
diesel::insert_into(tenants_dsl::tenants)
.values((
tenants_dsl::id.eq(new_id),
tenants_dsl::name.eq(&name_value),
tenants_dsl::storage_root.eq(Some(root)),
tenants_dsl::quickwit_index.eq(quickwit_value),
tenants_dsl::status.eq(TenantStatus::Active),
))
.execute(conn)
.context("failed to insert default tenant")?;
new_id
};
Ok(tenant_id)
})
.await?;
let mut conn = self
.state
.db_for_tenant(tenant_id)
.map_err(|err| anyhow!("failed to scope tenant connection: {err:?}"))?;
ensure_capability_set(&mut conn, tenant_id, owner_capabilities())
.map_err(|err| anyhow!("ensure owner capability set: {err:?}"))?;
ensure_capability_set(&mut conn, tenant_id, user_capabilities())
.map_err(|err| anyhow!("ensure user capability set: {err:?}"))?;
ensure_capability_set(&mut conn, tenant_id, readonly_capabilities())
.map_err(|err| anyhow!("ensure readonly capability set: {err:?}"))?;
ensure_capability_set(&mut conn, tenant_id, webdav_capabilities())
.map_err(|err| anyhow!("ensure webdav capability set: {err:?}"))?;
Ok(tenant_id)
}
pub async fn login_token(&self, username: &str, _password: &str) -> Result<String> {
let (access_token, _, _) = self.create_session(username).await?;
Ok(access_token)
}
pub async fn create_session(&self, username: &str) -> Result<(String, String, Uuid)> {
let username = username.to_string();
let state = self.state.clone();
self.with_conn(move |conn| {
use crate::schema::capability_sets::dsl as capability_sets_dsl;
use crate::schema::tenants::dsl as tenants_dsl;
use crate::schema::user_memberships::dsl as memberships_dsl;
use crate::schema::users::dsl as users_dsl;
let user: User = users_dsl::users
.filter(users_dsl::username.eq(&username))
.first(conn)?;
let membership: UserMembership = memberships_dsl::user_memberships
.filter(memberships_dsl::user_id.eq(user.id))
.first(conn)?;
let tenant: Tenant = tenants_dsl::tenants
.find(membership.tenant_id)
.first(conn)?;
let capability_set_id = membership
.capability_set_id
.ok_or_else(|| anyhow!("membership missing capability set"))?;
let cap_version = capability_sets_dsl::capability_sets
.find(capability_set_id)
.select(capability_sets_dsl::cap_version)
.first::<i32>(conn)?;
let now = Utc::now();
let session_id = Uuid::new_v4();
let access_token = state
.jwt
.generate_token(AccessTokenContext {
user_id: user.id,
tenant_id: tenant.id,
username: user.username.clone(),
principal_kind: PrincipalKind::UserSession,
principal_id: session_id,
capability_set_id,
cap_version,
})
.map_err(|err| anyhow!(err))?;
let session_value = generate_session_token();
let session_hash = hash_session_token(&session_value);
let refresh_expires_at =
now + ChronoDuration::days(state.config.refresh_token_expiry_days);
let new_session = NewUserSession {
id: session_id,
user_id: user.id,
token_hash: session_hash,
issued_at: now.naive_utc(),
expires_at: refresh_expires_at.naive_utc(),
tenant_id: tenant.id,
};
diesel::insert_into(session_dsl::user_sessions)
.values(&new_session)
.execute(conn)?;
let cookie = format!("refresh_token={session_value}");
Ok((access_token, cookie, tenant.id))
})
.await
}
pub async fn clear_jobs(&self) -> Result<()> {
self.with_conn(|conn| {
use crate::schema::jobs::dsl::jobs as jobs_table;
diesel::delete(jobs_table)
.execute(conn)
.context("failed to clear jobs")?;
Ok(())
})
.await
}
pub async fn jobs_by_type(&self, ty: &str) -> Result<Vec<Job>> {
let ty = ty.to_string();
self.with_conn(move |conn| {
use crate::schema::jobs::dsl::{job_type as job_type_col, jobs as jobs_table};
let rows = jobs_table
.filter(job_type_col.eq(&ty))
.load::<Job>(conn)
.context("failed to load jobs")?;
Ok(rows)
})
.await
}
pub async fn post_json<T: Serialize + ?Sized>(
&self,
path: &str,
payload: &T,
token: Option<&str>,
) -> Result<hyper::Response<Body>> {
self.post_json_with_cookie(path, payload, token, None).await
}
pub async fn post_json_with_cookie<T: Serialize + ?Sized>(
&self,
path: &str,
payload: &T,
token: Option<&str>,
cookie: Option<&str>,
) -> Result<hyper::Response<Body>> {
let body = serde_json::to_vec(payload)?;
let mut builder = Request::builder()
.method(Method::POST)
.uri(path)
.header("content-type", "application/json");
if let Some(token) = token {
builder = builder.header("authorization", format!("Bearer {token}"));
}
if let Some(cookie) = cookie {
builder = builder.header(header::COOKIE, cookie);
}
let request = builder.body(Body::from(body))?;
Ok(self
.router
.clone()
.oneshot(request)
.await
.expect("infallible response"))
}
pub async fn patch_json<T: Serialize + ?Sized>(
&self,
path: &str,
payload: &T,
token: Option<&str>,
) -> Result<hyper::Response<Body>> {
let body = serde_json::to_vec(payload)?;
let mut builder = Request::builder()
.method(Method::PATCH)
.uri(path)
.header("content-type", "application/json");
if let Some(token) = token {
builder = builder.header("authorization", format!("Bearer {token}"));
}
let request = builder.body(Body::from(body))?;
Ok(self
.router
.clone()
.oneshot(request)
.await
.expect("infallible response"))
}
pub async fn get(&self, path: &str, token: Option<&str>) -> Result<hyper::Response<Body>> {
let mut builder = Request::builder().method(Method::GET).uri(path);
if let Some(token) = token {
builder = builder.header("authorization", format!("Bearer {token}"));
}
let request = builder.body(Body::empty())?;
Ok(self
.router
.clone()
.oneshot(request)
.await
.expect("infallible response"))
}
pub async fn delete(&self, path: &str, token: Option<&str>) -> Result<hyper::Response<Body>> {
let builder = Request::builder().method(Method::DELETE).uri(path);
let builder = if let Some(token) = token {
builder.header("authorization", format!("Bearer {token}"))
} else {
builder
};
let request = builder.body(Body::empty())?;
Ok(self
.router
.clone()
.oneshot(request)
.await
.expect("infallible response"))
}
pub async fn upload_document(
&self,
path: &str,
filename: &str,
content_type: &str,
data: &[u8],
folder_id: Option<Uuid>,
token: &str,
) -> Result<hyper::Response<Body>> {
let extras = UploadExtras::empty();
self.upload_document_with_extras(
path,
filename,
content_type,
data,
folder_id,
extras,
token,
)
.await
}
pub async fn upload_document_with_options(
&self,
path: &str,
filename: &str,
content_type: &str,
data: &[u8],
folder_id: Option<Uuid>,
title: Option<&str>,
metadata_json: Option<&str>,
token: &str,
) -> Result<hyper::Response<Body>> {
let extras = UploadExtras {
title,
metadata_json,
tag_ids_json: None,
correspondents_json: None,
issued_at: None,
skip_existing: None,
};
self.upload_document_with_extras(
path,
filename,
content_type,
data,
folder_id,
extras,
token,
)
.await
}
pub async fn upload_document_with_extras(
&self,
path: &str,
filename: &str,
content_type: &str,
data: &[u8],
folder_id: Option<Uuid>,
extras: UploadExtras<'_>,
token: &str,
) -> Result<hyper::Response<Body>> {
let boundary = format!("boundary-{}", Uuid::new_v4());
let mut body = Vec::new();
body.extend(format!("--{boundary}\r\n").as_bytes());
body.extend(
format!(
"Content-Disposition: form-data; name=\"file\"; filename=\"{}\"\r\n",
filename
)
.as_bytes(),
);
body.extend(format!("Content-Type: {}\r\n\r\n", content_type).as_bytes());
body.extend(data);
body.extend(b"\r\n");
if let Some(folder) = folder_id {
body.extend(format!("--{boundary}\r\n").as_bytes());
body.extend(b"Content-Disposition: form-data; name=\"folder_id\"\r\n\r\n");
body.extend(folder.to_string().as_bytes());
body.extend(b"\r\n");
}
if let Some(title_value) = extras.title {
body.extend(format!("--{boundary}\r\n").as_bytes());
body.extend(b"Content-Disposition: form-data; name=\"title\"\r\n\r\n");
body.extend(title_value.as_bytes());
body.extend(b"\r\n");
}
if let Some(metadata_value) = extras.metadata_json {
body.extend(format!("--{boundary}\r\n").as_bytes());
body.extend(b"Content-Disposition: form-data; name=\"metadata\"\r\n\r\n");
body.extend(metadata_value.as_bytes());
body.extend(b"\r\n");
}
if let Some(tag_ids_value) = extras.tag_ids_json {
body.extend(format!("--{boundary}\r\n").as_bytes());
body.extend(b"Content-Disposition: form-data; name=\"tag_ids\"\r\n\r\n");
body.extend(tag_ids_value.as_bytes());
body.extend(b"\r\n");
}
if let Some(correspondents_value) = extras.correspondents_json {
body.extend(format!("--{boundary}\r\n").as_bytes());
body.extend(b"Content-Disposition: form-data; name=\"correspondents\"\r\n\r\n");
body.extend(correspondents_value.as_bytes());
body.extend(b"\r\n");
}
if let Some(issued_at_value) = extras.issued_at {
body.extend(format!("--{boundary}\r\n").as_bytes());
body.extend(b"Content-Disposition: form-data; name=\"issued_at\"\r\n\r\n");
body.extend(issued_at_value.as_bytes());
body.extend(b"\r\n");
}
if let Some(skip_flag) = extras.skip_existing {
body.extend(format!("--{boundary}\r\n").as_bytes());
body.extend(b"Content-Disposition: form-data; name=\"skip_existing\"\r\n\r\n");
body.extend(if skip_flag {
b"true".as_ref()
} else {
b"false".as_ref()
});
body.extend(b"\r\n");
}
body.extend(format!("--{boundary}--\r\n").as_bytes());
let builder = Request::builder()
.method(Method::POST)
.uri(path)
.header(
"content-type",
format!("multipart/form-data; boundary={boundary}"),
)
.header("authorization", format!("Bearer {token}"));
let request = builder.body(Body::from(body))?;
Ok(self
.router
.clone()
.oneshot(request)
.await
.expect("infallible response"))
}
pub async fn with_conn<F, T>(&self, f: F) -> Result<T>
where
F: FnOnce(&mut PgConnection) -> Result<T> + Send + 'static,
T: Send + 'static,
{
let pool = self.state.pool.clone();
tokio::task::spawn_blocking(move || {
let mut conn = pool
.get()
.map_err(|err| anyhow!("failed to get database connection: {err}"))?;
f(&mut conn)
})
.await
.context("connection task panicked")?
}
}
pub struct UploadExtras<'a> {
pub title: Option<&'a str>,
pub metadata_json: Option<&'a str>,
pub tag_ids_json: Option<&'a str>,
pub correspondents_json: Option<&'a str>,
pub issued_at: Option<&'a str>,
pub skip_existing: Option<bool>,
}
impl<'a> UploadExtras<'a> {
pub fn empty() -> Self {
Self {
title: None,
metadata_json: None,
tag_ids_json: None,
correspondents_json: None,
issued_at: None,
skip_existing: None,
}
}
}
pub async fn acquire_db_lock() -> tokio::sync::MutexGuard<'static, ()> {
DB_LOCK.lock().await
}
pub async fn body_to_vec(body: Body) -> Result<Vec<u8>> {
let collected = body
.collect()
.await
.map_err(|err| anyhow!("failed to read response body: {err}"))?;
Ok(collected.to_bytes().to_vec())
}
#[cfg(test)]
mod helper_tests {
use super::*;
#[tokio::test]
async fn create_session_and_login_token_provide_access() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let username = "helper-login";
let password = "irrelevant";
app.insert_user(username, TestUserRole::Owner).await?;
let (access, refresh, refresh_id) = app.create_session(username).await?;
assert!(!access.is_empty(), "access token should not be empty");
assert!(!refresh.is_empty(), "refresh token should not be empty");
assert_ne!(
refresh_id,
Uuid::nil(),
"refresh token id should be assigned"
);
let bearer = app.login_token(username, password).await?;
assert!(!bearer.is_empty(), "login_token must yield bearer");
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn insert_passkey_and_upload_with_options_succeeds() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let username = "helper-passkey";
let password = "unused";
let user_id = app.insert_user(username, TestUserRole::Owner).await?;
let passkey_id = app.insert_passkey(user_id, Some("Laptop")).await?;
assert_ne!(passkey_id, Uuid::nil());
let bearer = app.login_token(username, password).await?;
let response = app
.upload_document_with_options(
"/api/documents",
"helper.txt",
"text/plain",
b"helper-content",
None,
Some("Helper Note"),
Some("{\"category\":\"note\"}"),
&bearer,
)
.await?;
assert!(response.status().is_success());
app.cleanup().await?;
Ok(())
}
}
async fn prepare_database(pool: &PgPool) -> Result<()> {
let pool = pool.clone();
tokio::task::spawn_blocking(move || -> Result<()> {
let mut conn = pool
.get()
.map_err(|err| anyhow!("failed to acquire connection: {err}"))?;
conn.batch_execute(RESET_DATABASE_SQL)
.map_err(|err| anyhow!("failed to reset schema: {err}"))?;
conn.batch_execute("DROP TABLE IF EXISTS __diesel_schema_migrations;")
.map_err(|err| anyhow!("failed to drop diesel schema table: {err}"))?;
conn.run_pending_migrations(MIGRATIONS)
.map_err(|err| anyhow!("failed to run migrations: {err}"))?;
truncate_all(&mut conn)?;
Ok(())
})
.await
.context("migration task panicked")?
}
fn truncate_all(conn: &mut PgConnection) -> Result<()> {
conn.batch_execute(
"TRUNCATE TABLE \
tenant.document_assets, \
tenant.document_correspondents, \
tenant.correspondents, \
tenant.document_tags, \
tenant.document_versions, \
tenant.documents, \
tenant.folders, \
shared.jobs, \
tenant.user_sessions, \
tenant.tags, \
tenant.api_tokens, \
shared.webauthn_challenges, \
shared.user_passkeys, \
tenant.user_memberships, \
shared.users, \
shared.magic_tokens, \
shared.tenants \
RESTART IDENTITY CASCADE;",
)
.context("failed to truncate tables")?;
Ok(())
}
fn generate_session_token() -> String {
let mut bytes = [0u8; 32];
OsRng
.try_fill_bytes(&mut bytes)
.expect("failed to read random bytes");
hex::encode(bytes)
}
fn hash_session_token(value: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(value.as_bytes());
hex::encode(hasher.finalize())
}
+1 -34
View File
@@ -1,4 +1,4 @@
use diesel::{pg::PgConnection, result::Error as DieselError}; use diesel::pg::PgConnection;
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
@@ -6,25 +6,6 @@ use crate::{
state::AppState, state::AppState,
}; };
pub trait EnsureEntity<T> {
fn one(self) -> AppResult<T>;
fn maybe(self) -> AppResult<Option<T>>;
}
impl<T> EnsureEntity<T> for Result<T, DieselError> {
fn one(self) -> AppResult<T> {
self.map_err(AppError::from)
}
fn maybe(self) -> AppResult<Option<T>> {
match self {
Ok(value) => Ok(Some(value)),
Err(DieselError::NotFound) => Ok(None),
Err(err) => Err(AppError::from(err)),
}
}
}
impl AppState { impl AppState {
pub fn with_tenant_conn<F, T>(&self, tenant_id: Uuid, f: F) -> AppResult<T> pub fn with_tenant_conn<F, T>(&self, tenant_id: Uuid, f: F) -> AppResult<T>
where where
@@ -43,17 +24,3 @@ pub fn validate_bulk_ids(ids: &mut Vec<Uuid>, label: &str) -> AppResult<()> {
ids.dedup(); ids.dedup();
Ok(()) Ok(())
} }
pub trait IntoJsonResponse<T> {
fn into_json(self) -> AppResult<axum::Json<T>>;
}
impl<T> IntoJsonResponse<T> for T {
fn into_json(self) -> AppResult<axum::Json<T>> {
Ok(axum::Json(self))
}
}
pub fn no_content() -> AppResult<axum::http::StatusCode> {
Ok(axum::http::StatusCode::NO_CONTENT)
}
+32 -1
View File
@@ -10,13 +10,44 @@ pub fn inline_content_disposition(filename: &str) -> Option<String> {
.chars() .chars()
.map(|ch| match ch { .map(|ch| match ch {
'"' | '\\' => '_', '"' | '\\' => '_',
c if !c.is_ascii() => '_',
_ => ch, _ => ch,
}) })
.collect(); .collect();
let encoded = utf8_percent_encode(&sanitized, NON_ALPHANUMERIC); let encoded = utf8_percent_encode(filename, NON_ALPHANUMERIC);
Some(format!( Some(format!(
"inline; filename=\"{}\"; filename*=UTF-8''{}", "inline; filename=\"{}\"; filename*=UTF-8''{}",
sanitized, encoded sanitized, encoded
)) ))
} }
#[cfg(test)]
mod tests {
use super::*;
use reqwest::header::HeaderValue;
#[test]
fn test_inline_content_disposition_header_validity() {
// Test with a filename containing non-ASCII characters
let filename = "Täst.pdf";
let disposition = inline_content_disposition(filename).unwrap();
println!("Disposition: {}", disposition);
// This should fail if the sanitized part contains non-ASCII characters
// and we try to create a HeaderValue from it.
let result = HeaderValue::from_str(&disposition);
if let Ok(val) = result {
// Check if to_str succeeds (it should now!)
let to_str_res = val.to_str();
assert!(to_str_res.is_ok(), "HeaderValue::to_str should succeed for sanitized filename");
let disposition_str = to_str_res.unwrap();
assert!(disposition_str.contains("filename=\"T_st.pdf\""), "Filename should be sanitized");
assert!(disposition_str.contains("filename*=UTF-8''T%C3%A4st%2Epdf"), "UTF-8 filename should be preserved");
} else {
panic!("HeaderValue rejected the string: {:?}", result.err());
}
}
}
+2
View File
@@ -4,7 +4,9 @@ pub mod error;
pub mod http; pub mod http;
pub mod json; pub mod json;
pub mod named_entity; pub mod named_entity;
pub mod setops;
pub mod storage_paths; pub mod storage_paths;
pub mod text;
pub mod time; pub mod time;
pub mod tracing; pub mod tracing;
pub mod validation; pub mod validation;
+41
View File
@@ -0,0 +1,41 @@
use std::collections::HashSet;
use std::hash::Hash;
use uuid::Uuid;
use crate::error::AppResult;
use crate::state::PgPooledConnection;
/// Intersect an optional base set with a new set, returning the resulting option.
pub fn intersect_option_sets<T>(base: Option<HashSet<T>>, next: HashSet<T>) -> Option<HashSet<T>>
where
T: Eq + Hash + Copy,
{
Some(match base {
Some(existing) => existing.intersection(&next).copied().collect(),
None => next,
})
}
/// Iteratively intersect documents linked via a join table loader.
pub fn load_linked_doc_ids<F>(
conn: &mut PgPooledConnection,
ids: &[Uuid],
mut loader: F,
) -> AppResult<HashSet<Uuid>>
where
F: FnMut(&mut PgPooledConnection, Uuid) -> AppResult<HashSet<Uuid>>,
{
let mut current: Option<HashSet<Uuid>> = None;
for id in ids {
let docs_set = loader(conn, *id)?;
current = intersect_option_sets(current, docs_set);
if current.as_ref().is_some_and(|set| set.is_empty()) {
break;
}
}
Ok(current.unwrap_or_default())
}
+4 -24
View File
@@ -48,8 +48,8 @@ fn document_asset_type_prefix(document_id: Uuid, version_number: i32, asset_type
) )
} }
/// Returns the storage prefix under which the asset objects for a type/id pair live. /// Returns the storage key for an asset (single object).
pub fn document_asset_object_prefix( pub fn document_asset_key(
document_id: Uuid, document_id: Uuid,
version_number: i32, version_number: i32,
asset_type: &str, asset_type: &str,
@@ -62,21 +62,6 @@ pub fn document_asset_object_prefix(
) )
} }
/// Returns the full storage key for a specific asset object (ordinal).
pub fn document_asset_object_key(
document_id: Uuid,
version_number: i32,
asset_type: &str,
asset_id: Uuid,
ordinal: i32,
) -> String {
format!(
"{}/{}",
document_asset_object_prefix(document_id, version_number, asset_type, asset_id),
ordinal
)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -103,13 +88,8 @@ mod tests {
); );
assert_eq!( assert_eq!(
document_asset_object_prefix(document_id, 3, "preview", asset_id), document_asset_key(document_id, 3, "thumbnail", asset_id),
format!("documents/{document_id}/v3/assets/preview/{asset_id}") format!("documents/{document_id}/v3/assets/thumbnail/{asset_id}")
);
assert_eq!(
document_asset_object_key(document_id, 3, "preview", asset_id, 2),
format!("documents/{document_id}/v3/assets/preview/{asset_id}/2")
); );
} }
} }
+31
View File
@@ -0,0 +1,31 @@
use crate::error::{AppError, AppResult};
/// Normalizes an identifier-like user input by trimming, enforcing length, and validating characters.
pub fn normalize_identifier<F>(
value: &str,
max_len: usize,
empty_message: &str,
length_message: &str,
invalid_message: Option<&str>,
mut validator: F,
) -> AppResult<String>
where
F: FnMut(char) -> bool,
{
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(AppError::bad_request(empty_message));
}
if trimmed.len() > max_len {
return Err(AppError::bad_request(length_message));
}
if let Some(msg) = invalid_message {
if !trimmed.chars().all(|ch| validator(ch)) {
return Err(AppError::bad_request(msg));
}
}
Ok(trimmed.to_string())
}
+1 -1
View File
@@ -8,7 +8,7 @@ pub fn init_tracing(default_level: &str) {
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default_level)); EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default_level));
tracing_subscriber::fmt() tracing_subscriber::fmt()
.with_env_filter(filter) .with_env_filter(filter)
.with_target(false) .with_target(true)
.compact() .compact()
.init(); .init();
} }
+205 -103
View File
@@ -2,22 +2,28 @@ use std::{collections::HashSet, sync::Arc, time::Duration};
use async_trait::async_trait; use async_trait::async_trait;
use diesel::prelude::*; use diesel::prelude::*;
use infer;
use serde::Deserialize; use serde::Deserialize;
use serde_json::json;
use tokio::task; use tokio::task;
use tracing::{error, warn};
use uuid::Uuid; use uuid::Uuid;
use super::ocr::{document_is_pdf, OCR_TEXT_ASSET_TYPE};
use crate::{ use crate::{
jobs::{enqueue_job, JOB_ANALYZE_DOCUMENT, JOB_GENERATE_OCR_TEXT, JOB_GENERATE_THUMBNAILS}, auth::ensure_active_tenant, jobs::JOB_ANALYZE_DOCUMENT, models::Document, state::AppState,
models::{Document, DocumentAsset, DocumentVersion},
schema::{document_assets, document_versions, documents},
state::AppState,
storage::TenantStorage, storage::TenantStorage,
}; };
use super::{JobExecution, JobHandler}; use super::{
index::IndexDocumentTask,
issued_at::DetermineIssuedAtTask,
job_execution_from_task_error,
ocr::{GenerateOcrTask, TEXT_CONTENT_ASSET_TYPE},
taskflow::{
document::DocumentVersionTaskContext, BoxedTask, Task, TaskError, TaskExecutor,
TaskPlanner, TaskResult,
},
thumbnails::GenerateThumbnailsTask,
JobExecution, JobHandler,
};
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct AnalyzePayload { struct AnalyzePayload {
@@ -45,8 +51,23 @@ impl JobHandler for AnalyzeDocumentJob {
&self, &self,
state: Arc<AppState>, state: Arc<AppState>,
job: crate::models::Job, job: crate::models::Job,
_storage: TenantStorage, storage: TenantStorage,
) -> JobExecution { ) -> JobExecution {
let tenant_id = match job.tenant_id {
Some(id) => id,
None => {
return JobExecution::Failed {
error: "job is no longer associated with a tenant".to_string(),
}
}
};
if let Err(err) = ensure_active_tenant(&state, tenant_id) {
return JobExecution::Failed {
error: err.to_string(),
};
}
let payload: AnalyzePayload = match serde_json::from_value(job.payload.clone()) { let payload: AnalyzePayload = match serde_json::from_value(job.payload.clone()) {
Ok(payload) => payload, Ok(payload) => payload,
Err(err) => { Err(err) => {
@@ -56,114 +77,155 @@ impl JobHandler for AnalyzeDocumentJob {
} }
}; };
let state_clone = state.clone(); let mut context = DocumentVersionTaskContext::new(
let tenant_id = job.tenant_id; job.id,
match task::spawn_blocking(move || analyze_document(state_clone, tenant_id, payload)).await JOB_ANALYZE_DOCUMENT,
{ tenant_id,
Ok(Ok(execution)) => execution, payload.document_id,
Ok(Err(err)) => { payload.document_version_id,
warn!(job_id = %job.id, error = %err, "analyze job will retry"); payload.force,
JobExecution::Retry { state.config.worker_max_document_bytes,
delay: Duration::from_secs(30), state.clone(),
error: err, storage,
} );
}
Err(join_err) => { let planner = AnalyzePlanner::new(payload.force, state.clone());
error!(job_id = %job.id, error = %join_err, "analyze task panicked"); match TaskExecutor::run(&planner, &mut context).await {
JobExecution::Retry { Ok(()) => JobExecution::Success,
delay: Duration::from_secs(60), Err(err) => job_execution_from_task_error(err),
error: format!("worker panicked: {join_err}"),
}
}
} }
} }
} }
fn analyze_document( struct AnalyzePlanner {
force: bool,
state: Arc<AppState>, state: Arc<AppState>,
tenant_id: Uuid, }
payload: AnalyzePayload,
) -> Result<JobExecution, String> {
let mut conn = state
.db_for_tenant(tenant_id)
.map_err(|err| format!("{err:?}"))?;
let version: DocumentVersion = document_versions::table const MIME_SNIFF_BYTES: usize = 8192;
.find(payload.document_version_id)
.first(&mut conn)
.map_err(|err| {
format!(
"failed to load document_version {} for tenant {}: {err:?}",
payload.document_version_id, tenant_id
)
})?;
if version.document_id != payload.document_id { impl AnalyzePlanner {
return Err("document/version mismatch".into()); fn new(force: bool, state: Arc<AppState>) -> Self {
Self { force, state }
} }
}
let document: Document = documents::table #[async_trait]
.find(payload.document_id) impl TaskPlanner<DocumentVersionTaskContext> for AnalyzePlanner {
.first(&mut conn) async fn plan(
.map_err(|err| { &self,
format!( ctx: &mut DocumentVersionTaskContext,
"failed to load document {} for tenant {}: {err:?}", ) -> TaskResult<Vec<BoxedTask<DocumentVersionTaskContext>>> {
payload.document_id, tenant_id let document = ctx.document().await?.clone();
) let mut tasks: Vec<BoxedTask<DocumentVersionTaskContext>> = Vec::new();
})?;
let tenant_id = document.tenant_id; tasks.push(Box::new(EnsureMimeTask));
let (supported, _reason) = determine_thumbnail_support(&document); let (thumbnail_supported, _) = determine_thumbnail_support(&document);
let ocr_supported = document_is_pdf(&document); if thumbnail_supported {
tasks.push(Box::new(GenerateThumbnailsTask::new(self.force)));
let existing_ocr: Option<DocumentAsset> = document_assets::table
.filter(document_assets::document_version_id.eq(payload.document_version_id))
.filter(document_assets::asset_type.eq(OCR_TEXT_ASSET_TYPE))
.filter(document_assets::tenant_id.eq(tenant_id))
.first(&mut conn)
.optional()
.map_err(|err| format!("{err:?}"))?;
let skip_ocr = existing_ocr.is_some() && !payload.force;
if supported {
let enqueue_result = enqueue_job(
&mut conn,
tenant_id,
JOB_GENERATE_THUMBNAILS,
json!({
"document_id": payload.document_id,
"document_version_id": payload.document_version_id,
"force": payload.force,
}),
None,
);
if let Err(err) = enqueue_result {
return Err(err.to_string());
} }
}
if ocr_supported && !skip_ocr { let existing_ocr = ctx.asset(TEXT_CONTENT_ASSET_TYPE).await?.is_some();
let enqueue_result = enqueue_job( let mut should_index = existing_ocr;
&mut conn,
tenant_id,
JOB_GENERATE_OCR_TEXT,
json!({
"document_id": payload.document_id,
"document_version_id": payload.document_version_id,
"force": payload.force,
}),
None,
);
if let Err(err) = enqueue_result { if document_supports_ocr(&document) {
return Err(err.to_string()); if self.force || !existing_ocr {
tasks.push(Box::new(GenerateOcrTask::new(
self.force,
self.state.clone(),
)));
should_index = true;
}
} }
tasks.push(Box::new(DetermineIssuedAtTask::new()));
if should_index {
tasks.push(Box::new(IndexDocumentTask::new()));
}
Ok(tasks)
}
}
struct EnsureMimeTask;
#[async_trait]
impl Task<DocumentVersionTaskContext> for EnsureMimeTask {
fn name(&self) -> &'static str {
"ensure-mime-type"
} }
Ok(JobExecution::Success) async fn execute(&self, ctx: &mut DocumentVersionTaskContext) -> TaskResult<()> {
let document = ctx.document().await?.clone();
let current = document.mime_type.clone();
let guessed = guess_mime_type(ctx, &document).await?;
let desired = match guessed {
Some(mime) if current.as_deref() != Some(mime.as_str()) => Some(mime),
_ => None,
};
if let Some(new_mime) = desired {
update_document_mime(ctx, document.id, new_mime).await?;
}
Ok(())
}
}
async fn guess_mime_type(
ctx: &mut DocumentVersionTaskContext,
document: &Document,
) -> TaskResult<Option<String>> {
let bytes = ctx.object_head(MIME_SNIFF_BYTES).await?;
Ok(sniff_mime(&bytes, &document.original_name))
}
fn sniff_mime(bytes: &[u8], original_name: &str) -> Option<String> {
if let Some(kind) = infer::get(bytes) {
return Some(kind.mime_type().to_string());
}
mime_guess::from_path(original_name)
.first_raw()
.map(|value| value.to_string())
}
async fn update_document_mime(
ctx: &mut DocumentVersionTaskContext,
document_id: Uuid,
mime_type: String,
) -> TaskResult<()> {
let tenant_id = ctx.tenant_id();
let state = ctx.state().clone();
let mime_type_clone = mime_type.clone();
task::spawn_blocking(move || -> Result<(), String> {
let mut conn = state
.db_for_tenant(tenant_id)
.map_err(|err| format!("{err:?}"))?;
diesel::update(
crate::schema::documents::table.filter(crate::schema::documents::id.eq(document_id)),
)
.set(crate::schema::documents::mime_type.eq(Some(mime_type_clone)))
.execute(&mut conn)
.map_err(|err| format!("{err:?}"))
.map(|_| ())
})
.await
.map_err(|err| {
TaskError::retry(
Duration::from_secs(60),
format!("mime update task panicked: {err}"),
)
})?
.map_err(|err| TaskError::retry(Duration::from_secs(30), err))?;
ctx.set_document_mime(Some(mime_type));
Ok(())
} }
pub(crate) fn determine_thumbnail_support(document: &Document) -> (bool, Option<String>) { pub(crate) fn determine_thumbnail_support(document: &Document) -> (bool, Option<String>) {
@@ -175,12 +237,18 @@ pub(crate) fn determine_thumbnail_support(document: &Document) -> (bool, Option<
"image/bmp", "image/bmp",
"image/webp", "image/webp",
"application/pdf", "application/pdf",
"video/mp4",
"video/quicktime",
"video/webm",
"video/x-msvideo",
"video/x-ms-wmv",
"video/x-matroska",
] ]
.into_iter() .into_iter()
.collect(); .collect();
if let Some(ref content_type) = document.content_type { if let Some(ref mime_type) = document.mime_type {
if supported_mimes.contains(content_type.as_str()) { if supported_mimes.contains(mime_type.as_str()) {
return (true, None); return (true, None);
} }
} }
@@ -192,7 +260,8 @@ pub(crate) fn determine_thumbnail_support(document: &Document) -> (bool, Option<
.map(|ext| ext.to_ascii_lowercase()) .map(|ext| ext.to_ascii_lowercase())
{ {
let supported_exts = [ let supported_exts = [
"jpg", "jpeg", "png", "gif", "tif", "tiff", "bmp", "webp", "pdf", "jpg", "jpeg", "png", "gif", "tif", "tiff", "bmp", "webp", "pdf", "mp4", "m4v", "mov",
"webm", "mkv", "avi", "wmv",
]; ];
if supported_exts.contains(&ext.as_str()) { if supported_exts.contains(&ext.as_str()) {
return (true, None); return (true, None);
@@ -204,3 +273,36 @@ pub(crate) fn determine_thumbnail_support(document: &Document) -> (bool, Option<
Some("content type not supported for thumbnails".into()), Some("content type not supported for thumbnails".into()),
) )
} }
fn document_supports_ocr(document: &Document) -> bool {
document
.mime_type
.as_deref()
.map(|mime| mime.eq_ignore_ascii_case("application/pdf"))
.unwrap_or_else(|| {
document
.original_name
.rsplit('.')
.next()
.map(|ext| ext.eq_ignore_ascii_case("pdf"))
.unwrap_or(false)
})
}
#[cfg(test)]
mod tests {
use super::sniff_mime;
#[test]
fn sniff_mime_prefers_magic_bytes() {
const PNG_HEADER: [u8; 8] = [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
let mime = sniff_mime(&PNG_HEADER, "file.txt");
assert_eq!(mime.as_deref(), Some("image/png"));
}
#[test]
fn sniff_mime_falls_back_to_extension() {
let mime = sniff_mime(b"not enough to detect", "video.mp4");
assert_eq!(mime.as_deref(), Some("video/mp4"));
}
}
+7 -30
View File
@@ -3,14 +3,13 @@ use std::collections::HashMap;
use diesel::{prelude::*, PgConnection}; use diesel::{prelude::*, PgConnection};
use uuid::Uuid; use uuid::Uuid;
use crate::models::{Document, DocumentAsset, DocumentAssetObject, DocumentVersion}; use crate::models::{Document, DocumentAsset, DocumentVersion};
use crate::schema::{document_asset_objects, document_assets, document_versions, documents}; use crate::schema::{document_assets, document_versions, documents};
use crate::state::AppState; use crate::state::AppState;
pub(crate) struct DocumentVersionContext { pub(crate) struct LoadedDocumentVersion {
pub document: Document, pub document: Document,
pub version: DocumentVersion, pub version: DocumentVersion,
pub tenant_id: Uuid,
} }
pub(crate) fn load_document_version( pub(crate) fn load_document_version(
@@ -18,7 +17,7 @@ pub(crate) fn load_document_version(
tenant_id: Uuid, tenant_id: Uuid,
document_id: Uuid, document_id: Uuid,
version_id: Uuid, version_id: Uuid,
) -> Result<DocumentVersionContext, String> { ) -> Result<LoadedDocumentVersion, String> {
let mut conn = state let mut conn = state
.db_for_tenant(tenant_id) .db_for_tenant(tenant_id)
.map_err(|err| format!("{err:?}"))?; .map_err(|err| format!("{err:?}"))?;
@@ -37,16 +36,11 @@ pub(crate) fn load_document_version(
.first(&mut conn) .first(&mut conn)
.map_err(|err| format!("{err:?}"))?; .map_err(|err| format!("{err:?}"))?;
Ok(DocumentVersionContext { Ok(LoadedDocumentVersion { document, version })
document,
version,
tenant_id,
})
} }
pub(crate) struct LoadedAsset { pub struct LoadedAsset {
pub asset: DocumentAsset, pub asset: DocumentAsset,
pub objects: Vec<DocumentAssetObject>,
} }
pub(crate) fn load_version_assets( pub(crate) fn load_version_assets(
@@ -70,26 +64,9 @@ pub(crate) fn load_version_assets(
.load(conn) .load(conn)
.map_err(|err| format!("{err:?}"))?; .map_err(|err| format!("{err:?}"))?;
let asset_ids: Vec<Uuid> = assets.iter().map(|asset| asset.id).collect();
let mut object_map: HashMap<Uuid, Vec<DocumentAssetObject>> = HashMap::new();
if !asset_ids.is_empty() {
let objects: Vec<DocumentAssetObject> = document_asset_objects::table
.filter(document_asset_objects::asset_id.eq_any(&asset_ids))
.filter(document_asset_objects::tenant_id.eq(tenant_id))
.order(document_asset_objects::ordinal.asc())
.load(conn)
.map_err(|err| format!("{err:?}"))?;
for object in objects {
object_map.entry(object.asset_id).or_default().push(object);
}
}
let mut result = HashMap::with_capacity(assets.len()); let mut result = HashMap::with_capacity(assets.len());
for asset in assets { for asset in assets {
let objects = object_map.remove(&asset.id).unwrap_or_default(); result.insert(asset.asset_type.clone(), LoadedAsset { asset });
result.insert(asset.asset_type.clone(), LoadedAsset { asset, objects });
} }
Ok(result) Ok(result)
+49 -183
View File
@@ -1,213 +1,79 @@
use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use async_trait::async_trait; use async_trait::async_trait;
use diesel::prelude::*;
use reqwest::Client; use reqwest::Client;
use serde::Deserialize;
use tokio::task;
use tracing::{error, warn};
use uuid::Uuid;
use crate::{ use crate::documents::search::{build_quickwit_ingest_record, quickwit_ingest};
documents::search::{build_quickwit_ingest_record, quickwit_ingest},
jobs::JOB_INDEX_DOCUMENT_TEXT,
models::{Document, DocumentVersion},
schema::{document_asset_objects, document_assets, document_versions, documents},
state::AppState,
storage::TenantStorage,
};
use super::{ use super::{
fetch_version_object, handle_fetch_error, ocr::OCR_TEXT_ASSET_TYPE, JobExecution, JobHandler, ocr::TEXT_CONTENT_ASSET_TYPE,
taskflow::{document::DocumentVersionTaskContext, Task, TaskError, TaskResult},
}; };
#[derive(Debug, Deserialize)] pub struct IndexDocumentTask;
struct IndexPayload {
document_id: Uuid,
document_version_id: Uuid,
}
pub struct IndexDocumentTextJob; impl IndexDocumentTask {
impl IndexDocumentTextJob {
pub fn new() -> Self { pub fn new() -> Self {
Self Self
} }
} }
#[async_trait] #[async_trait]
impl JobHandler for IndexDocumentTextJob { impl Task<DocumentVersionTaskContext> for IndexDocumentTask {
fn job_type(&self) -> &'static str { fn name(&self) -> &'static str {
JOB_INDEX_DOCUMENT_TEXT "index-document-text"
} }
async fn handle( async fn execute(&self, ctx: &mut DocumentVersionTaskContext) -> TaskResult<()> {
&self, let state = ctx.state().clone();
state: Arc<AppState>, let quickwit_endpoint = state
job: crate::models::Job, .config
storage: TenantStorage, .quickwit_endpoint
) -> JobExecution { .clone()
let payload: IndexPayload = match serde_json::from_value(job.payload.clone()) { .ok_or_else(|| TaskError::fail("quickwit endpoint missing"))?;
Ok(payload) => payload,
Err(err) => {
return JobExecution::Failed {
error: format!("invalid index payload: {err}"),
}
}
};
let quickwit_endpoint = match &state.config.quickwit_endpoint { let tenant = state.tenants.get_by_id(ctx.tenant_id()).map_err(|err| {
Some(endpoint) => endpoint.clone(), TaskError::retry(
None => { Duration::from_secs(30),
return JobExecution::Failed { format!("failed to load tenant: {err:?}"),
error: "quickwit endpoint missing".into(), )
}; })?;
}
};
let tenant = match state.tenants.get_by_id(job.tenant_id) { let quickwit_index = tenant
Ok(tenant) => tenant, .quickwit_index
Err(err) => { .clone()
warn!(job_id = %job.id, error = ?err, "failed to load tenant for indexing"); .ok_or_else(|| TaskError::fail("tenant quickwit index not configured"))?;
return JobExecution::Retry {
delay: Duration::from_secs(30),
error: format!("failed to load tenant: {err:?}"),
};
}
};
let quickwit_index = match tenant.quickwit_index.clone() { let asset = ctx
Some(index) => index, .asset(TEXT_CONTENT_ASSET_TYPE)
None => { .await?
return JobExecution::Failed { .ok_or_else(|| TaskError::fail("missing OCR text asset"))?;
error: "tenant quickwit index not configured".into(),
};
}
};
let client = Client::new(); let s3_key = asset.asset.s3_key.clone();
let bytes = ctx.storage().get_object(&s3_key).await.map_err(|err| {
TaskError::retry(
Duration::from_secs(30),
format!("failed to download ocr text: {err}"),
)
})?;
let state_clone = state.clone(); let text = String::from_utf8(bytes)
let tenant_id = job.tenant_id; .map_err(|err| TaskError::fail(format!("ocr text not valid UTF-8: {err}")))?;
let context =
match task::spawn_blocking(move || load_context(state_clone, tenant_id, payload)).await
{
Ok(Ok(ctx)) => ctx,
Ok(Err(err)) => {
warn!(job_id = %job.id, error = %err, "index job will retry");
return JobExecution::Retry {
delay: Duration::from_secs(30),
error: err,
};
}
Err(join_err) => {
error!(job_id = %job.id, error = %join_err, "index task panicked");
return JobExecution::Retry {
delay: Duration::from_secs(60),
error: format!("worker panicked: {join_err}"),
};
}
};
if context.text_s3_key.is_none() {
warn!(job_id = %job.id, "missing OCR text asset; failing indexing job");
return JobExecution::Failed {
error: "missing OCR text asset".into(),
};
}
let s3_key = context.text_s3_key.unwrap();
let bytes = match fetch_version_object(
&context.version,
&storage,
&s3_key,
state.config.worker_max_document_bytes,
)
.await
{
Ok(bytes) => bytes,
Err(err) => return handle_fetch_error(&job, err, "failed to download ocr text"),
};
let text = match String::from_utf8(bytes) {
Ok(text) => text,
Err(err) => {
warn!(job_id = %job.id, error = %err, "ocr text not valid UTF-8");
return JobExecution::Failed {
error: "ocr text not valid UTF-8".into(),
};
}
};
if text.trim().is_empty() { if text.trim().is_empty() {
warn!(job_id = %job.id, "ocr text empty; skipping"); return Err(TaskError::fail("ocr text empty"));
return JobExecution::Failed {
error: "ocr text empty".into(),
};
} }
let record = let document = ctx.document().await?.clone();
build_quickwit_ingest_record(&context.document, &context.version, job.tenant_id, &text); let version = ctx.version().await?.clone();
match quickwit_ingest(&client, &quickwit_endpoint, &quickwit_index, &[record]).await { let record = build_quickwit_ingest_record(&document, &version, ctx.tenant_id(), &text);
Ok(()) => JobExecution::Success, let client = Client::new();
Err(err) => {
warn!(job_id = %job.id, error = %err, "quickwit ingest failed"); quickwit_ingest(&client, &quickwit_endpoint, &quickwit_index, &[record])
JobExecution::Retry { .await
delay: Duration::from_secs(30), .map_err(|err| TaskError::retry(Duration::from_secs(30), err.to_string()))?;
error: err.to_string(),
} Ok(())
}
}
} }
} }
struct IndexContext {
document: Document,
version: DocumentVersion,
text_s3_key: Option<String>,
}
fn load_context(
state: Arc<AppState>,
tenant_id: Uuid,
payload: IndexPayload,
) -> Result<IndexContext, String> {
let mut conn = state
.db_for_tenant(tenant_id)
.map_err(|err| format!("{err:?}"))?;
let version: DocumentVersion = document_versions::table
.find(payload.document_version_id)
.first(&mut conn)
.map_err(|err| format!("{err:?}"))?;
if version.document_id != payload.document_id {
return Err("document/version mismatch".into());
}
let document: Document = documents::table
.find(payload.document_id)
.first(&mut conn)
.map_err(|err| format!("{err:?}"))?;
let text_s3_key: Option<String> = document_asset_objects::table
.inner_join(
document_assets::table.on(document_asset_objects::asset_id.eq(document_assets::id)),
)
.filter(document_assets::document_version_id.eq(payload.document_version_id))
.filter(document_assets::asset_type.eq(OCR_TEXT_ASSET_TYPE))
.filter(document_assets::tenant_id.eq(tenant_id))
.filter(document_asset_objects::ordinal.eq(1))
.filter(document_asset_objects::tenant_id.eq(tenant_id))
.select(document_asset_objects::s3_key)
.first(&mut conn)
.optional()
.map_err(|err| format!("{err:?}"))?;
Ok(IndexContext {
document,
version,
text_s3_key,
})
}
+695
View File
@@ -0,0 +1,695 @@
use std::time::Duration;
use async_trait::async_trait;
use chrono::{DateTime, Datelike, NaiveDate, NaiveDateTime, TimeZone, Utc};
use diesel::prelude::*;
use once_cell::sync::Lazy;
use regex::Match;
use regex::Regex;
use serde_json::Value;
use tracing::{info, warn};
use uuid::Uuid;
use crate::issued_at::{DateOrder, IssuedAtSettings};
use crate::schema::documents::dsl as documents_dsl;
use crate::workers::ocr::TEXT_CONTENT_ASSET_TYPE;
use crate::workers::taskflow::document::DocumentVersionTaskContext;
use crate::workers::taskflow::{Task, TaskContext, TaskError, TaskResult};
#[path = "issued_at_months.rs"]
mod issued_at_months;
const MAX_FILENAME_CHARS: usize = 256;
const MAX_TEXT_CHARS: usize = 50_000;
const DATE_SEP_PATTERN: &str = r"[\s._/\-]+";
static YMD_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?u)\b(\d{4})[./-](\d{1,2})[./-](\d{1,2})\b").expect("ymd regex"));
static NUMERIC_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?u)\b(\d{1,2})[./-](\d{1,2})[./-](\d{2,4})\b").expect("numeric regex")
});
static DAY_MONTH_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(&format!(
r"(?u)\b(\d{{1,2}})(?:st|nd|rd|th)?{SEP}({MONTH_PATTERN}){SEP}(\d{{2,4}})\b",
SEP = DATE_SEP_PATTERN,
MONTH_PATTERN = month_pattern()
))
.expect("day month regex")
});
static MONTH_DAY_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(&format!(
r"(?u)\b({MONTH_PATTERN}){SEP}(\d{{1,2}})(?:st|nd|rd|th)?(?:,)?{SEP}(\d{{2,4}})\b",
SEP = DATE_SEP_PATTERN,
MONTH_PATTERN = month_pattern()
))
.expect("month day regex")
});
static MONTH_YEAR_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(&format!(
r"(?u)\b({MONTH_PATTERN})[\s._-]*(\d{{4}})\b",
MONTH_PATTERN = month_pattern()
))
.expect("month year regex")
});
fn month_pattern() -> &'static str {
issued_at_months::pattern()
}
pub struct DetermineIssuedAtTask;
impl DetermineIssuedAtTask {
pub fn new() -> Self {
Self
}
}
#[async_trait]
impl Task<DocumentVersionTaskContext> for DetermineIssuedAtTask {
fn name(&self) -> &'static str {
"determine-issued-at"
}
async fn execute(&self, ctx: &mut DocumentVersionTaskContext) -> TaskResult<()> {
let document = ctx.document().await?.clone();
if document.issued_at.is_some() {
return Ok(());
}
let version = ctx.version().await?.clone();
let settings = ctx.state().issued_at_settings();
let now_utc = Utc::now();
let parser_hint = parser_supplied_date(&document.metadata, &version.metadata, &settings)
.and_then(|dt| settings.normalize_datetime(dt, now_utc));
let filename_candidate = settings.filename_date_order().and_then(|order| {
let normalized = normalize_content(&document.original_name, MAX_FILENAME_CHARS);
find_date_in_text(&normalized, order, &settings, now_utc)
});
let text_candidate = load_document_text(ctx).await?.and_then(|text| {
let normalized = normalize_content(&text, MAX_TEXT_CHARS);
find_date_in_text(&normalized, settings.date_order(), &settings, now_utc)
});
if let Some((final_date, source)) = parser_hint
.map(|dt| (dt, IssuedAtSource::Parser))
.or_else(|| filename_candidate.map(|dt| (dt, IssuedAtSource::Filename)))
.or_else(|| text_candidate.map(|dt| (dt, IssuedAtSource::Text)))
{
persist_issued_at(ctx, document.id, final_date.naive_utc()).await?;
info!(
job_id = %ctx.job_id(),
document_id = %document.id,
issued_at = %final_date,
source = source.as_ref(),
"issued_at determined"
);
} else {
info!(
job_id = %ctx.job_id(),
document_id = %document.id,
"no issued_at signals discovered; leaving unset"
);
}
Ok(())
}
}
async fn persist_issued_at(
ctx: &DocumentVersionTaskContext,
document_id: Uuid,
issued_at: NaiveDateTime,
) -> TaskResult<()> {
let tenant_id = ctx.tenant_id();
let state = ctx.state().clone();
let result = tokio::task::spawn_blocking(move || -> Result<(), String> {
let mut conn = state
.db_for_tenant(tenant_id)
.map_err(|err| format!("failed to scope connection: {err:?}"))?;
diesel::update(
documents_dsl::documents
.filter(documents_dsl::tenant_id.eq(tenant_id))
.filter(documents_dsl::id.eq(document_id)),
)
.set(documents_dsl::issued_at.eq(issued_at))
.execute(&mut conn)
.map_err(|err| format!("failed to update issued_at: {err}"))?;
Ok(())
})
.await
.map_err(|err| {
TaskError::retry(
Duration::from_secs(30),
format!("issued_at task panicked: {err}"),
)
})?;
result.map_err(|err| TaskError::retry(Duration::from_secs(30), err))?;
Ok(())
}
fn parser_supplied_date(
document_meta: &Value,
version_meta: &Value,
settings: &IssuedAtSettings,
) -> Option<DateTime<Utc>> {
metadata_datetime(document_meta)
.or_else(|| metadata_datetime(version_meta))
.and_then(|raw| parse_hint_datetime(raw, settings))
}
fn metadata_datetime(value: &Value) -> Option<&str> {
match value {
Value::String(s) => Some(s.as_str()),
Value::Object(map) => {
for key in [
"issued_at_override",
"issued_at",
"source_date",
"created_at",
] {
if let Some(Value::String(s)) = map.get(key) {
return Some(s.as_str());
}
}
if let Some(Value::Object(parser)) = map.get("parser") {
if let Some(Value::String(s)) = parser.get("issued_at") {
return Some(s.as_str());
}
}
None
}
_ => None,
}
}
fn parse_hint_datetime(raw: &str, settings: &IssuedAtSettings) -> Option<DateTime<Utc>> {
if let Ok(dt) = DateTime::parse_from_rfc3339(raw) {
return Some(dt.with_timezone(&Utc));
}
if let Ok(date) = NaiveDate::parse_from_str(raw, "%Y-%m-%d") {
return settings
.timezone()
.with_ymd_and_hms(date.year(), date.month(), date.day(), 0, 0, 0)
.single()
.map(|dt| dt.with_timezone(&Utc));
}
None
}
fn normalize_content(input: &str, limit: usize) -> String {
let truncated: String = input.chars().take(limit).collect();
let mut normalized = String::with_capacity(truncated.len());
for ch in truncated.chars() {
if ch.is_control() {
normalized.push(' ');
} else {
normalized.extend(ch.to_lowercase());
}
}
normalized
}
fn find_date_in_text(
text: &str,
order: DateOrder,
settings: &IssuedAtSettings,
now_utc: DateTime<Utc>,
) -> Option<DateTime<Utc>> {
collect_matches_with_spans(text, order, settings, now_utc)
.into_iter()
.map(|(_, dt)| dt)
.next()
}
fn collect_matches_with_spans(
text: &str,
order: DateOrder,
settings: &IssuedAtSettings,
now_utc: DateTime<Utc>,
) -> Vec<(usize, DateTime<Utc>)> {
let mut matches: Vec<(usize, usize, DateTime<Utc>)> = Vec::new();
let mut push_date = |span: Option<Match>, date: NaiveDate| {
if let Some(dt) = settings.normalize_naive(date, now_utc) {
if let Some(span) = span {
let start = span.start();
let end = span.end();
if let Some(existing) =
matches
.iter_mut()
.find(|(existing_start, existing_end, _)| {
*existing_start != usize::MAX
&& start < *existing_end
&& *existing_start < end
})
{
let existing_len = existing.1.saturating_sub(existing.0);
let new_len = end.saturating_sub(start);
if new_len > existing_len {
*existing = (start, end, dt);
}
return;
}
matches.push((start, end, dt));
} else {
matches.push((usize::MAX, usize::MAX, dt));
}
}
};
for caps in YMD_RE.captures_iter(text) {
let (Some(year_match), Some(month_match), Some(day_match)) =
(caps.get(1), caps.get(2), caps.get(3))
else {
continue;
};
let year = match year_match.as_str().parse::<i32>() {
Ok(value) => value,
Err(_) => continue,
};
let month = match month_match.as_str().parse::<u32>() {
Ok(value) => value,
Err(_) => continue,
};
let day = match day_match.as_str().parse::<u32>() {
Ok(value) => value,
Err(_) => continue,
};
if let Some(date) = NaiveDate::from_ymd_opt(year, month, day) {
push_date(caps.get(0), date);
}
}
for caps in NUMERIC_RE.captures_iter(text) {
let (Some(first_match), Some(second_match), Some(year_match)) =
(caps.get(1), caps.get(2), caps.get(3))
else {
continue;
};
let first = match first_match.as_str().parse::<u32>() {
Ok(value) => value,
Err(_) => continue,
};
let second = match second_match.as_str().parse::<u32>() {
Ok(value) => value,
Err(_) => continue,
};
let year_raw = year_match.as_str();
let mut year = match year_raw.parse::<i32>() {
Ok(value) => value,
Err(_) => continue,
};
if year_raw.len() == 2 {
year += if year >= 70 { 1900 } else { 2000 };
}
// NUMERIC_RE always captures a day-first form (dd[sep]mm[sep]yy(yy));
// YMD layouts are handled earlier by YMD_RE, so YMD here is treated the
// same as DMY to avoid mis-parsing strings like 01-07-2024.
let (day, month) = match order {
DateOrder::Dmy | DateOrder::Ymd => (first, second),
DateOrder::Mdy => (second, first),
};
if let Some(date) = NaiveDate::from_ymd_opt(year, month, day) {
push_date(caps.get(0), date);
}
}
for caps in DAY_MONTH_RE.captures_iter(text) {
let (Some(day_match), Some(month_match), Some(year_match)) =
(caps.get(1), caps.get(2), caps.get(3))
else {
continue;
};
let day_str = day_match
.as_str()
.trim_matches(|c: char| !c.is_ascii_digit());
let day = match day_str.parse::<u32>() {
Ok(value) => value,
Err(_) => continue,
};
let Some(month) = month_name_to_number(month_match.as_str(), settings) else {
continue;
};
if year_match.as_str().len() < 3 {
continue;
}
let Some(year) = normalize_year(year_match.as_str()) else {
continue;
};
if let Some(date) = NaiveDate::from_ymd_opt(year, month, day) {
push_date(caps.get(0), date);
}
}
for caps in MONTH_DAY_RE.captures_iter(text) {
let (Some(month_match), Some(day_match), Some(year_match)) =
(caps.get(1), caps.get(2), caps.get(3))
else {
continue;
};
let Some(month) = month_name_to_number(month_match.as_str(), settings) else {
continue;
};
let day_str = day_match
.as_str()
.trim_matches(|c: char| !c.is_ascii_digit());
let day = match day_str.parse::<u32>() {
Ok(value) => value,
Err(_) => continue,
};
let Some(year) = normalize_year(year_match.as_str()) else {
continue;
};
if let Some(date) = NaiveDate::from_ymd_opt(year, month, day) {
push_date(caps.get(0), date);
}
}
for caps in MONTH_YEAR_RE.captures_iter(text) {
let (Some(month_match), Some(year_match)) = (caps.get(1), caps.get(2)) else {
continue;
};
let Some(month) = month_name_to_number(month_match.as_str(), settings) else {
continue;
};
let year = match year_match.as_str().parse::<i32>() {
Ok(value) => value,
Err(_) => continue,
};
if let Some(date) = NaiveDate::from_ymd_opt(year, month, 1) {
push_date(caps.get(0), date);
}
}
matches.sort_by_key(|(start, _, _)| *start);
matches
.into_iter()
.map(|(start, _, dt)| (start, dt))
.collect()
}
fn normalize_year(raw: &str) -> Option<i32> {
if raw.len() == 2 {
let mut year = raw.parse::<i32>().ok()?;
year += if year >= 70 { 1900 } else { 2000 };
Some(year)
} else {
raw.parse::<i32>().ok()
}
}
fn month_name_to_number(value: &str, settings: &IssuedAtSettings) -> Option<u32> {
let normalized = value.trim();
let variant = issued_at_months::variants()
.iter()
.find(|entry| entry.name == normalized)?;
if settings.locales().is_empty() || variant.locales.is_empty() {
return Some(variant.month);
}
if variant
.locales
.iter()
.any(|locale| settings.locales().contains(*locale))
{
Some(variant.month)
} else {
None
}
}
async fn load_document_text(ctx: &mut DocumentVersionTaskContext) -> TaskResult<Option<String>> {
let object_key = {
let asset = ctx.asset(TEXT_CONTENT_ASSET_TYPE).await?;
asset.map(|a| a.asset.s3_key.clone())
};
let Some(key) = object_key else {
return Ok(None);
};
match ctx.storage().get_object(&key).await {
Ok(bytes) => match String::from_utf8(bytes) {
Ok(mut text) => {
if text.len() > MAX_TEXT_CHARS {
text.truncate(MAX_TEXT_CHARS);
}
Ok(Some(text))
}
Err(err) => {
warn!(error = %err, "ocr text asset not valid utf-8");
Ok(None)
}
},
Err(err) => {
warn!(error = %err, "failed to download ocr text for issued_at extractor");
Ok(None)
}
}
}
#[derive(Copy, Clone)]
enum IssuedAtSource {
Parser,
Filename,
Text,
}
impl IssuedAtSource {
fn as_ref(&self) -> &'static str {
match self {
IssuedAtSource::Parser => "parser",
IssuedAtSource::Filename => "filename",
IssuedAtSource::Text => "text",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::AppConfig;
use serde::Deserialize;
use serde_yaml::Value as YamlValue;
use once_cell::sync::Lazy;
#[derive(Clone, Deserialize)]
struct CaseSuite {
cases: Vec<CaseDefinition>,
}
#[derive(Clone, Deserialize)]
struct CaseDefinition {
name: String,
parser: String,
#[serde(default)]
filename: Option<String>,
#[serde(default)]
content: Option<String>,
#[serde(default)]
settings: CaseSettings,
expected: ExpectedCase,
}
#[derive(Clone, Default, Deserialize)]
struct CaseSettings {
#[serde(rename = "DATE_PARSER_LANGUAGES", default)]
date_parser_languages: Vec<String>,
#[serde(rename = "FILENAME_DATE_ORDER")]
filename_date_order: Option<String>,
#[serde(rename = "DATE_ORDER")]
date_order: Option<String>,
#[serde(rename = "IGNORE_DATES", default)]
ignore_dates: Vec<String>,
}
#[derive(Clone, Deserialize)]
struct ExpectedCase {
mode: ExpectedMode,
#[serde(default)]
value: Option<YamlValue>,
}
#[derive(Clone, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
enum ExpectedMode {
None,
Single,
Multiple,
}
static CASES: Lazy<CaseSuite> = Lazy::new(|| {
let raw = include_str!("../../tests/data/issued_at_cases.yaml");
serde_yaml::from_str(raw).expect("failed to parse issued_at cases")
});
pub(crate) fn run_named_case(name: &str) {
let case = CASES
.cases
.iter()
.find(|case| case.name == name)
.unwrap_or_else(|| panic!("case '{}' not found", name))
.clone();
run_case(case);
}
fn run_case(case: CaseDefinition) {
let mut config = base_config();
if let Some(order) = case.settings.date_order {
config.issued_at_date_order = order;
}
config.issued_at_filename_date_order = case.settings.filename_date_order;
config.issued_at_date_parser_locales = case.settings.date_parser_languages;
config.issued_at_ignore_dates = case.settings.ignore_dates;
let settings = IssuedAtSettings::from_config(&config);
let now_utc = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).single().unwrap();
let mut matches = Vec::new();
if let Some(filename) = &case.filename {
if let Some(order) = settings.filename_date_order() {
let normalized = normalize_content(filename, MAX_FILENAME_CHARS);
matches.extend(
collect_matches_with_spans(&normalized, order, &settings, now_utc)
.into_iter()
.map(|(_, dt)| dt.date_naive()),
);
}
}
if let Some(content) = &case.content {
let normalized = normalize_content(content, MAX_TEXT_CHARS);
matches.extend(
collect_matches_with_spans(&normalized, settings.date_order(), &settings, now_utc)
.into_iter()
.map(|(_, dt)| dt.date_naive()),
);
}
let actual: Vec<String> = matches
.into_iter()
.map(|date| date.format("%Y-%m-%d").to_string())
.collect();
match case.parser.as_str() {
"parse_date" => match case.expected.mode {
ExpectedMode::None => assert!(
actual.is_empty(),
"case '{}' expected no matches, got {:?}",
case.name,
actual
),
ExpectedMode::Single => {
let expected = case.expected.single_value();
assert!(
expected.is_some(),
"case '{}' is missing expected single value",
case.name
);
assert_eq!(
actual.first(),
expected.as_ref(),
"case '{}' single mismatch",
case.name
);
}
ExpectedMode::Multiple => panic!(
"case '{}' declares parse_date but expects multiple results",
case.name
),
},
"parse_date_generator" => {
let expected = case.expected.multiple_values().unwrap_or_default();
assert_eq!(expected, actual, "case '{}' multiple mismatch", case.name);
}
other => panic!("unsupported parser '{}' in case {}", other, case.name),
}
}
impl ExpectedCase {
fn single_value(&self) -> Option<String> {
match self.value.as_ref()? {
YamlValue::String(value) => Some(value.clone()),
other => Some(other.as_str()?.to_string()),
}
}
fn multiple_values(&self) -> Option<Vec<String>> {
let list = match self.value.as_ref()? {
YamlValue::Sequence(seq) => seq,
_ => return None,
};
Some(
list.iter()
.filter_map(|value| value.as_str().map(|s| s.to_string()))
.collect(),
)
}
}
fn base_config() -> AppConfig {
AppConfig {
database_url: "postgres://test".to_string(),
migrations_database_url: None,
database_max_pool_size: 5,
server_host: "127.0.0.1".to_string(),
server_port: 0,
webdav_host: "127.0.0.1".to_string(),
webdav_port: 0,
jwt_secret: "secret".to_string(),
jwt_issuer: "issuer".to_string(),
jwt_audience: "audience".to_string(),
jwt_expiry_minutes: 60,
download_token_audience: "download".to_string(),
download_token_expiry_minutes: 60,
refresh_token_expiry_days: 30,
refresh_cookie_secure: false,
refresh_cookie_domain: None,
cors_allowed_origin: None,
proxy_downloads: false,
aws_endpoint_url: None,
aws_access_key_id: None,
aws_secret_access_key: None,
aws_region: "us-east-1".to_string(),
s3_bucket: "bucket".to_string(),
quickwit_endpoint: None,
quickwit_index: None,
worker_max_document_bytes: 100 * 1024 * 1024,
upload_body_limit_bytes: 64 * 1024 * 1024,
service_timezone: "UTC".to_string(),
issued_at_date_order: "DMY".to_string(),
issued_at_filename_date_order: None,
issued_at_date_parser_locales: Vec::new(),
issued_at_ignore_dates: Vec::new(),
webauthn_rp_id: Some("localhost".to_string()),
webauthn_origin: Some("http://localhost".to_string()),
webauthn_rp_name: "Papercrate".to_string(),
}
}
include!(concat!(env!("OUT_DIR"), "/issued_at_generated_tests.rs"));
#[test]
fn month_name_lookup_handles_turkish_variants() {
let settings = IssuedAtSettings::from_config(&base_config());
assert_eq!(month_name_to_number("şubat", &settings), Some(2));
assert_eq!(month_name_to_number("subat", &settings), Some(2));
}
#[test]
fn locale_filter_limits_month_names() {
let mut config = base_config();
config.issued_at_date_parser_locales = vec!["tr".into()];
let settings = IssuedAtSettings::from_config(&config);
assert_eq!(month_name_to_number("january", &settings), None);
assert_eq!(month_name_to_number("şubat", &settings), Some(2));
}
}
+15
View File
@@ -0,0 +1,15 @@
pub(super) struct MonthVariant {
pub name: &'static str,
pub month: u32,
pub locales: &'static [&'static str],
}
include!(concat!(env!("OUT_DIR"), "/issued_at_months.rs"));
pub(super) fn pattern() -> &'static str {
MONTH_PATTERN
}
pub(super) fn variants() -> &'static [MonthVariant] {
MONTH_VARIANTS
}
+27 -37
View File
@@ -11,15 +11,19 @@ use crate::{
state::AppState, state::AppState,
storage::TenantStorage, storage::TenantStorage,
}; };
use taskflow::TaskError;
pub mod analyze; pub mod analyze;
pub mod common; pub mod common;
pub mod index; pub mod index;
pub mod issued_at;
pub mod ocr; pub mod ocr;
pub mod purge;
pub mod taskflow;
pub mod tenants; pub mod tenants;
pub mod thumbnails; pub mod thumbnails;
use tenants::ProvisionTenantJob; use tenants::{DeleteTenantJob, ProvisionTenantJob};
#[derive(Debug)] #[derive(Debug)]
pub enum JobExecution { pub enum JobExecution {
@@ -89,8 +93,20 @@ impl Worker {
drop(conn); drop(conn);
if let Some(job) = job_opt { if let Some(job) = job_opt {
let tenant_id = match job.tenant_id {
Some(id) => id,
None => {
warn!(job_id = %job.id, job_type = %job.job_type, "job detached from tenant; marking failed");
if let Ok(mut conn) = self.state.db_unscoped() {
let _ =
mark_job_failed(&mut conn, job.id, "job detached from tenant context");
}
return Ok(true);
}
};
if let Some(handler) = self.handlers.get(job.job_type.as_str()) { if let Some(handler) = self.handlers.get(job.job_type.as_str()) {
let execution = match self.state.storage_for_tenant(job.tenant_id) { let execution = match self.state.storage_for_tenant(tenant_id) {
Ok(storage) => { Ok(storage) => {
handler handler
.handle(self.state.clone(), job.clone(), storage) .handle(self.state.clone(), job.clone(), storage)
@@ -147,13 +163,19 @@ impl Worker {
pub fn default_handlers() -> Vec<Arc<dyn JobHandler>> { pub fn default_handlers() -> Vec<Arc<dyn JobHandler>> {
vec![ vec![
Arc::new(analyze::AnalyzeDocumentJob::new()), Arc::new(analyze::AnalyzeDocumentJob::new()),
Arc::new(thumbnails::GenerateThumbnailsJob::new()), Arc::new(purge::PurgeDocumentJob::new()),
Arc::new(ocr::GenerateOcrTextJob::new()),
Arc::new(index::IndexDocumentTextJob::new()),
Arc::new(ProvisionTenantJob::new()), Arc::new(ProvisionTenantJob::new()),
Arc::new(DeleteTenantJob::new()),
] ]
} }
pub(crate) fn job_execution_from_task_error(error: TaskError) -> JobExecution {
match error {
TaskError::Fail { error } => JobExecution::Failed { error },
TaskError::Retry { delay, error } => JobExecution::Retry { delay, error },
}
}
pub(crate) fn check_worker_document_limit( pub(crate) fn check_worker_document_limit(
size_bytes: i64, size_bytes: i64,
limit_bytes: u64, limit_bytes: u64,
@@ -185,35 +207,3 @@ pub(crate) async fn fetch_version_object(
.await .await
.map_err(FetchVersionError::Storage) .map_err(FetchVersionError::Storage)
} }
pub(crate) fn handle_fetch_error(
job: &crate::models::Job,
err: FetchVersionError,
message: &str,
) -> JobExecution {
match err {
FetchVersionError::TooLarge { size, limit } => {
warn!(
job_id = %job.id,
size_bytes = size,
limit_bytes = limit,
"document exceeds worker size limit"
);
JobExecution::Failed {
error: format!("document size {size} bytes exceeds worker limit of {limit} bytes"),
}
}
FetchVersionError::Storage(err) => {
warn!(
job_id = %job.id,
error = %err,
context = message,
"failed to fetch object for worker"
);
JobExecution::Retry {
delay: Duration::from_secs(30),
error: err.to_string(),
}
}
}
}
+227 -336
View File
@@ -10,172 +10,84 @@ use async_trait::async_trait;
use chrono::Utc; use chrono::Utc;
use diesel::{pg::upsert::excluded, prelude::*}; use diesel::{pg::upsert::excluded, prelude::*};
use pdfium_render::prelude::*; use pdfium_render::prelude::*;
use serde::Deserialize;
use serde_json::json; use serde_json::json;
use tempfile::NamedTempFile; use tempfile::NamedTempFile;
use tokio::task; use tokio::task;
use tracing::{error, info, warn}; use tracing::{info, warn};
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
documents::asset::delete_asset, documents::asset::delete_asset,
jobs::{enqueue_job, JOB_GENERATE_OCR_TEXT, JOB_INDEX_DOCUMENT_TEXT}, error::AppResult,
models::{ models::{Document, DocumentAsset, DocumentVersion, NewDocumentAsset},
Document, DocumentAsset, DocumentAssetObject, DocumentVersion, NewDocumentAsset, schema::document_assets,
NewDocumentAssetObject,
},
schema::{document_asset_objects, document_assets},
state::AppState, state::AppState,
storage::TenantStorage, utils::storage_paths::document_asset_key,
utils::storage_paths::document_asset_object_prefix,
}; };
use super::{ use super::taskflow::{
common::{load_document_version, load_version_assets}, document::DocumentVersionTaskContext, Task, TaskContext, TaskError, TaskResult,
fetch_version_object, handle_fetch_error, JobExecution, JobHandler,
}; };
pub const OCR_TEXT_ASSET_TYPE: &str = "ocr-text"; pub const TEXT_CONTENT_ASSET_TYPE: &str = "text-content";
const MIN_TEXT_LENGTH: usize = 50; const MIN_TEXT_LENGTH: usize = 50;
#[derive(Clone, Debug, Deserialize)] pub struct GenerateOcrTask {
struct OcrPayload {
document_id: Uuid,
document_version_id: Uuid,
#[serde(default)]
force: bool, force: bool,
state: Arc<AppState>,
} }
pub struct GenerateOcrTextJob; impl GenerateOcrTask {
pub fn new(force: bool, state: Arc<AppState>) -> Self {
impl GenerateOcrTextJob { Self { force, state }
pub fn new() -> Self {
Self
} }
} }
#[async_trait] #[async_trait]
impl JobHandler for GenerateOcrTextJob { impl Task<DocumentVersionTaskContext> for GenerateOcrTask {
fn job_type(&self) -> &'static str { fn name(&self) -> &'static str {
JOB_GENERATE_OCR_TEXT "generate-ocr-text"
} }
async fn handle( async fn execute(&self, ctx: &mut DocumentVersionTaskContext) -> TaskResult<()> {
&self, let context = build_ocr_context(ctx, self.force).await?;
state: Arc<AppState>,
job: crate::models::Job,
storage: TenantStorage,
) -> JobExecution {
let payload: OcrPayload = match serde_json::from_value(job.payload.clone()) {
Ok(payload) => payload,
Err(err) => {
return JobExecution::Failed {
error: format!("invalid OCR payload: {err}"),
}
}
};
let state_clone = state.clone();
let payload_clone = payload.clone();
let tenant_id = job.tenant_id;
let context = match task::spawn_blocking(move || {
load_ocr_context(state_clone, tenant_id, payload_clone)
})
.await
{
Ok(Ok(ctx)) => ctx,
Ok(Err(err)) => {
warn!(job_id = %job.id, error = %err, "ocr job will retry");
return JobExecution::Retry {
delay: Duration::from_secs(30),
error: err,
};
}
Err(join_err) => {
error!(job_id = %job.id, error = %join_err, "ocr task panicked");
return JobExecution::Retry {
delay: Duration::from_secs(60),
error: format!("worker panicked: {join_err}"),
};
}
};
if context.skip { if context.skip {
info!(job_id = %job.id, "ocr already present; skipping"); info!(job_id = %ctx.job_id(), "ocr already present; skipping");
return JobExecution::Success; return Ok(());
} }
let bytes = match fetch_version_object( let bytes = ctx.buffered_object().await?.to_vec();
&context.version, let meta = PdfDocumentMeta {
&storage, mime_type: context.document.mime_type.clone(),
&context.version.s3_key,
state.config.worker_max_document_bytes,
)
.await
{
Ok(bytes) => bytes,
Err(err) => return handle_fetch_error(&job, err, "failed to fetch document for ocr"),
};
let doc_meta = PdfDocumentMeta {
content_type: context.document.content_type.clone(),
original_name: context.document.original_name.clone(), original_name: context.document.original_name.clone(),
}; };
let generation = let generation = task::spawn_blocking(move || generate_ocr_text(&meta, &bytes))
match task::spawn_blocking(move || generate_ocr_text(&doc_meta, &bytes)).await { .await
Ok(result) => result, .map_err(|err| {
Err(join_err) => { TaskError::retry(
error!(job_id = %job.id, error = %join_err, "ocr text task panicked"); Duration::from_secs(60),
return JobExecution::Retry { format!("ocr text task panicked: {err}"),
delay: Duration::from_secs(60), )
error: format!("worker panicked: {join_err}"), })?;
};
}
};
let Some(generation) = generation else { let Some(generation) = generation else {
warn!(job_id = %job.id, "no text extracted from document; failing job"); warn!(job_id = %ctx.job_id(), "no text extracted from document; failing job");
return JobExecution::Failed { return Err(TaskError::fail("no text extracted and OCR unavailable"));
error: "no text extracted and OCR unavailable".into(),
};
}; };
if let Some(existing_asset) = &context.existing_asset { remove_existing_ocr_asset(ctx, &context).await;
for object in &context.existing_objects {
if let Err(err) = storage.delete_object(&object.s3_key).await {
warn!(job_id = %job.id, error = %err, s3_key = %object.s3_key, "failed to delete existing ocr asset object");
}
}
let tenant_id = context.document.tenant_id;
let asset_id = existing_asset.id;
let state_clone = state.clone();
match task::spawn_blocking(move || {
delete_asset(state_clone.as_ref(), tenant_id, asset_id)
})
.await
{
Ok(Ok(())) => {}
Ok(Err(err)) => {
warn!(job_id = %job.id, error = ?err, asset_id = %asset_id, "failed to remove ocr asset metadata after deletion");
}
Err(join_err) => {
warn!(job_id = %job.id, error = %join_err, asset_id = %asset_id, "failed to remove ocr asset metadata: task panicked");
}
}
}
let asset_id = Uuid::new_v4(); let asset_id = Uuid::new_v4();
let s3_key = document_asset_key(
let s3_key = document_asset_object_prefix(
context.document.id, context.document.id,
context.version.version_number, context.version.version_number,
OCR_TEXT_ASSET_TYPE, TEXT_CONTENT_ASSET_TYPE,
asset_id, asset_id,
); );
if let Err(err) = storage ctx.storage()
.put_object( .put_object(
&s3_key, &s3_key,
generation.text.into_bytes(), generation.text.into_bytes(),
@@ -183,112 +95,163 @@ impl JobHandler for GenerateOcrTextJob {
None, None,
) )
.await .await
{ .map_err(|err| TaskError::retry(Duration::from_secs(30), err.to_string()))?;
warn!(job_id = %job.id, error = %err, "failed to upload ocr text");
return JobExecution::Retry {
delay: Duration::from_secs(30),
error: err.to_string(),
};
}
let state_clone = state.clone(); let state = self.state.clone();
match task::spawn_blocking(move || { task::spawn_blocking(move || {
persist_ocr_metadata(state_clone, &context, asset_id, &s3_key, generation.source) persist_ocr_metadata(state, &context, asset_id, &s3_key, generation.source)
}) })
.await .await
{ .map_err(|err| {
Ok(Ok(())) => { TaskError::retry(
if let Err(err) = enqueue_index_job(&state, job.tenant_id, &payload) { Duration::from_secs(60),
warn!(job_id = %job.id, error = %err, "failed to enqueue index job"); format!("ocr metadata task panicked: {err}"),
} )
JobExecution::Success })?
} .map_err(|err| TaskError::retry(Duration::from_secs(30), err))?;
Ok(Err(err)) => {
warn!(job_id = %job.id, error = %err, "failed to persist ocr metadata");
JobExecution::Retry {
delay: Duration::from_secs(30),
error: err,
}
}
Err(join_err) => {
error!(job_id = %job.id, error = %join_err, "ocr metadata task panicked");
JobExecution::Retry {
delay: Duration::from_secs(30),
error: format!("metadata update panic: {join_err}"),
}
}
}
}
}
struct PdfDocumentMeta { ctx.invalidate_asset_cache();
content_type: Option<String>,
original_name: String, Ok(())
}
} }
struct OcrContext { struct OcrContext {
document: Document, document: Document,
version: DocumentVersion, version: DocumentVersion,
existing_asset: Option<DocumentAsset>, existing_asset: Option<DocumentAsset>,
existing_objects: Vec<DocumentAssetObject>,
skip: bool, skip: bool,
} }
struct OcrGeneration { async fn build_ocr_context(
text: String, ctx: &mut DocumentVersionTaskContext,
source: &'static str, force: bool,
} ) -> TaskResult<OcrContext> {
let document = ctx.document().await?.clone();
let version = ctx.version().await?.clone();
let asset = ctx.asset(TEXT_CONTENT_ASSET_TYPE).await?;
fn load_ocr_context( let existing_asset = asset.map(|asset| asset.asset.clone());
state: Arc<AppState>,
tenant_id: Uuid,
payload: OcrPayload,
) -> Result<OcrContext, String> {
let base = load_document_version(
state.as_ref(),
tenant_id,
payload.document_id,
payload.document_version_id,
)?;
let mut conn = state if !document_is_pdf(&document) {
.db_for_tenant(base.tenant_id)
.map_err(|err| format!("{err:?}"))?;
let mut assets = load_version_assets(
&mut conn,
base.tenant_id,
base.version.id,
&[OCR_TEXT_ASSET_TYPE],
)?;
let (existing_asset, existing_objects) = assets
.remove(OCR_TEXT_ASSET_TYPE)
.map(|entry| (Some(entry.asset), entry.objects))
.unwrap_or((None, Vec::new()));
let is_pdf = document_is_pdf(&base.document);
if !is_pdf {
return Ok(OcrContext { return Ok(OcrContext {
document: base.document, document,
version: base.version, version,
existing_asset, existing_asset,
existing_objects,
skip: true, skip: true,
}); });
} }
let skip = existing_asset.is_some() && !payload.force; let skip = existing_asset.is_some() && !force;
Ok(OcrContext { Ok(OcrContext {
document: base.document, document,
version: base.version, version,
existing_asset, existing_asset,
existing_objects,
skip, skip,
}) })
} }
async fn remove_existing_ocr_asset(ctx: &DocumentVersionTaskContext, context: &OcrContext) {
if let Some(existing_asset) = &context.existing_asset {
if let Err(err) = ctx.storage().delete_object(&existing_asset.s3_key).await {
warn!(
job_id = %ctx.job_id(),
error = %err,
s3_key = %existing_asset.s3_key,
"failed to delete existing ocr asset object"
);
}
let tenant_id = context.document.tenant_id;
let asset_id = existing_asset.id;
let state = ctx.state().clone();
match task::spawn_blocking(move || -> AppResult<()> {
let mut conn = state.db_for_tenant(tenant_id)?;
delete_asset(&mut conn, tenant_id, asset_id)
})
.await
{
Ok(Ok(())) => {}
Ok(Err(err)) => {
warn!(
job_id = %ctx.job_id(),
error = ?err,
asset_id = %asset_id,
"failed to remove ocr asset metadata after deletion"
);
}
Err(join_err) => {
warn!(
job_id = %ctx.job_id(),
error = %join_err,
asset_id = %asset_id,
"failed to remove ocr asset metadata: task panicked"
);
}
}
}
}
fn persist_ocr_metadata(
state: Arc<AppState>,
context: &OcrContext,
asset_id: Uuid,
s3_key: &str,
source: OcrSource,
) -> Result<(), String> {
let tenant_id = context.document.tenant_id;
let mut conn = state
.db_for_tenant(tenant_id)
.map_err(|err| format!("{err:?}"))?;
let document_version_id = context.version.id;
let existing_asset = context.existing_asset.as_ref().map(|asset| asset.id);
if let Some(existing_asset) = existing_asset {
diesel::delete(
document_assets::table
.filter(document_assets::id.eq(existing_asset))
.filter(document_assets::tenant_id.eq(tenant_id)),
)
.execute(&mut conn)
.map_err(|err| format!("{err:?}"))?;
}
let metadata = json!({
"source": source.to_string(),
"generated_at": Utc::now().to_rfc3339(),
});
let new_asset = NewDocumentAsset {
id: asset_id,
document_version_id,
asset_type: TEXT_CONTENT_ASSET_TYPE.to_string(),
mime_type: "text/plain".to_string(),
metadata,
s3_key: s3_key.to_string(),
tenant_id,
};
diesel::insert_into(document_assets::table)
.values(&new_asset)
.on_conflict((
document_assets::document_version_id,
document_assets::asset_type,
))
.do_update()
.set((
document_assets::mime_type.eq(excluded(document_assets::mime_type)),
document_assets::metadata.eq(excluded(document_assets::metadata)),
document_assets::s3_key.eq(excluded(document_assets::s3_key)),
document_assets::id.eq(excluded(document_assets::id)),
))
.execute(&mut conn)
.map_err(|err| format!("{err:?}"))?;
Ok(())
}
fn generate_ocr_text(meta: &PdfDocumentMeta, bytes: &[u8]) -> Option<OcrGeneration> { fn generate_ocr_text(meta: &PdfDocumentMeta, bytes: &[u8]) -> Option<OcrGeneration> {
if !document_meta_is_pdf(meta) { if !document_meta_is_pdf(meta) {
return None; return None;
@@ -298,7 +261,7 @@ fn generate_ocr_text(meta: &PdfDocumentMeta, bytes: &[u8]) -> Option<OcrGenerati
if text.trim().chars().count() >= MIN_TEXT_LENGTH { if text.trim().chars().count() >= MIN_TEXT_LENGTH {
return Some(OcrGeneration { return Some(OcrGeneration {
text, text,
source: "pdf-text", source: OcrSource::PdfText,
}); });
} }
} }
@@ -306,20 +269,60 @@ fn generate_ocr_text(meta: &PdfDocumentMeta, bytes: &[u8]) -> Option<OcrGenerati
match run_ocr(bytes) { match run_ocr(bytes) {
Ok(Some(text)) => Some(OcrGeneration { Ok(Some(text)) => Some(OcrGeneration {
text, text,
source: "ocr", source: OcrSource::Ocr,
}), }),
Ok(None) => None, Ok(None) => None,
Err(OcrError::BinaryMissing) => { Err(OcrError::BinaryMissing) => {
warn!("ocrmypdf not installed; cannot perform OCR"); warn!("ocrmypdf binary not found; OCR unavailable");
None None
} }
Err(err) => { Err(err) => {
warn!(error = ?err, "ocr command failed"); warn!(error = %err, "ocr command failed");
None None
} }
} }
} }
struct PdfDocumentMeta {
mime_type: Option<String>,
original_name: String,
}
struct OcrGeneration {
text: String,
source: OcrSource,
}
#[derive(Clone, Copy)]
enum OcrSource {
PdfText,
Ocr,
}
impl fmt::Display for OcrSource {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
OcrSource::PdfText => write!(f, "pdf-text"),
OcrSource::Ocr => write!(f, "ocr"),
}
}
}
#[derive(Debug)]
enum OcrError {
BinaryMissing,
Failed(String),
}
impl fmt::Display for OcrError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
OcrError::BinaryMissing => write!(f, "ocrmypdf binary not found"),
OcrError::Failed(msg) => write!(f, "ocr failed: {msg}"),
}
}
}
fn extract_pdf_text(bytes: &[u8]) -> Result<String, String> { fn extract_pdf_text(bytes: &[u8]) -> Result<String, String> {
let pdfium = Pdfium::default(); let pdfium = Pdfium::default();
let document = pdfium let document = pdfium
@@ -343,21 +346,6 @@ fn extract_pdf_text(bytes: &[u8]) -> Result<String, String> {
Ok(combined) Ok(combined)
} }
#[derive(Debug)]
enum OcrError {
BinaryMissing,
Failed(String),
}
impl fmt::Display for OcrError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
OcrError::BinaryMissing => write!(f, "ocrmypdf binary not found"),
OcrError::Failed(msg) => write!(f, "ocr failed: {msg}"),
}
}
}
fn run_ocr(bytes: &[u8]) -> Result<Option<String>, OcrError> { fn run_ocr(bytes: &[u8]) -> Result<Option<String>, OcrError> {
let mut input = NamedTempFile::new().map_err(|err| OcrError::Failed(err.to_string()))?; let mut input = NamedTempFile::new().map_err(|err| OcrError::Failed(err.to_string()))?;
input input
@@ -406,121 +394,9 @@ fn run_ocr(bytes: &[u8]) -> Result<Option<String>, OcrError> {
} }
} }
fn persist_ocr_metadata(
state: Arc<AppState>,
context: &OcrContext,
asset_id: Uuid,
s3_key: &str,
source: &'static str,
) -> Result<(), String> {
let tenant_id = context.document.tenant_id;
let mut conn = state
.db_for_tenant(tenant_id)
.map_err(|err| format!("{err:?}"))?;
if let Some(existing_asset) = &context.existing_asset {
diesel::delete(document_assets::table.filter(document_assets::id.eq(existing_asset.id)))
.execute(&mut conn)
.map_err(|err| format!("{err:?}"))?;
}
let new_asset = NewDocumentAsset {
id: asset_id,
document_version_id: context.version.id,
asset_type: OCR_TEXT_ASSET_TYPE.to_string(),
mime_type: "text/plain".to_string(),
metadata: json!({
"generated_at": Utc::now().to_rfc3339(),
"source": source,
}),
cardinality: Some(1),
tenant_id,
};
diesel::insert_into(document_assets::table)
.values(&new_asset)
.on_conflict((
document_assets::document_version_id,
document_assets::asset_type,
))
.do_update()
.set((
document_assets::mime_type.eq(excluded(document_assets::mime_type)),
document_assets::metadata.eq(excluded(document_assets::metadata)),
document_assets::cardinality.eq(excluded(document_assets::cardinality)),
))
.execute(&mut conn)
.map_err(|err| format!("{err:?}"))?;
let existing_object_id: Option<Uuid> = document_asset_objects::table
.filter(document_asset_objects::asset_id.eq(asset_id))
.filter(document_asset_objects::ordinal.eq(1))
.filter(document_asset_objects::tenant_id.eq(tenant_id))
.select(document_asset_objects::id)
.first(&mut conn)
.optional()
.map_err(|err| format!("{err:?}"))?;
let object_id = existing_object_id.unwrap_or_else(Uuid::new_v4);
let new_object = NewDocumentAssetObject {
id: object_id,
asset_id,
ordinal: 1,
s3_key: s3_key.to_string(),
metadata: json!({}),
tenant_id,
};
diesel::insert_into(document_asset_objects::table)
.values(&new_object)
.on_conflict((
document_asset_objects::asset_id,
document_asset_objects::ordinal,
))
.do_update()
.set((
document_asset_objects::s3_key.eq(excluded(document_asset_objects::s3_key)),
document_asset_objects::metadata.eq(excluded(document_asset_objects::metadata)),
))
.execute(&mut conn)
.map_err(|err| format!("{err:?}"))?;
Ok(())
}
fn enqueue_index_job(
state: &AppState,
tenant_id: Uuid,
payload: &OcrPayload,
) -> Result<(), String> {
let mut conn = state
.db_for_tenant(tenant_id)
.map_err(|err| format!("{err:?}"))?;
enqueue_job(
&mut conn,
tenant_id,
JOB_INDEX_DOCUMENT_TEXT,
json!({
"document_id": payload.document_id,
"document_version_id": payload.document_version_id,
}),
None,
)
.map(|_| ())
.map_err(|err| err.to_string())
}
pub fn document_is_pdf(document: &Document) -> bool {
document_meta_is_pdf(&PdfDocumentMeta {
content_type: document.content_type.clone(),
original_name: document.original_name.clone(),
})
}
fn document_meta_is_pdf(meta: &PdfDocumentMeta) -> bool { fn document_meta_is_pdf(meta: &PdfDocumentMeta) -> bool {
if let Some(content_type) = &meta.content_type { if let Some(mime_type) = &meta.mime_type {
if content_type.eq_ignore_ascii_case("application/pdf") { if mime_type.eq_ignore_ascii_case("application/pdf") {
return true; return true;
} }
} }
@@ -531,3 +407,18 @@ fn document_meta_is_pdf(meta: &PdfDocumentMeta) -> bool {
.map(|ext| ext.eq_ignore_ascii_case("pdf")) .map(|ext| ext.eq_ignore_ascii_case("pdf"))
.unwrap_or(false) .unwrap_or(false)
} }
fn document_is_pdf(document: &Document) -> bool {
document
.mime_type
.as_deref()
.map(|mime| mime.eq_ignore_ascii_case("application/pdf"))
.unwrap_or_else(|| {
document
.original_name
.rsplit('.')
.next()
.map(|ext| ext.eq_ignore_ascii_case("pdf"))
.unwrap_or(false)
})
}
+298
View File
@@ -0,0 +1,298 @@
use std::collections::HashSet;
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use diesel::prelude::*;
use diesel::result::Error as DieselError;
use serde::Deserialize;
use uuid::Uuid;
use crate::auth::ensure_active_tenant;
use crate::jobs::JOB_PURGE_DOCUMENT;
use crate::models::{Document, DocumentVersion};
use crate::schema::{document_assets, document_versions};
use crate::state::AppState;
use crate::storage::TenantStorage;
use super::{
job_execution_from_task_error,
taskflow::{BoxedTask, Task, TaskContext, TaskError, TaskExecutor, TaskPlanner, TaskResult},
JobExecution, JobHandler,
};
#[derive(Debug, Deserialize)]
struct PurgeDocumentPayload {
document_id: Uuid,
}
#[derive(Debug)]
struct PurgeContext {
document_id: Uuid,
version_keys: Vec<String>,
asset_keys: Vec<String>,
}
pub struct PurgeDocumentJob;
impl PurgeDocumentJob {
pub fn new() -> Self {
Self
}
}
#[async_trait]
impl JobHandler for PurgeDocumentJob {
fn job_type(&self) -> &'static str {
JOB_PURGE_DOCUMENT
}
async fn handle(
&self,
state: Arc<AppState>,
job: crate::models::Job,
storage: TenantStorage,
) -> JobExecution {
let tenant_id = match job.tenant_id {
Some(id) => id,
None => {
return JobExecution::Failed {
error: "job is no longer associated with a tenant".to_string(),
}
}
};
if let Err(err) = ensure_active_tenant(&state, tenant_id) {
return JobExecution::Failed {
error: err.to_string(),
};
}
let payload: PurgeDocumentPayload = match serde_json::from_value(job.payload.clone()) {
Ok(payload) => payload,
Err(err) => {
return JobExecution::Failed {
error: format!("invalid purge payload: {err}"),
};
}
};
let mut context = PurgeTaskContext::new(
job.id,
JOB_PURGE_DOCUMENT,
tenant_id,
payload.document_id,
state.clone(),
storage,
);
let planner = PurgePlanner;
match TaskExecutor::run(&planner, &mut context).await {
Ok(()) => JobExecution::Success,
Err(err) => job_execution_from_task_error(err),
}
}
}
struct PurgeTaskContext {
job_id: Uuid,
job_type: &'static str,
tenant_id: Uuid,
document_id: Uuid,
state: Arc<AppState>,
storage: TenantStorage,
}
impl PurgeTaskContext {
fn new(
job_id: Uuid,
job_type: &'static str,
tenant_id: Uuid,
document_id: Uuid,
state: Arc<AppState>,
storage: TenantStorage,
) -> Self {
Self {
job_id,
job_type,
tenant_id,
document_id,
state,
storage,
}
}
}
impl TaskContext for PurgeTaskContext {
fn job_id(&self) -> Uuid {
self.job_id
}
fn job_type(&self) -> &'static str {
self.job_type
}
}
struct PurgePlanner;
#[async_trait]
impl TaskPlanner<PurgeTaskContext> for PurgePlanner {
async fn plan(
&self,
_ctx: &mut PurgeTaskContext,
) -> TaskResult<Vec<BoxedTask<PurgeTaskContext>>> {
Ok(vec![Box::new(PurgeTask)])
}
}
struct PurgeTask;
#[async_trait]
impl Task<PurgeTaskContext> for PurgeTask {
fn name(&self) -> &'static str {
"purge-document"
}
async fn execute(&self, ctx: &mut PurgeTaskContext) -> TaskResult<()> {
let tenant_id = ctx.tenant_id;
let document_id = ctx.document_id;
let state = ctx.state.clone();
let preparation = tokio::task::spawn_blocking(move || {
prepare_purge_context(state, tenant_id, document_id)
})
.await
.map_err(|err| {
TaskError::retry(
Duration::from_secs(60),
format!("purge preparation panicked: {err}"),
)
})?;
let Some(context) =
preparation.map_err(|err| TaskError::retry(Duration::from_secs(30), err))?
else {
return Ok(());
};
delete_storage_objects(&ctx.storage, &context)
.await
.map_err(|err| TaskError::retry(Duration::from_secs(30), err))?;
let state = ctx.state.clone();
tokio::task::spawn_blocking(move || finalize_purge(state, tenant_id, context.document_id))
.await
.map_err(|err| {
TaskError::retry(
Duration::from_secs(60),
format!("purge finalize panicked: {err}"),
)
})?
.map_err(|err| TaskError::retry(Duration::from_secs(30), err))?;
Ok(())
}
}
fn prepare_purge_context(
state: Arc<AppState>,
tenant_id: Uuid,
document_id: Uuid,
) -> Result<Option<PurgeContext>, String> {
let mut conn = state
.db_for_tenant(tenant_id)
.map_err(|err| format!("failed to scope tenant connection: {err:?}"))?;
conn.transaction(|conn| {
use crate::schema::documents::dsl as doc_dsl;
let doc_opt = doc_dsl::documents
.filter(doc_dsl::tenant_id.eq(tenant_id))
.find(document_id)
.for_update()
.first::<Document>(conn)
.optional()?;
let Some(document) = doc_opt else {
return Ok(None);
};
if document.deleted_at.is_none() {
return Ok(None);
}
let versions: Vec<DocumentVersion> = document_versions::table
.filter(document_versions::document_id.eq(document_id))
.filter(document_versions::tenant_id.eq(tenant_id))
.load(conn)?;
let version_keys: Vec<String> = versions
.iter()
.map(|version| version.s3_key.clone())
.collect();
let version_ids: Vec<Uuid> = versions.iter().map(|version| version.id).collect();
let asset_keys = if version_ids.is_empty() {
Vec::new()
} else {
document_assets::table
.filter(document_assets::document_version_id.eq_any(&version_ids))
.filter(document_assets::tenant_id.eq(tenant_id))
.select(document_assets::s3_key)
.load(conn)?
};
Ok(Some(PurgeContext {
document_id,
version_keys,
asset_keys,
}))
})
.map_err(|err: DieselError| format!("failed to prepare purge: {err}"))
}
async fn delete_storage_objects(
storage: &TenantStorage,
context: &PurgeContext,
) -> Result<(), String> {
let mut keys = HashSet::new();
keys.extend(context.version_keys.iter().cloned());
keys.extend(context.asset_keys.iter().cloned());
for key in keys {
if let Err(err) = storage.delete_object(&key).await {
return Err(format!("failed to delete object {}: {err:?}", key));
}
}
Ok(())
}
fn finalize_purge(state: Arc<AppState>, tenant_id: Uuid, document_id: Uuid) -> Result<(), String> {
let mut conn = state
.db_for_tenant(tenant_id)
.map_err(|err| format!("failed to scope tenant connection: {err:?}"))?;
conn.transaction(|conn| {
use crate::schema::documents::dsl as doc_dsl;
let doc_opt = doc_dsl::documents
.filter(doc_dsl::tenant_id.eq(tenant_id))
.find(document_id)
.for_update()
.first::<Document>(conn)
.optional()?;
let Some(document) = doc_opt else {
return Ok(());
};
if document.deleted_at.is_none() {
return Ok(());
}
diesel::delete(doc_dsl::documents.filter(doc_dsl::id.eq(document_id))).execute(conn)?;
Ok(())
})
.map_err(|err: DieselError| format!("failed to finalize purge: {err}"))
}
+225
View File
@@ -0,0 +1,225 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use tokio::task;
use uuid::Uuid;
use crate::models::{Document, DocumentVersion};
use crate::state::AppState;
use crate::storage::TenantStorage;
use crate::workers::common::{load_document_version, load_version_assets, LoadedAsset};
use crate::workers::{check_worker_document_limit, fetch_version_object, FetchVersionError};
use super::{TaskContext, TaskError, TaskResult};
const BLOCKING_RETRY_DELAY: Duration = Duration::from_secs(60);
const DEFAULT_RETRY_DELAY: Duration = Duration::from_secs(30);
pub struct DocumentVersionTaskContext {
job_id: Uuid,
job_type: &'static str,
tenant_id: Uuid,
document_id: Uuid,
document_version_id: Uuid,
force: bool,
max_document_bytes: u64,
state: Arc<AppState>,
storage: TenantStorage,
document: Option<Document>,
version: Option<DocumentVersion>,
assets: Option<HashMap<String, LoadedAsset>>, // keyed by asset_type
object_bytes: Option<Vec<u8>>,
}
impl DocumentVersionTaskContext {
#[allow(clippy::too_many_arguments)]
pub fn new(
job_id: Uuid,
job_type: &'static str,
tenant_id: Uuid,
document_id: Uuid,
document_version_id: Uuid,
force: bool,
max_document_bytes: u64,
state: Arc<AppState>,
storage: TenantStorage,
) -> Self {
Self {
job_id,
job_type,
tenant_id,
document_id,
document_version_id,
force,
max_document_bytes,
state,
storage,
document: None,
version: None,
assets: None,
object_bytes: None,
}
}
pub fn tenant_id(&self) -> Uuid {
self.tenant_id
}
pub fn document_id(&self) -> Uuid {
self.document_id
}
pub fn version_id(&self) -> Uuid {
self.document_version_id
}
pub fn force(&self) -> bool {
self.force
}
pub fn storage(&self) -> &TenantStorage {
&self.storage
}
pub fn invalidate_asset_cache(&mut self) {
self.assets = None;
}
pub fn set_document_mime(&mut self, mime: Option<String>) {
if let Some(document) = self.document.as_mut() {
document.mime_type = mime;
}
}
pub fn state(&self) -> &Arc<AppState> {
&self.state
}
pub fn max_document_bytes(&self) -> u64 {
self.max_document_bytes
}
pub async fn document(&mut self) -> TaskResult<&Document> {
self.ensure_document_loaded().await?;
Ok(self.document.as_ref().expect("document hydrated"))
}
pub async fn version(&mut self) -> TaskResult<&DocumentVersion> {
self.ensure_document_loaded().await?;
Ok(self.version.as_ref().expect("version hydrated"))
}
pub async fn assets(&mut self) -> TaskResult<&HashMap<String, LoadedAsset>> {
if self.assets.is_none() {
let tenant_id = self.tenant_id;
let version_id = self.document_version_id;
let state = self.state.clone();
let result = task::spawn_blocking(move || {
let mut conn = state
.db_for_tenant(tenant_id)
.map_err(|err| format!("failed to scope tenant connection: {err:?}"))?;
load_version_assets(&mut conn, tenant_id, version_id, &[])
})
.await
.map_err(|err| {
TaskError::retry(
BLOCKING_RETRY_DELAY,
format!("asset load task panicked: {err}"),
)
})?
.map_err(|err| TaskError::retry(DEFAULT_RETRY_DELAY, err))?;
self.assets = Some(result);
}
Ok(self.assets.as_ref().expect("asset map hydrated"))
}
pub async fn asset(&mut self, asset_type: &str) -> TaskResult<Option<&LoadedAsset>> {
let assets = self.assets().await?;
Ok(assets.get(asset_type))
}
pub async fn buffered_object(&mut self) -> TaskResult<&[u8]> {
if self.object_bytes.is_some() {
return Ok(self.object_bytes.as_deref().expect("bytes present"));
}
let version = self.version().await?.clone();
let bytes = fetch_version_object(
&version,
&self.storage,
&version.s3_key,
self.max_document_bytes,
)
.await
.map_err(|err| match err {
FetchVersionError::TooLarge { size, limit } => TaskError::fail(format!(
"document size {size} bytes exceeds worker limit of {limit} bytes"
)),
FetchVersionError::Storage(err) => TaskError::retry(
DEFAULT_RETRY_DELAY,
format!("failed to fetch object: {err}"),
),
})?;
self.object_bytes = Some(bytes);
Ok(self.object_bytes.as_deref().expect("bytes hydrated"))
}
pub async fn object_head(&mut self, max_bytes: usize) -> TaskResult<Vec<u8>> {
let version = self.version().await?.clone();
check_worker_document_limit(version.size_bytes, self.max_document_bytes).map_err(
|(size, limit)| {
TaskError::fail(format!(
"document size {size} bytes exceeds worker limit of {limit} bytes"
))
},
)?;
let end = max_bytes.saturating_sub(1) as u64;
self.storage
.get_object_range(&version.s3_key, 0, Some(end))
.await
.map_err(|err| {
TaskError::retry(
DEFAULT_RETRY_DELAY,
format!("failed to fetch ranged object: {err}"),
)
})
}
async fn ensure_document_loaded(&mut self) -> TaskResult<()> {
if self.document.is_some() && self.version.is_some() {
return Ok(());
}
let tenant_id = self.tenant_id;
let document_id = self.document_id;
let version_id = self.document_version_id;
let state = self.state.clone();
let loaded = task::spawn_blocking(move || {
load_document_version(state.as_ref(), tenant_id, document_id, version_id)
})
.await
.map_err(|err| {
TaskError::retry(
BLOCKING_RETRY_DELAY,
format!("document load task panicked: {err}"),
)
})?
.map_err(|err| TaskError::fail(format!("failed to load document context: {err}")))?;
self.document = Some(loaded.document);
self.version = Some(loaded.version);
Ok(())
}
}
impl TaskContext for DocumentVersionTaskContext {
fn job_id(&self) -> Uuid {
self.job_id
}
fn job_type(&self) -> &'static str {
self.job_type
}
}
+85
View File
@@ -0,0 +1,85 @@
use std::time::Duration;
use async_trait::async_trait;
use thiserror::Error;
use tracing::info;
use uuid::Uuid;
pub mod document;
pub type TaskResult<T> = Result<T, TaskError>;
#[derive(Debug, Error)]
pub enum TaskError {
#[error("{error}")]
Fail { error: String },
#[error("{error}")]
Retry { delay: Duration, error: String },
}
impl TaskError {
pub fn fail(error: impl Into<String>) -> Self {
Self::Fail {
error: error.into(),
}
}
pub fn retry(delay: Duration, error: impl Into<String>) -> Self {
Self::Retry {
delay,
error: error.into(),
}
}
}
pub trait TaskContext: Send + Sync {
fn job_id(&self) -> Uuid;
fn job_type(&self) -> &'static str;
}
#[async_trait]
pub trait Task<Ctx>: Send + Sync
where
Ctx: TaskContext,
{
fn name(&self) -> &'static str;
async fn execute(&self, ctx: &mut Ctx) -> TaskResult<()>;
}
pub type BoxedTask<Ctx> = Box<dyn Task<Ctx> + Send + Sync>;
#[async_trait]
pub trait TaskPlanner<Ctx>: Send + Sync
where
Ctx: TaskContext,
{
async fn plan(&self, ctx: &mut Ctx) -> TaskResult<Vec<BoxedTask<Ctx>>>;
}
pub struct TaskExecutor;
impl TaskExecutor {
pub async fn run<P, C>(planner: &P, ctx: &mut C) -> TaskResult<()>
where
P: TaskPlanner<C>,
C: TaskContext,
{
let tasks = planner.plan(ctx).await?;
for task in tasks {
info!(
job_id = %ctx.job_id(),
job_type = ctx.job_type(),
task = task.name(),
"starting job task"
);
task.execute(ctx).await?;
info!(
job_id = %ctx.job_id(),
job_type = ctx.job_type(),
task = task.name(),
"finished job task"
);
}
Ok(())
}
}
+556 -103
View File
@@ -1,20 +1,41 @@
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait; use async_trait::async_trait;
use chrono::Utc; use chrono::{DateTime, Utc};
use diesel::prelude::*; use diesel::prelude::*;
use diesel::sql_types::Jsonb;
use hmac::{Hmac, Mac};
use reqwest::Client; use reqwest::Client;
use serde::Deserialize; use serde::Deserialize;
use serde_json::json;
use tracing::warn; use tracing::warn;
use uuid::Uuid; use uuid::Uuid;
use crate::documents::search::ensure_quickwit_index; use sha2::Sha256;
use crate::jobs::JOB_PROVISION_TENANT;
use crate::models::{NewUserMembership, TenantStatus}; use crate::auth::capability_sets::{
use crate::schema::{tenants, user_memberships}; ensure_capability_set, owner_capabilities, readonly_capabilities, user_capabilities,
webdav_capabilities,
};
use crate::documents::search::{delete_quickwit_index, ensure_quickwit_index};
use crate::jobs::{JOB_DELETE_TENANT, JOB_PROVISION_TENANT};
use crate::models::{NewUserMembership, Tenant, TenantStatus};
use crate::schema::{
api_tokens, correspondents, document_assets, document_correspondents, document_tags,
document_versions, documents, folders, tags, tenants, user_memberships, user_sessions,
};
use crate::state::AppState; use crate::state::AppState;
use crate::tenants::TenantRepository; use crate::tenants::TenantRepository;
use crate::workers::{JobExecution, JobHandler}; use crate::workers::{
job_execution_from_task_error,
taskflow::{BoxedTask, Task, TaskContext, TaskError, TaskExecutor, TaskPlanner, TaskResult},
JobExecution, JobHandler,
};
type HmacSha256 = Hmac<Sha256>;
const DELETE_PROOF_TTL_SECONDS: i64 = 300;
const DELETE_PROOF_VERSION: &str = "v1";
pub struct ProvisionTenantJob; pub struct ProvisionTenantJob;
@@ -36,77 +57,129 @@ impl JobHandler for ProvisionTenantJob {
job: crate::models::Job, job: crate::models::Job,
_storage: crate::storage::TenantStorage, _storage: crate::storage::TenantStorage,
) -> JobExecution { ) -> JobExecution {
let mut conn = match state.db_unscoped() { let tenant_id = match job.tenant_id {
Ok(conn) => conn, Some(id) => id,
Err(err) => { None => {
warn!(job_id = %job.id, error = ?err, "failed to get connection for tenant provisioning");
return JobExecution::Retry {
delay: std::time::Duration::from_secs(30),
error: "database connection unavailable".into(),
};
}
};
let tenant = match TenantRepository::get_by_id(&mut conn, job.tenant_id) {
Ok(tenant) => tenant,
Err(err) => {
warn!(job_id = %job.id, error = ?err, "tenant not found for provisioning");
return JobExecution::Failed { return JobExecution::Failed {
error: "tenant not found".into(), error: "provision job is missing tenant context".to_string(),
}; }
} }
}; };
let members = ProvisionPayload::from_job(&job).unwrap_or_default();
let mut context = ProvisionContext::new(
job.id,
JOB_PROVISION_TENANT,
tenant_id,
state.clone(),
members,
);
let planner = ProvisionPlanner;
match TaskExecutor::run(&planner, &mut context).await {
Ok(()) => JobExecution::Success,
Err(err) => job_execution_from_task_error(err),
}
}
}
struct ProvisionContext {
job_id: Uuid,
job_type: &'static str,
tenant_id: Uuid,
state: Arc<AppState>,
members: Vec<Uuid>,
}
impl ProvisionContext {
fn new(
job_id: Uuid,
job_type: &'static str,
tenant_id: Uuid,
state: Arc<AppState>,
members: Vec<Uuid>,
) -> Self {
Self {
job_id,
job_type,
tenant_id,
state,
members,
}
}
}
impl TaskContext for ProvisionContext {
fn job_id(&self) -> Uuid {
self.job_id
}
fn job_type(&self) -> &'static str {
self.job_type
}
}
struct ProvisionPlanner;
#[async_trait]
impl TaskPlanner<ProvisionContext> for ProvisionPlanner {
async fn plan(
&self,
_ctx: &mut ProvisionContext,
) -> TaskResult<Vec<BoxedTask<ProvisionContext>>> {
Ok(vec![Box::new(ProvisionTask)])
}
}
struct ProvisionTask;
#[async_trait]
impl Task<ProvisionContext> for ProvisionTask {
fn name(&self) -> &'static str {
"provision-tenant"
}
async fn execute(&self, ctx: &mut ProvisionContext) -> TaskResult<()> {
let mut conn = ctx
.state
.db_unscoped()
.map_err(|err| TaskError::retry(Duration::from_secs(30), format!("{err:?}")))?;
let tenant = TenantRepository::get_by_id(&mut conn, ctx.tenant_id).map_err(|err| {
TaskError::fail(format!("tenant not found for provisioning: {err:?}"))
})?;
drop(conn); drop(conn);
let mut conn = match state.db_for_tenant(tenant.id) { let mut conn = ctx
Ok(conn) => conn, .state
Err(err) => { .db_for_tenant(tenant.id)
warn!(job_id = %job.id, error = ?err, "failed to scope connection for tenant provisioning"); .map_err(|err| TaskError::retry(Duration::from_secs(30), format!("{err:?}")))?;
return JobExecution::Retry {
delay: std::time::Duration::from_secs(30),
error: "tenant connection unavailable".into(),
};
}
};
if tenant.status == TenantStatus::Active { if tenant.status == TenantStatus::Active {
warn!( warn!(
job_id = %job.id, job_id = %ctx.job_id(),
tenant_id = %tenant.id, tenant_id = %tenant.id,
"tenant already active; skipping provisioning" "tenant already active; skipping provisioning"
); );
return JobExecution::Success; return Ok(());
} }
if tenant.status != TenantStatus::Creating { if tenant.status != TenantStatus::Creating {
warn!( return Err(TaskError::fail(format!(
job_id = %job.id, "tenant status '{}' not eligible for provisioning",
tenant_id = %tenant.id, tenant.status.as_str()
status = %tenant.status.as_str(), )));
"tenant not in creating state; skipping provisioning"
);
return JobExecution::Failed {
error: format!(
"tenant status '{}' not eligible for provisioning",
tenant.status.as_str()
),
};
} }
let endpoint = match &state.config.quickwit_endpoint {
Some(endpoint) => endpoint.trim_end_matches('/').to_owned(), let endpoint = ctx
None => { .state
warn!( .config
job_id = %job.id, .quickwit_endpoint
tenant_id = %tenant.id, .as_ref()
"quickwit endpoint not configured; retrying" .map(|value| value.trim_end_matches('/').to_owned())
); .ok_or_else(|| {
return JobExecution::Retry { TaskError::retry(Duration::from_secs(30), "quickwit endpoint not configured")
delay: std::time::Duration::from_secs(30), })?;
error: "quickwit endpoint not configured".into(),
};
}
};
let index_id = tenant let index_id = tenant
.quickwit_index .quickwit_index
@@ -115,63 +188,306 @@ impl JobHandler for ProvisionTenantJob {
.unwrap_or_else(|| format!("documents-{}", tenant.id)); .unwrap_or_else(|| format!("documents-{}", tenant.id));
let client = Client::new(); let client = Client::new();
if let Err(err) = ensure_quickwit_index(&client, &endpoint, &index_id).await { ensure_quickwit_index(&client, &endpoint, &index_id)
warn!( .await
job_id = %job.id, .map_err(|err| TaskError::retry(Duration::from_secs(30), err.to_string()))?;
tenant_id = %tenant.id,
error = %err, let owner_capability_set_id =
"failed to ensure quickwit index" ensure_capability_set(&mut conn, tenant.id, owner_capabilities())
); .map_err(|_| {
return JobExecution::Retry { TaskError::retry(Duration::from_secs(30), "owner capability set unavailable")
delay: std::time::Duration::from_secs(30), })?
error: err.to_string(), .id;
ensure_capability_set(&mut conn, tenant.id, user_capabilities()).map_err(|_| {
TaskError::retry(Duration::from_secs(30), "user capability set unavailable")
})?;
ensure_capability_set(&mut conn, tenant.id, readonly_capabilities()).map_err(|_| {
TaskError::retry(
Duration::from_secs(30),
"readonly capability set unavailable",
)
})?;
ensure_capability_set(&mut conn, tenant.id, webdav_capabilities()).map_err(|_| {
TaskError::retry(Duration::from_secs(30), "webdav capability set unavailable")
})?;
for member in &ctx.members {
let new_membership = NewUserMembership {
id: Uuid::new_v4(),
user_id: *member,
tenant_id: tenant.id,
capability_set_id: Some(owner_capability_set_id),
}; };
}
if let Some(members) = ProvisionPayload::from_job(&job) { if let Err(err) = diesel::insert_into(user_memberships::table)
for member in members { .values(&new_membership)
let new_membership = NewUserMembership { .on_conflict((user_memberships::user_id, user_memberships::tenant_id))
id: Uuid::new_v4(), .do_nothing()
user_id: member, .execute(&mut conn)
tenant_id: tenant.id, {
}; warn!(
job_id = %ctx.job_id(),
if let Err(err) = diesel::insert_into(user_memberships::table) tenant_id = %tenant.id,
.values(&new_membership) user_id = %member,
.on_conflict((user_memberships::user_id, user_memberships::tenant_id)) error = %err,
.do_nothing() "failed to assign initial membership"
.execute(&mut conn) );
{
warn!(
job_id = %job.id,
tenant_id = %tenant.id,
user_id = %member,
error = %err,
"failed to assign initial membership"
);
}
} }
} }
if let Err(err) = diesel::update(tenants::table.find(tenant.id)) diesel::update(tenants::table.find(tenant.id))
.set(( .set((
tenants::status.eq(TenantStatus::Active), tenants::status.eq(TenantStatus::Active),
tenants::quickwit_index.eq(Some(index_id)), tenants::quickwit_index.eq(Some(index_id)),
tenants::updated_at.eq(Utc::now().naive_utc()), tenants::updated_at.eq(Utc::now().naive_utc()),
)) ))
.execute(&mut conn) .execute(&mut conn)
{ .map_err(|err| {
warn!(job_id = %job.id, error = %err, "failed to activate tenant"); TaskError::retry(
return JobExecution::Retry { Duration::from_secs(30),
delay: std::time::Duration::from_secs(30), format!("failed to update tenant status: {err}"),
error: format!("failed to update tenant status: {err}"), )
}; })?;
}
JobExecution::Success Ok(())
} }
} }
async fn delete_tenant(
state: &Arc<AppState>,
storage: crate::storage::TenantStorage,
tenant_id: Uuid,
current_job_id: Uuid,
payload: &DeleteTenantPayload,
) -> Result<(), String> {
let remove_tenant = payload.remove_tenant;
let tenant = {
let mut conn = state
.db_unscoped()
.map_err(|err| format!("failed to get db connection: {err:?}"))?;
TenantRepository::get_by_id(&mut conn, tenant_id)
.map_err(|err| format!("tenant lookup failed: {err:?}"))?
};
if tenant.name != payload.tenant_name {
return Err(format!(
"tenant name mismatch: expected '{}', got '{}'",
payload.tenant_name, tenant.name
));
}
if tenant.status != TenantStatus::Deleting {
return Err(format!(
"tenant status '{}' not eligible for deletion",
tenant.status.as_str()
));
}
match (payload_action_applicable(remove_tenant), payload.action) {
(DeleteAction::Delete, DeleteAction::Delete)
| (DeleteAction::Reset, DeleteAction::Reset) => {}
_ => {
return Err("delete payload action mismatch".into());
}
}
let issued_at = DateTime::parse_from_rfc3339(&payload.issued_at)
.map_err(|_| "invalid issued_at timestamp".to_string())?
.with_timezone(&Utc);
if (Utc::now() - issued_at).num_seconds().abs() > DELETE_PROOF_TTL_SECONDS {
return Err("delete confirmation expired".into());
}
let resolved_final_status = if remove_tenant {
None
} else {
Some(payload.final_status.unwrap_or(FinalTenantStatus::Suspended))
};
let final_status_str = resolved_final_status.map(|s| s.as_str());
let message = build_delete_proof_message(
tenant_id,
&tenant.name,
payload.action,
&payload.nonce,
&payload.issued_at,
final_status_str,
);
verify_delete_proof(&state.config.jwt_secret, &message, &payload.signature)?;
let object_keys = {
let mut conn = state
.db_for_tenant(tenant_id)
.map_err(|err| format!("failed to scope tenant connection: {err:?}"))?;
collect_object_keys(&mut conn)
.map_err(|err| format!("failed to collect storage keys: {err}"))?
};
delete_storage_objects(&storage, &object_keys)
.await
.map_err(|err| format!("failed to delete storage objects: {err}"))?;
reset_quickwit_index(state, &tenant, remove_tenant)
.await
.map_err(|err| format!("quickwit cleanup failed: {err}"))?;
{
let mut conn = state
.db_for_tenant(tenant_id)
.map_err(|err| format!("failed to scope tenant connection: {err:?}"))?;
delete_tenant_rows(&mut conn, tenant_id, remove_tenant)
.map_err(|err| format!("tenant data cleanup failed: {err}"))?;
}
{
let mut conn = state
.db_unscoped()
.map_err(|err| format!("failed to get db connection: {err:?}"))?;
let detach_result = json!({
"tenant": {
"id": tenant.id,
"name": tenant.name,
},
"action": payload.action.as_str(),
"remove_tenant": remove_tenant,
"final_status": final_status_str,
"timestamp": Utc::now().to_rfc3339(),
});
diesel::sql_query(
"UPDATE jobs \
SET tenant_id = NULL, \
result = jsonb_set(COALESCE(result, '{}'::jsonb), '{detached_tenant}', $3::jsonb, true) \
WHERE tenant_id = $1 AND id <> $2",
)
.bind::<diesel::sql_types::Uuid, _>(tenant_id)
.bind::<diesel::sql_types::Uuid, _>(current_job_id)
.bind::<Jsonb, _>(detach_result)
.execute(&mut conn)
.map_err(|err| format!("failed to detach tenant jobs: {err}"))?;
if remove_tenant {
diesel::delete(tenants::table.find(tenant_id))
.execute(&mut conn)
.map_err(|err| format!("failed to delete tenant row: {err}"))?;
} else {
let new_status = match resolved_final_status.unwrap_or(FinalTenantStatus::Suspended) {
FinalTenantStatus::Active => TenantStatus::Active,
FinalTenantStatus::Suspended => TenantStatus::Suspended,
};
diesel::update(tenants::table.find(tenant_id))
.set((
tenants::status.eq(new_status),
tenants::updated_at.eq(Utc::now().naive_utc()),
))
.execute(&mut conn)
.map_err(|err| format!("failed to update tenant status: {err}"))?;
}
}
Ok(())
}
struct TenantObjectKeys {
version_keys: Vec<String>,
asset_keys: Vec<String>,
}
fn collect_object_keys(conn: &mut PgConnection) -> Result<TenantObjectKeys, diesel::result::Error> {
let version_keys = document_versions::table
.select(document_versions::s3_key)
.load::<String>(conn)?;
let asset_keys = document_assets::table
.select(document_assets::s3_key)
.load::<String>(conn)?;
Ok(TenantObjectKeys {
version_keys,
asset_keys,
})
}
async fn delete_storage_objects(
storage: &crate::storage::TenantStorage,
keys: &TenantObjectKeys,
) -> Result<(), String> {
for key in keys.version_keys.iter().chain(keys.asset_keys.iter()) {
storage
.delete_object(key)
.await
.map_err(|err| format!("failed to delete object '{key}': {err}"))?;
}
Ok(())
}
async fn reset_quickwit_index(
state: &Arc<AppState>,
tenant: &Tenant,
remove_index: bool,
) -> Result<(), String> {
let endpoint = match state.config.quickwit_endpoint.as_ref() {
Some(endpoint) => endpoint,
None => return Ok(()),
};
let index_id = match tenant.quickwit_index.as_deref() {
Some(index) => index,
None => return Ok(()),
};
let client = Client::new();
delete_quickwit_index(&client, endpoint, index_id)
.await
.map_err(|err| format!("quickwit delete failed: {err}"))?;
if !remove_index {
ensure_quickwit_index(&client, endpoint, index_id)
.await
.map_err(|err| format!("quickwit ensure failed: {err}"))?;
}
Ok(())
}
fn delete_tenant_rows(
conn: &mut PgConnection,
tenant_id: Uuid,
remove_memberships: bool,
) -> Result<(), diesel::result::Error> {
conn.transaction(|conn| {
diesel::delete(document_assets::table.filter(document_assets::tenant_id.eq(tenant_id)))
.execute(conn)?;
diesel::delete(
document_correspondents::table.filter(document_correspondents::tenant_id.eq(tenant_id)),
)
.execute(conn)?;
diesel::delete(document_tags::table.filter(document_tags::tenant_id.eq(tenant_id)))
.execute(conn)?;
diesel::delete(document_versions::table.filter(document_versions::tenant_id.eq(tenant_id)))
.execute(conn)?;
diesel::delete(documents::table.filter(documents::tenant_id.eq(tenant_id)))
.execute(conn)?;
diesel::delete(folders::table.filter(folders::tenant_id.eq(tenant_id))).execute(conn)?;
diesel::delete(correspondents::table.filter(correspondents::tenant_id.eq(tenant_id)))
.execute(conn)?;
diesel::delete(tags::table.filter(tags::tenant_id.eq(tenant_id))).execute(conn)?;
diesel::delete(user_sessions::table.filter(user_sessions::tenant_id.eq(tenant_id)))
.execute(conn)?;
diesel::delete(api_tokens::table.filter(api_tokens::tenant_id.eq(tenant_id)))
.execute(conn)?;
if remove_memberships {
diesel::delete(
user_memberships::table.filter(user_memberships::tenant_id.eq(tenant_id)),
)
.execute(conn)?;
}
Ok(())
})
}
#[derive(Deserialize, Default)] #[derive(Deserialize, Default)]
struct ProvisionPayload { struct ProvisionPayload {
#[serde(default)] #[serde(default)]
@@ -185,3 +501,140 @@ impl ProvisionPayload {
.ok() .ok()
} }
} }
pub struct DeleteTenantJob;
impl DeleteTenantJob {
pub fn new() -> Self {
Self
}
}
pub fn build_delete_proof_message(
tenant_id: Uuid,
tenant_name: &str,
action: DeleteAction,
nonce: &str,
issued_at: &str,
final_status: Option<&str>,
) -> String {
let status = final_status.unwrap_or("none");
format!(
"{}|{}|{}|{}|{}|{}|{}",
DELETE_PROOF_VERSION,
tenant_id,
tenant_name,
action.as_str(),
nonce,
issued_at,
status
)
}
pub fn sign_delete_proof(secret: &str, message: &str) -> Result<String, String> {
let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
.map_err(|err| format!("failed to init hmac: {err}"))?;
mac.update(message.as_bytes());
let bytes = mac.finalize().into_bytes();
Ok(hex::encode(bytes))
}
fn verify_delete_proof(secret: &str, message: &str, signature: &str) -> Result<(), String> {
let signature_bytes = hex::decode(signature)
.map_err(|_| "invalid delete proof signature encoding".to_string())?;
let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
.map_err(|err| format!("failed to init hmac: {err}"))?;
mac.update(message.as_bytes());
mac.verify_slice(&signature_bytes)
.map_err(|_| "delete proof signature mismatch".to_string())
}
#[derive(Debug, Deserialize)]
struct DeleteTenantPayload {
#[serde(default)]
remove_tenant: bool,
#[serde(default)]
final_status: Option<FinalTenantStatus>,
tenant_name: String,
action: DeleteAction,
nonce: String,
issued_at: String,
signature: String,
}
#[derive(Debug, Deserialize, Clone, Copy)]
#[serde(rename_all = "lowercase")]
enum FinalTenantStatus {
Active,
Suspended,
}
impl FinalTenantStatus {
fn as_str(&self) -> &'static str {
match self {
FinalTenantStatus::Active => "active",
FinalTenantStatus::Suspended => "suspended",
}
}
}
#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum DeleteAction {
Delete,
Reset,
}
impl DeleteAction {
pub fn as_str(&self) -> &'static str {
match self {
DeleteAction::Delete => "delete",
DeleteAction::Reset => "reset",
}
}
}
fn payload_action_applicable(remove_tenant: bool) -> DeleteAction {
if remove_tenant {
DeleteAction::Delete
} else {
DeleteAction::Reset
}
}
#[async_trait]
impl JobHandler for DeleteTenantJob {
fn job_type(&self) -> &'static str {
JOB_DELETE_TENANT
}
async fn handle(
&self,
state: Arc<AppState>,
job: crate::models::Job,
storage: crate::storage::TenantStorage,
) -> JobExecution {
let payload: DeleteTenantPayload = match serde_json::from_value(job.payload.clone()) {
Ok(payload) => payload,
Err(err) => {
return JobExecution::Failed {
error: format!("invalid delete tenant payload: {err}"),
}
}
};
let tenant_id = match job.tenant_id {
Some(id) => id,
None => {
return JobExecution::Failed {
error: "delete job is missing tenant context".to_string(),
}
}
};
match delete_tenant(&state, storage, tenant_id, job.id, &payload).await {
Ok(()) => JobExecution::Success,
Err(err) => JobExecution::Failed { error: err },
}
}
}
File diff suppressed because it is too large Load Diff

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