Compare commits
1
Commits
4ec19dbd70
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c9a9fc060 |
@@ -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 }}
|
|
||||||
@@ -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
|
||||||
@@ -66,6 +66,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
|
||||||
|
|||||||
Generated
+985
-1119
File diff suppressed because it is too large
Load Diff
+26
-17
@@ -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"
|
||||||
|
|||||||
+102
-24
@@ -1,29 +1,114 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1.7
|
||||||
|
|
||||||
FROM rust:1-slim AS builder
|
ARG RUST_VERSION=1
|
||||||
|
FROM --platform=$BUILDPLATFORM rust:${RUST_VERSION}-slim AS builder
|
||||||
|
ARG TARGETARCH
|
||||||
|
ENV TARGETARCH=${TARGETARCH}
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN cat <<'SCRIPT' >/usr/local/bin/resolve-target.sh
|
||||||
&& apt-get install -y --no-install-recommends \
|
#!/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
|
||||||
|
|
||||||
|
ENV PKG_CONFIG_ALLOW_CROSS=1 \
|
||||||
|
CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc \
|
||||||
|
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc \
|
||||||
|
PKG_CONFIG_LIBDIR_aarch64_unknown_linux_gnu=/usr/lib/aarch64-linux-gnu/pkgconfig \
|
||||||
|
PKG_CONFIG_PATH_aarch64_unknown_linux_gnu=/usr/lib/aarch64-linux-gnu/pkgconfig \
|
||||||
|
PKG_CONFIG_SYSROOT_DIR_aarch64_unknown_linux_gnu=/usr/aarch64-linux-gnu \
|
||||||
|
OPENSSL_DIR_aarch64_unknown_linux_gnu=/usr/lib/aarch64-linux-gnu \
|
||||||
|
OPENSSL_LIB_DIR_aarch64_unknown_linux_gnu=/usr/lib/aarch64-linux-gnu \
|
||||||
|
OPENSSL_INCLUDE_DIR_aarch64_unknown_linux_gnu=/usr/include/aarch64-linux-gnu \
|
||||||
|
CC_x86_64_unknown_linux_gnu=x86_64-linux-gnu-gcc \
|
||||||
|
CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=x86_64-linux-gnu-gcc \
|
||||||
|
PKG_CONFIG_LIBDIR_x86_64_unknown_linux_gnu=/usr/lib/x86_64-linux-gnu/pkgconfig \
|
||||||
|
PKG_CONFIG_PATH_x86_64_unknown_linux_gnu=/usr/lib/x86_64-linux-gnu/pkgconfig \
|
||||||
|
OPENSSL_DIR_x86_64_unknown_linux_gnu=/usr/lib/x86_64-linux-gnu \
|
||||||
|
OPENSSL_LIB_DIR_x86_64_unknown_linux_gnu=/usr/lib/x86_64-linux-gnu \
|
||||||
|
OPENSSL_INCLUDE_DIR_x86_64_unknown_linux_gnu=/usr/include/x86_64-linux-gnu
|
||||||
|
|
||||||
|
RUN set -eux; \
|
||||||
|
case "${TARGETARCH}" in \
|
||||||
|
amd64) TARGET_DEB_ARCH=amd64 ;; \
|
||||||
|
arm64) TARGET_DEB_ARCH=arm64 ;; \
|
||||||
|
*) echo "Unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \
|
||||||
|
esac; \
|
||||||
|
BUILD_DEB_ARCH="$(dpkg --print-architecture)"; \
|
||||||
|
if [ "${TARGET_DEB_ARCH}" != "${BUILD_DEB_ARCH}" ]; then \
|
||||||
|
dpkg --add-architecture "${TARGET_DEB_ARCH}"; \
|
||||||
|
fi; \
|
||||||
|
apt-get update; \
|
||||||
|
apt-get install -y --no-install-recommends \
|
||||||
build-essential \
|
build-essential \
|
||||||
pkg-config \
|
pkg-config \
|
||||||
|
curl \
|
||||||
libssl-dev \
|
libssl-dev \
|
||||||
libpq-dev \
|
libpq-dev \
|
||||||
libjpeg-dev \
|
libjpeg-dev \
|
||||||
libpng-dev \
|
libpng-dev \
|
||||||
curl \
|
zlib1g-dev; \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
if [ "${TARGET_DEB_ARCH}" != "${BUILD_DEB_ARCH}" ]; then \
|
||||||
|
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:${TARGET_DEB_ARCH}" \
|
||||||
|
"libssl-dev:${TARGET_DEB_ARCH}" \
|
||||||
|
"libpq-dev:${TARGET_DEB_ARCH}" \
|
||||||
|
"libjpeg-dev:${TARGET_DEB_ARCH}" \
|
||||||
|
"libpng-dev:${TARGET_DEB_ARCH}" \
|
||||||
|
"zlib1g-dev:${TARGET_DEB_ARCH}"; \
|
||||||
|
fi; \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY Cargo.toml Cargo.lock ./
|
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
|
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
|
||||||
|
|
||||||
FROM debian:trixie-slim AS runtime
|
RUN set -eux; \
|
||||||
|
case "${TARGETARCH}" in \
|
||||||
|
amd64) pdfium_package=pdfium-linux-x64.tgz ;; \
|
||||||
|
arm64) pdfium_package=pdfium-linux-arm64.tgz ;; \
|
||||||
|
*) echo "Unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \
|
||||||
|
esac; \
|
||||||
|
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}" ]; \
|
||||||
|
cp "${pdfium_so}" /artifacts/libpdfium.so; \
|
||||||
|
rm -rf /tmp/pdfium.tgz /tmp/pdfium
|
||||||
|
|
||||||
|
FROM --platform=$TARGETPLATFORM debian:trixie-slim AS runtime
|
||||||
|
ARG TARGETARCH
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
@@ -38,25 +123,18 @@ RUN apt-get update \
|
|||||||
tesseract-ocr \
|
tesseract-ocr \
|
||||||
ghostscript \
|
ghostscript \
|
||||||
qpdf \
|
qpdf \
|
||||||
|
ffmpeg \
|
||||||
&& 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=builder /artifacts/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
|
||||||
|
|||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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"
|
||||||
@@ -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,9 +151,12 @@ 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 {
|
||||||
|
let capabilities = load_capabilities_for_set(conn, token.capability_set_id)?;
|
||||||
|
if !capabilities.contains(&required) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if verify_token_secret(secret, &token.token_hash)? {
|
if verify_token_secret(secret, &token.token_hash)? {
|
||||||
return Ok(Some(token));
|
return Ok(Some(token));
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
+69
-7
@@ -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 {
|
||||||
|
subject: DownloadSubject::Document {
|
||||||
doc_id: document_id,
|
doc_id: document_id,
|
||||||
|
version_id,
|
||||||
|
},
|
||||||
|
user_id,
|
||||||
|
tenant_id,
|
||||||
|
iss: self.issuer.clone(),
|
||||||
|
aud: self.download_audience.clone(),
|
||||||
|
iat: now.timestamp() as usize,
|
||||||
|
exp: exp.timestamp() as usize,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(encode(&Header::default(), &claims, &self.encoding)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate_asset_download_token(
|
||||||
|
&self,
|
||||||
|
asset_id: Uuid,
|
||||||
|
user_id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
) -> Result<String> {
|
||||||
|
let now = Utc::now();
|
||||||
|
let exp = now + self.download_expiry;
|
||||||
|
let claims = DownloadClaims {
|
||||||
|
subject: DownloadSubject::Asset { asset_id },
|
||||||
user_id,
|
user_id,
|
||||||
tenant_id,
|
tenant_id,
|
||||||
iss: self.issuer.clone(),
|
iss: self.issuer.clone(),
|
||||||
@@ -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,
|
||||||
|
|||||||
+143
-13
@@ -1,41 +1,116 @@
|
|||||||
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 {
|
||||||
|
let state = state.clone();
|
||||||
|
async move {
|
||||||
if let Some(user) = parts.extensions.get::<AuthenticatedUser>() {
|
if let Some(user) = parts.extensions.get::<AuthenticatedUser>() {
|
||||||
return Ok(user.clone());
|
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())?;
|
.map_err(|_| AppError::unauthorized())?;
|
||||||
|
|
||||||
@@ -44,17 +119,37 @@ impl FromRequestParts<AppState> for AuthenticatedUser {
|
|||||||
.verify_token(bearer.token())
|
.verify_token(bearer.token())
|
||||||
.map_err(|_| AppError::unauthorized())?;
|
.map_err(|_| AppError::unauthorized())?;
|
||||||
|
|
||||||
|
let mut tenant_conn = state.db_for_tenant(claims.tenant_id)?;
|
||||||
|
let capability_set = load_capability_set(&mut tenant_conn, claims.capability_set_id)
|
||||||
|
.map_err(|_| AppError::unauthorized())?;
|
||||||
|
|
||||||
|
if capability_set.cap_version != claims.cap_version {
|
||||||
|
return Err(AppError::unauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
let capabilities = load_capabilities_for_set(&mut tenant_conn, capability_set.id)
|
||||||
|
.map_err(|_| AppError::unauthorized())?;
|
||||||
|
|
||||||
let user = AuthenticatedUser {
|
let user = AuthenticatedUser {
|
||||||
user_id: claims.sub,
|
user_id: claims.sub,
|
||||||
username: claims.username,
|
username: claims.username,
|
||||||
tenant_id: claims.tenant_id,
|
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(user.clone());
|
||||||
|
parts
|
||||||
|
.extensions
|
||||||
|
.insert(TenantConnectionHolder::new(tenant_conn));
|
||||||
|
|
||||||
Ok(user)
|
Ok(user)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct TenantScopedConn {
|
pub struct TenantScopedConn {
|
||||||
pub conn: PgPooledConnection,
|
pub conn: PgPooledConnection,
|
||||||
@@ -69,17 +164,28 @@ 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();
|
||||||
|
async move {
|
||||||
|
let user = AuthenticatedUser::from_request_parts(parts, &state).await?;
|
||||||
let tenant_id = user.tenant_id;
|
let tenant_id = user.tenant_id;
|
||||||
let conn = state.db_for_tenant(tenant_id)?;
|
let mut conn = if let Some(holder) = parts.extensions.remove::<TenantConnectionHolder>()
|
||||||
|
{
|
||||||
|
holder
|
||||||
|
.into_conn()
|
||||||
|
.ok_or_else(|| AppError::internal("tenant connection unavailable"))?
|
||||||
|
} else {
|
||||||
|
state.db_for_tenant(tenant_id)?
|
||||||
|
};
|
||||||
|
|
||||||
|
ensure_active_tenant_with_conn(&mut conn, tenant_id)?;
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
conn,
|
conn,
|
||||||
@@ -89,3 +195,27 @@ impl FromRequestParts<AppState> for TenantScopedConn {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn ensure_active_tenant(state: &AppState, tenant_id: Uuid) -> AppResult<()> {
|
||||||
|
let mut conn = state.db_unscoped()?;
|
||||||
|
ensure_active_tenant_with_conn(&mut conn, tenant_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn ensure_active_tenant_with_conn(
|
||||||
|
conn: &mut PgConnection,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
) -> AppResult<()> {
|
||||||
|
use tenant_dsl::tenants;
|
||||||
|
|
||||||
|
let status: TenantStatus = tenants
|
||||||
|
.find(tenant_id)
|
||||||
|
.select(tenant_dsl::status)
|
||||||
|
.first(conn)?;
|
||||||
|
|
||||||
|
if status != TenantStatus::Active {
|
||||||
|
return Err(AppError::new(StatusCode::FORBIDDEN, "tenant is not active"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,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))?;
|
||||||
|
|||||||
+328
-55
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create_user(pool: &PgPool, username: &str) -> Result<()> {
|
async fn migrate_database(database_url: String) -> Result<()> {
|
||||||
if username.trim().is_empty() {
|
let redacted = redact_database_url(&database_url);
|
||||||
bail!("username must not be empty");
|
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<()> {
|
||||||
|
let username = normalize_identifier(
|
||||||
|
username,
|
||||||
|
100,
|
||||||
|
"username must not be empty",
|
||||||
|
"username must not exceed 100 characters",
|
||||||
|
Some("username may only contain printable characters"),
|
||||||
|
|ch| !ch.is_control(),
|
||||||
|
)
|
||||||
|
.map_err(|err| anyhow!("{:?}", err))?;
|
||||||
|
|
||||||
let mut conn = pool.get().context("failed to get database connection")?;
|
let 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,58 +738,84 @@ 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))?;
|
||||||
|
|
||||||
|
apply_tenant_guc(&mut conn, tenant.id)
|
||||||
|
.map_err(|err| anyhow!("failed to set tenant context for {}: {err:?}", tenant.name))?;
|
||||||
|
|
||||||
|
let result = async {
|
||||||
let tenant_storage = TenantStorage::new(Arc::clone(&storage), &tenant)
|
let tenant_storage = TenantStorage::new(Arc::clone(&storage), &tenant)
|
||||||
.with_context(|| format!("missing storage root for tenant {}", tenant.name))?;
|
.with_context(|| format!("missing storage root for tenant {}", tenant.name))?;
|
||||||
|
|
||||||
let assets: Vec<DocumentAsset> = document_assets::table
|
let mut asset_query = document_assets::table
|
||||||
.filter(document_assets::tenant_id.eq(tenant.id))
|
.filter(document_assets::tenant_id.eq(tenant.id))
|
||||||
|
.into_boxed();
|
||||||
|
|
||||||
|
if let Some(asset_type) = asset_type {
|
||||||
|
asset_query = asset_query.filter(document_assets::asset_type.eq(asset_type));
|
||||||
|
}
|
||||||
|
|
||||||
|
let assets: Vec<DocumentAsset> = asset_query
|
||||||
.load(&mut conn)
|
.load(&mut conn)
|
||||||
.with_context(|| format!("failed to load assets for tenant {}", tenant.name))?;
|
.with_context(|| format!("failed to load assets for tenant {}", tenant.name))?;
|
||||||
|
|
||||||
if assets.is_empty() {
|
if assets.is_empty() {
|
||||||
println!("Tenant {}: no assets", tenant.name);
|
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(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
println!(
|
match asset_type {
|
||||||
|
Some(asset_type) => println!(
|
||||||
|
"Tenant {} ({}): deleting {} '{}' assets…",
|
||||||
|
tenant.name,
|
||||||
|
tenant.id,
|
||||||
|
assets.len(),
|
||||||
|
asset_type
|
||||||
|
),
|
||||||
|
None => println!(
|
||||||
"Tenant {} ({}): deleting {} assets…",
|
"Tenant {} ({}): deleting {} assets…",
|
||||||
tenant.name,
|
tenant.name,
|
||||||
tenant.id,
|
tenant.id,
|
||||||
assets.len()
|
assets.len()
|
||||||
);
|
),
|
||||||
|
}
|
||||||
|
|
||||||
let asset_ids: Vec<Uuid> = assets.iter().map(|asset| asset.id).collect();
|
let asset_ids: Vec<Uuid> = assets.iter().map(|asset| asset.id).collect();
|
||||||
|
|
||||||
let objects: Vec<DocumentAssetObject> = document_asset_objects::table
|
for asset in &assets {
|
||||||
.filter(document_asset_objects::tenant_id.eq(tenant.id))
|
if let Err(err) = tenant_storage.delete_object(&asset.s3_key).await {
|
||||||
.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!(
|
eprintln!(
|
||||||
"Failed to delete object {} (tenant {}): {err}",
|
"Failed to delete object {} (tenant {}): {err}",
|
||||||
object.s3_key, tenant.name
|
asset.s3_key, tenant.name
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
diesel::delete(
|
diesel::delete(
|
||||||
document_asset_objects::table
|
document_assets::table
|
||||||
.filter(document_asset_objects::tenant_id.eq(tenant.id))
|
.filter(document_assets::tenant_id.eq(tenant.id))
|
||||||
.filter(document_asset_objects::asset_id.eq_any(&asset_ids)),
|
.filter(document_assets::id.eq_any(&asset_ids)),
|
||||||
)
|
)
|
||||||
.execute(&mut conn)
|
|
||||||
.with_context(|| format!("failed to remove asset objects for tenant {}", tenant.name))?;
|
|
||||||
|
|
||||||
diesel::delete(document_assets::table.filter(document_assets::tenant_id.eq(tenant.id)))
|
|
||||||
.execute(&mut conn)
|
.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(
|
||||||
config: &AppConfig,
|
config: &AppConfig,
|
||||||
|
|||||||
+76
-1
@@ -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() {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
pub mod responders;
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
use diesel_migrations::{embed_migrations, EmbeddedMigrations};
|
||||||
|
|
||||||
|
pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
|
||||||
+203
-50
@@ -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
@@ -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
@@ -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, ¤t_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,
|
|
||||||
¤t_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")
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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
|
||||||
))
|
))
|
||||||
|
|||||||
+402
-1738
File diff suppressed because it is too large
Load Diff
+85
-471
@@ -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
|
#[derive(OpenApi)]
|
||||||
.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 {
|
|
||||||
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
@@ -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
@@ -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)?;
|
#[derive(OpenApi)]
|
||||||
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,
|
|
||||||
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
|
||||||
))
|
))
|
||||||
)]
|
)]
|
||||||
|
|||||||
+30
-30
@@ -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},
|
||||||
|
utils::{
|
||||||
json::deserialize_patch_field,
|
json::deserialize_patch_field,
|
||||||
named_entity::{ensure_name_available, normalize_name},
|
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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-29
@@ -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(),
|
||||||
|
|
||||||
#[allow(deprecated)]
|
|
||||||
let mut loader = aws_config::from_env().region(region_provider);
|
|
||||||
|
|
||||||
if let Some(endpoint) = &config.aws_endpoint_url {
|
|
||||||
loader = loader.endpoint_url(endpoint);
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
config
|
||||||
|
.aws_region
|
||||||
|
.parse::<Region>()
|
||||||
|
.context("invalid AWS region")?
|
||||||
|
};
|
||||||
|
|
||||||
if let (Some(access_key), Some(secret_key)) = (
|
let credentials = if let (Some(access_key), Some(secret_key)) = (
|
||||||
config.aws_access_key_id.clone(),
|
config.aws_access_key_id.as_deref(),
|
||||||
config.aws_secret_access_key.clone(),
|
config.aws_secret_access_key.as_deref(),
|
||||||
) {
|
) {
|
||||||
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
@@ -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,
|
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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, ¤t_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,
|
||||||
|
¤t_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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+58
-55
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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")
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
+209
-107
@@ -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> {
|
const MIME_SNIFF_BYTES: usize = 8192;
|
||||||
|
|
||||||
|
impl AnalyzePlanner {
|
||||||
|
fn new(force: bool, state: Arc<AppState>) -> Self {
|
||||||
|
Self { force, state }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl TaskPlanner<DocumentVersionTaskContext> for AnalyzePlanner {
|
||||||
|
async fn plan(
|
||||||
|
&self,
|
||||||
|
ctx: &mut DocumentVersionTaskContext,
|
||||||
|
) -> TaskResult<Vec<BoxedTask<DocumentVersionTaskContext>>> {
|
||||||
|
let document = ctx.document().await?.clone();
|
||||||
|
let mut tasks: Vec<BoxedTask<DocumentVersionTaskContext>> = Vec::new();
|
||||||
|
|
||||||
|
tasks.push(Box::new(EnsureMimeTask));
|
||||||
|
|
||||||
|
let (thumbnail_supported, _) = determine_thumbnail_support(&document);
|
||||||
|
if thumbnail_supported {
|
||||||
|
tasks.push(Box::new(GenerateThumbnailsTask::new(self.force)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let existing_ocr = ctx.asset(TEXT_CONTENT_ASSET_TYPE).await?.is_some();
|
||||||
|
let mut should_index = existing_ocr;
|
||||||
|
|
||||||
|
if document_supports_ocr(&document) {
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
let mut conn = state
|
||||||
.db_for_tenant(tenant_id)
|
.db_for_tenant(tenant_id)
|
||||||
.map_err(|err| format!("{err:?}"))?;
|
.map_err(|err| format!("{err:?}"))?;
|
||||||
|
|
||||||
let version: DocumentVersion = document_versions::table
|
diesel::update(
|
||||||
.find(payload.document_version_id)
|
crate::schema::documents::table.filter(crate::schema::documents::id.eq(document_id)),
|
||||||
.first(&mut conn)
|
|
||||||
.map_err(|err| {
|
|
||||||
format!(
|
|
||||||
"failed to load document_version {} for tenant {}: {err:?}",
|
|
||||||
payload.document_version_id, tenant_id
|
|
||||||
)
|
)
|
||||||
})?;
|
.set(crate::schema::documents::mime_type.eq(Some(mime_type_clone)))
|
||||||
|
.execute(&mut conn)
|
||||||
if version.document_id != payload.document_id {
|
.map_err(|err| format!("{err:?}"))
|
||||||
return Err("document/version mismatch".into());
|
.map(|_| ())
|
||||||
}
|
})
|
||||||
|
.await
|
||||||
let document: Document = documents::table
|
|
||||||
.find(payload.document_id)
|
|
||||||
.first(&mut conn)
|
|
||||||
.map_err(|err| {
|
.map_err(|err| {
|
||||||
format!(
|
TaskError::retry(
|
||||||
"failed to load document {} for tenant {}: {err:?}",
|
Duration::from_secs(60),
|
||||||
payload.document_id, tenant_id
|
format!("mime update task panicked: {err}"),
|
||||||
)
|
)
|
||||||
})?;
|
})?
|
||||||
|
.map_err(|err| TaskError::retry(Duration::from_secs(30), err))?;
|
||||||
|
|
||||||
let tenant_id = document.tenant_id;
|
ctx.set_document_mime(Some(mime_type));
|
||||||
|
Ok(())
|
||||||
let (supported, _reason) = determine_thumbnail_support(&document);
|
|
||||||
let ocr_supported = document_is_pdf(&document);
|
|
||||||
|
|
||||||
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 enqueue_result = enqueue_job(
|
|
||||||
&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 {
|
|
||||||
return Err(err.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(JobExecution::Success)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+52
-186
@@ -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) {
|
|
||||||
Ok(tenant) => tenant,
|
|
||||||
Err(err) => {
|
|
||||||
warn!(job_id = %job.id, error = ?err, "failed to load tenant for indexing");
|
|
||||||
return JobExecution::Retry {
|
|
||||||
delay: Duration::from_secs(30),
|
|
||||||
error: format!("failed to load tenant: {err:?}"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let quickwit_index = match tenant.quickwit_index.clone() {
|
|
||||||
Some(index) => index,
|
|
||||||
None => {
|
|
||||||
return JobExecution::Failed {
|
|
||||||
error: "tenant quickwit index not configured".into(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let client = Client::new();
|
|
||||||
|
|
||||||
let state_clone = state.clone();
|
|
||||||
let tenant_id = job.tenant_id;
|
|
||||||
let context =
|
|
||||||
match task::spawn_blocking(move || load_context(state_clone, tenant_id, payload)).await
|
|
||||||
{
|
|
||||||
Ok(Ok(ctx)) => ctx,
|
|
||||||
Ok(Err(err)) => {
|
|
||||||
warn!(job_id = %job.id, error = %err, "index job will retry");
|
|
||||||
return JobExecution::Retry {
|
|
||||||
delay: Duration::from_secs(30),
|
|
||||||
error: err,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
Err(join_err) => {
|
|
||||||
error!(job_id = %job.id, error = %join_err, "index task panicked");
|
|
||||||
return JobExecution::Retry {
|
|
||||||
delay: Duration::from_secs(60),
|
|
||||||
error: format!("worker panicked: {join_err}"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if context.text_s3_key.is_none() {
|
|
||||||
warn!(job_id = %job.id, "missing OCR text asset; failing indexing job");
|
|
||||||
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,
|
let quickwit_index = tenant
|
||||||
Err(err) => return handle_fetch_error(&job, err, "failed to download ocr text"),
|
.quickwit_index
|
||||||
};
|
.clone()
|
||||||
let text = match String::from_utf8(bytes) {
|
.ok_or_else(|| TaskError::fail("tenant quickwit index not configured"))?;
|
||||||
Ok(text) => text,
|
|
||||||
Err(err) => {
|
let asset = ctx
|
||||||
warn!(job_id = %job.id, error = %err, "ocr text not valid UTF-8");
|
.asset(TEXT_CONTENT_ASSET_TYPE)
|
||||||
return JobExecution::Failed {
|
.await?
|
||||||
error: "ocr text not valid UTF-8".into(),
|
.ok_or_else(|| TaskError::fail("missing OCR text asset"))?;
|
||||||
};
|
|
||||||
}
|
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 text = String::from_utf8(bytes)
|
||||||
|
.map_err(|err| TaskError::fail(format!("ocr text not valid UTF-8: {err}")))?;
|
||||||
|
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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}"))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
+537
-84
@@ -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_id = %tenant.id,
|
|
||||||
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 '{}' not eligible for provisioning",
|
||||||
tenant.status.as_str()
|
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,25 +188,36 @@ 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,
|
|
||||||
"failed to ensure quickwit index"
|
|
||||||
);
|
|
||||||
return JobExecution::Retry {
|
|
||||||
delay: std::time::Duration::from_secs(30),
|
|
||||||
error: err.to_string(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(members) = ProvisionPayload::from_job(&job) {
|
let owner_capability_set_id =
|
||||||
for member in members {
|
ensure_capability_set(&mut conn, tenant.id, owner_capabilities())
|
||||||
|
.map_err(|_| {
|
||||||
|
TaskError::retry(Duration::from_secs(30), "owner capability set unavailable")
|
||||||
|
})?
|
||||||
|
.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 {
|
let new_membership = NewUserMembership {
|
||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
user_id: member,
|
user_id: *member,
|
||||||
tenant_id: tenant.id,
|
tenant_id: tenant.id,
|
||||||
|
capability_set_id: Some(owner_capability_set_id),
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Err(err) = diesel::insert_into(user_memberships::table)
|
if let Err(err) = diesel::insert_into(user_memberships::table)
|
||||||
@@ -143,7 +227,7 @@ impl JobHandler for ProvisionTenantJob {
|
|||||||
.execute(&mut conn)
|
.execute(&mut conn)
|
||||||
{
|
{
|
||||||
warn!(
|
warn!(
|
||||||
job_id = %job.id,
|
job_id = %ctx.job_id(),
|
||||||
tenant_id = %tenant.id,
|
tenant_id = %tenant.id,
|
||||||
user_id = %member,
|
user_id = %member,
|
||||||
error = %err,
|
error = %err,
|
||||||
@@ -151,25 +235,257 @@ impl JobHandler for ProvisionTenantJob {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
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}"),
|
)
|
||||||
};
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
JobExecution::Success
|
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)]
|
||||||
@@ -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 },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+385
-538
File diff suppressed because it is too large
Load Diff
@@ -1,27 +1,42 @@
|
|||||||
mod common;
|
use anyhow::{Context, Result};
|
||||||
|
|
||||||
use anyhow::Result;
|
|
||||||
use axum::body::Body;
|
use axum::body::Body;
|
||||||
use axum::http::{header, Method, Request, StatusCode};
|
use axum::http::{header, Method, Request, StatusCode};
|
||||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use common::{acquire_db_lock, body_to_vec, TestApp};
|
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
use papercrate::models::{ApiToken, ApiTokenCapability};
|
use diesel::OptionalExtension;
|
||||||
|
use papercrate::auth::capability_sets;
|
||||||
|
use papercrate::models::{ApiCapability, ApiToken};
|
||||||
use papercrate::routes::webdav;
|
use papercrate::routes::webdav;
|
||||||
use papercrate::schema::api_tokens;
|
use papercrate::schema::api_tokens;
|
||||||
|
use papercrate::schema::capability_sets::dsl as capability_sets_dsl;
|
||||||
|
use papercrate::test_support::{acquire_db_lock, body_to_vec, TestApp, TestUserRole};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use tower::ServiceExt;
|
use tower::ServiceExt;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
const LEGACY_WEBDAV_CAPS: &[&str] = &[
|
||||||
|
"documents:edit",
|
||||||
|
"documents:read",
|
||||||
|
"documents:upload",
|
||||||
|
"documents:write",
|
||||||
|
"folders:edit",
|
||||||
|
"folders:read",
|
||||||
|
"folders:write",
|
||||||
|
"webdav:read",
|
||||||
|
];
|
||||||
|
|
||||||
|
const READ_ONLY_CAPS: &[&str] = &["documents:read"];
|
||||||
|
const LIMITED_WEBDAV_CAPS: &[&str] = &["documents:read", "webdav:read"];
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct TokenInfo {
|
struct TokenInfo {
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
label: Option<String>,
|
label: Option<String>,
|
||||||
last_used_at: Option<String>,
|
last_used_at: Option<String>,
|
||||||
revoked_at: Option<String>,
|
revoked_at: Option<String>,
|
||||||
capabilities: Vec<ApiTokenCapability>,
|
capability_set_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -52,34 +67,28 @@ async fn api_token_crud_flow() -> Result<()> {
|
|||||||
|
|
||||||
let username = "alice";
|
let username = "alice";
|
||||||
let password = "correct horse battery";
|
let password = "correct horse battery";
|
||||||
app.insert_user(username, password, "admin").await?;
|
app.insert_user(username, TestUserRole::Owner).await?;
|
||||||
let access_token = app.login_token(username, password).await?;
|
let access_token = app.login_token(username, password).await?;
|
||||||
|
|
||||||
let created = create_token(&app, &access_token, json!({ "label": "dav" })).await?;
|
let legacy_set_id =
|
||||||
|
ensure_capability_set_slug(&app, &access_token, "legacy_webdav", LEGACY_WEBDAV_CAPS)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let created = create_token(&app, &access_token, Some("dav"), legacy_set_id, None).await?;
|
||||||
let token_id = created.info.id;
|
let token_id = created.info.id;
|
||||||
assert_eq!(created.info.label.as_deref(), Some("dav"));
|
assert_eq!(created.info.label.as_deref(), Some("dav"));
|
||||||
assert!(created.info.last_used_at.is_none());
|
assert!(created.info.last_used_at.is_none());
|
||||||
assert_eq!(created.info.capabilities, vec![ApiTokenCapability::Webdav]);
|
assert_eq!(created.info.capability_set_id, legacy_set_id);
|
||||||
|
|
||||||
let regenerated = regenerate_token(&app, &access_token, token_id).await?;
|
let regenerated = regenerate_token(&app, &access_token, token_id).await?;
|
||||||
assert_eq!(regenerated.info.id, token_id);
|
assert_eq!(regenerated.info.id, token_id);
|
||||||
assert_ne!(regenerated.token, created.token);
|
assert_ne!(regenerated.token, created.token);
|
||||||
assert!(regenerated.info.last_used_at.is_none());
|
assert!(regenerated.info.last_used_at.is_none());
|
||||||
|
|
||||||
let updated = update_token_capabilities(
|
|
||||||
&app,
|
|
||||||
&access_token,
|
|
||||||
token_id,
|
|
||||||
json!({ "capabilities": ["webdav", "api"] }),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
assert_eq!(updated.capabilities.len(), 2);
|
|
||||||
assert!(updated.capabilities.contains(&ApiTokenCapability::Webdav));
|
|
||||||
assert!(updated.capabilities.contains(&ApiTokenCapability::Api));
|
|
||||||
|
|
||||||
let listed = list_tokens(&app, &access_token).await?;
|
let listed = list_tokens(&app, &access_token).await?;
|
||||||
assert_eq!(listed.len(), 1);
|
assert_eq!(listed.len(), 1);
|
||||||
assert_eq!(listed[0].id, token_id);
|
assert_eq!(listed[0].id, token_id);
|
||||||
|
assert_eq!(listed[0].capability_set_id, legacy_set_id);
|
||||||
|
|
||||||
let tenant_id_for_token = app
|
let tenant_id_for_token = app
|
||||||
.with_conn(move |conn| {
|
.with_conn(move |conn| {
|
||||||
@@ -91,6 +100,15 @@ async fn api_token_crud_flow() -> Result<()> {
|
|||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
let readonly_set_id =
|
||||||
|
ensure_capability_set_slug(&app, &access_token, "readonly", READ_ONLY_CAPS).await?;
|
||||||
|
|
||||||
|
let readonly_token =
|
||||||
|
create_token(&app, &access_token, Some("readonly"), readonly_set_id, None).await?;
|
||||||
|
let readonly_exchange = exchange_token(&app, &readonly_token.token).await?;
|
||||||
|
assert_eq!(readonly_exchange.tenant.id, tenant_id_for_token);
|
||||||
|
delete_token(&app, &access_token, readonly_token.info.id).await?;
|
||||||
|
|
||||||
let exchange = exchange_token(&app, ®enerated.token).await?;
|
let exchange = exchange_token(&app, ®enerated.token).await?;
|
||||||
assert_eq!(exchange.token_type, "Bearer");
|
assert_eq!(exchange.token_type, "Bearer");
|
||||||
assert!(!exchange.access_token.is_empty());
|
assert!(!exchange.access_token.is_empty());
|
||||||
@@ -101,9 +119,11 @@ async fn api_token_crud_flow() -> Result<()> {
|
|||||||
delete_token(&app, &access_token, token_id).await?;
|
delete_token(&app, &access_token, token_id).await?;
|
||||||
|
|
||||||
let listed_after = list_tokens(&app, &access_token).await?;
|
let listed_after = list_tokens(&app, &access_token).await?;
|
||||||
assert_eq!(listed_after.len(), 1);
|
let revoked_entry = listed_after
|
||||||
assert_eq!(listed_after[0].id, token_id);
|
.iter()
|
||||||
assert!(listed_after[0].revoked_at.is_some());
|
.find(|entry| entry.id == token_id)
|
||||||
|
.expect("revoked token still listed");
|
||||||
|
assert!(revoked_entry.revoked_at.is_some());
|
||||||
|
|
||||||
app.cleanup().await?;
|
app.cleanup().await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -116,10 +136,14 @@ async fn webdav_basic_auth_uses_api_tokens() -> Result<()> {
|
|||||||
|
|
||||||
let username = "bruce";
|
let username = "bruce";
|
||||||
let password = "wayne";
|
let password = "wayne";
|
||||||
app.insert_user(username, password, "admin").await?;
|
app.insert_user(username, TestUserRole::Owner).await?;
|
||||||
let access_token = app.login_token(username, password).await?;
|
let access_token = app.login_token(username, password).await?;
|
||||||
|
|
||||||
let created = create_token(&app, &access_token, json!({ "label": "webdav" })).await?;
|
let legacy_set_id =
|
||||||
|
ensure_capability_set_slug(&app, &access_token, "legacy_webdav", LEGACY_WEBDAV_CAPS)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let created = create_token(&app, &access_token, Some("webdav"), legacy_set_id, None).await?;
|
||||||
let token_id = created.info.id;
|
let token_id = created.info.id;
|
||||||
|
|
||||||
let router = webdav::create_router().with_state(app.state.clone());
|
let router = webdav::create_router().with_state(app.state.clone());
|
||||||
@@ -187,6 +211,61 @@ async fn webdav_basic_auth_uses_api_tokens() -> Result<()> {
|
|||||||
let response = router.clone().oneshot(success_request).await?;
|
let response = router.clone().oneshot(success_request).await?;
|
||||||
assert_eq!(response.status(), StatusCode::MULTI_STATUS);
|
assert_eq!(response.status(), StatusCode::MULTI_STATUS);
|
||||||
|
|
||||||
|
// Token without webdav_read cannot authenticate.
|
||||||
|
let read_only_set_id =
|
||||||
|
ensure_capability_set_slug(&app, &access_token, "documents_read", READ_ONLY_CAPS).await?;
|
||||||
|
|
||||||
|
let limited_token =
|
||||||
|
create_token(&app, &access_token, Some("limited"), read_only_set_id, None).await?;
|
||||||
|
assert_eq!(limited_token.info.capability_set_id, read_only_set_id);
|
||||||
|
|
||||||
|
let limited_header = format!(
|
||||||
|
"Basic {}",
|
||||||
|
BASE64.encode(format!("{}:{}", username, limited_token.token))
|
||||||
|
);
|
||||||
|
|
||||||
|
let limited_request = Request::builder()
|
||||||
|
.method(propfind.clone())
|
||||||
|
.uri("/")
|
||||||
|
.header(header::AUTHORIZATION, limited_header.clone())
|
||||||
|
.header("depth", "0")
|
||||||
|
.body(Body::empty())?;
|
||||||
|
let limited_response = router.clone().oneshot(limited_request).await?;
|
||||||
|
assert_eq!(limited_response.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
|
||||||
|
let limited_set_id = ensure_capability_set_slug(
|
||||||
|
&app,
|
||||||
|
&access_token,
|
||||||
|
"documents_read_webdav",
|
||||||
|
LIMITED_WEBDAV_CAPS,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let upgraded_token = create_token(
|
||||||
|
&app,
|
||||||
|
&access_token,
|
||||||
|
Some("limited-webdav"),
|
||||||
|
limited_set_id,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_eq!(upgraded_token.info.capability_set_id, limited_set_id);
|
||||||
|
|
||||||
|
let upgraded_request = Request::builder()
|
||||||
|
.method(propfind.clone())
|
||||||
|
.uri("/")
|
||||||
|
.header(
|
||||||
|
header::AUTHORIZATION,
|
||||||
|
format!(
|
||||||
|
"Basic {}",
|
||||||
|
BASE64.encode(format!("{}:{}", username, upgraded_token.token))
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.header("depth", "0")
|
||||||
|
.body(Body::empty())?;
|
||||||
|
let upgraded_response = router.clone().oneshot(upgraded_request).await?;
|
||||||
|
assert_eq!(upgraded_response.status(), StatusCode::MULTI_STATUS);
|
||||||
|
|
||||||
delete_token(&app, &access_token, token_id).await?;
|
delete_token(&app, &access_token, token_id).await?;
|
||||||
|
|
||||||
let failure_request = Request::builder()
|
let failure_request = Request::builder()
|
||||||
@@ -205,8 +284,22 @@ async fn webdav_basic_auth_uses_api_tokens() -> Result<()> {
|
|||||||
async fn create_token(
|
async fn create_token(
|
||||||
app: &TestApp,
|
app: &TestApp,
|
||||||
access_token: &str,
|
access_token: &str,
|
||||||
payload: serde_json::Value,
|
label: Option<&str>,
|
||||||
|
capability_set_id: Uuid,
|
||||||
|
expires_at: Option<&str>,
|
||||||
) -> Result<CreateTokenResponse> {
|
) -> Result<CreateTokenResponse> {
|
||||||
|
let mut payload = json!({
|
||||||
|
"capability_set_id": capability_set_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(label) = label {
|
||||||
|
payload["label"] = json!(label);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(expires) = expires_at {
|
||||||
|
payload["expires_at"] = json!(expires);
|
||||||
|
}
|
||||||
|
|
||||||
let response = app
|
let response = app
|
||||||
.post_json("/api/profile/api-tokens", &payload, Some(access_token))
|
.post_json("/api/profile/api-tokens", &payload, Some(access_token))
|
||||||
.await?;
|
.await?;
|
||||||
@@ -232,24 +325,6 @@ async fn regenerate_token(
|
|||||||
Ok(serde_json::from_slice(&body)?)
|
Ok(serde_json::from_slice(&body)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn update_token_capabilities(
|
|
||||||
app: &TestApp,
|
|
||||||
access_token: &str,
|
|
||||||
token_id: Uuid,
|
|
||||||
payload: serde_json::Value,
|
|
||||||
) -> Result<TokenInfo> {
|
|
||||||
let response = app
|
|
||||||
.patch_json(
|
|
||||||
&format!("/api/profile/api-tokens/{token_id}"),
|
|
||||||
&payload,
|
|
||||||
Some(access_token),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
|
||||||
let body = body_to_vec(response.into_body()).await?;
|
|
||||||
Ok(serde_json::from_slice(&body)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn list_tokens(app: &TestApp, access_token: &str) -> Result<Vec<TokenInfo>> {
|
async fn list_tokens(app: &TestApp, access_token: &str) -> Result<Vec<TokenInfo>> {
|
||||||
let response = app
|
let response = app
|
||||||
.get("/api/profile/api-tokens", Some(access_token))
|
.get("/api/profile/api-tokens", Some(access_token))
|
||||||
@@ -269,6 +344,50 @@ async fn delete_token(app: &TestApp, access_token: &str, token_id: Uuid) -> Resu
|
|||||||
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn ensure_capability_set_slug(
|
||||||
|
app: &TestApp,
|
||||||
|
access_token: &str,
|
||||||
|
slug: &str,
|
||||||
|
capabilities: &[&str],
|
||||||
|
) -> Result<Uuid> {
|
||||||
|
let claims = app
|
||||||
|
.state
|
||||||
|
.jwt
|
||||||
|
.verify_token(access_token)
|
||||||
|
.context("failed to decode access token claims")?;
|
||||||
|
let tenant_id = claims.tenant_id;
|
||||||
|
let slug = slug.to_string();
|
||||||
|
let desired_capabilities = capabilities
|
||||||
|
.iter()
|
||||||
|
.map(|value| {
|
||||||
|
value
|
||||||
|
.parse::<ApiCapability>()
|
||||||
|
.map_err(|err| anyhow::anyhow!("invalid capability '{value}': {err}"))
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>>>()?;
|
||||||
|
|
||||||
|
let caps_for_insert = desired_capabilities.clone();
|
||||||
|
app.with_conn(move |conn| {
|
||||||
|
if let Some(existing) = capability_sets_dsl::capability_sets
|
||||||
|
.filter(capability_sets_dsl::tenant_id.eq(tenant_id))
|
||||||
|
.filter(capability_sets_dsl::slug.eq(&slug))
|
||||||
|
.select(capability_sets_dsl::id)
|
||||||
|
.first::<Uuid>(conn)
|
||||||
|
.optional()?
|
||||||
|
{
|
||||||
|
return Ok(existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
let created =
|
||||||
|
capability_sets::create_capability_set(conn, tenant_id, &slug, caps_for_insert)
|
||||||
|
.map_err(|err| {
|
||||||
|
anyhow::anyhow!("failed to create capability set '{slug}': {err:?}")
|
||||||
|
})?;
|
||||||
|
Ok(created.id)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
async fn exchange_token(app: &TestApp, api_token: &str) -> Result<LoginResponseView> {
|
async fn exchange_token(app: &TestApp, api_token: &str) -> Result<LoginResponseView> {
|
||||||
let response = app
|
let response = app
|
||||||
.post_json(
|
.post_json(
|
||||||
|
|||||||
+131
-42
@@ -1,19 +1,23 @@
|
|||||||
mod common;
|
|
||||||
|
|
||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
use axum::http::{header::SET_COOKIE, StatusCode};
|
use axum::http::{header::SET_COOKIE, StatusCode};
|
||||||
use chrono::{Duration as ChronoDuration, Utc};
|
use chrono::{Duration as ChronoDuration, Utc};
|
||||||
use common::{acquire_db_lock, body_to_vec, TestApp};
|
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
|
use papercrate::auth::capability_sets::{ensure_capability_set, owner_capabilities};
|
||||||
|
use papercrate::auth::jwt::{AccessTokenContext, PrincipalKind};
|
||||||
use papercrate::auth::passkeys::{
|
use papercrate::auth::passkeys::{
|
||||||
PasskeyLoginFinishPayload, PasskeyLoginStartPayload, PasskeyRegistrationFinishPayload,
|
PasskeyLoginFinishPayload, PasskeyLoginStartPayload, PasskeyRegistrationFinishPayload,
|
||||||
RegistrationChallengeResponse,
|
RegistrationChallengeResponse,
|
||||||
};
|
};
|
||||||
use papercrate::models::{NewUserMembership, NewUserSession, TenantStatus, UserPasskey};
|
use papercrate::models::{
|
||||||
|
MagicToken, MagicTokenKind, NewUserMembership, NewUserSession, TenantStatus, UserPasskey,
|
||||||
|
};
|
||||||
use papercrate::openapi::schemas::PasskeySummary;
|
use papercrate::openapi::schemas::PasskeySummary;
|
||||||
use papercrate::schema::{tenants, user_memberships, user_sessions, users};
|
use papercrate::schema::{
|
||||||
use rand::rngs::OsRng;
|
capability_sets, magic_tokens::dsl as magic_dsl, tenants, tenants::dsl as tenant_dsl,
|
||||||
use rand::RngCore;
|
user_memberships, user_sessions, users,
|
||||||
|
};
|
||||||
|
use papercrate::test_support::{acquire_db_lock, body_to_vec, TestApp, TestUserRole};
|
||||||
|
use rand::{rngs::OsRng, TryRngCore};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
@@ -53,19 +57,6 @@ struct SignupStartResponse {
|
|||||||
challenge: RegistrationChallengeResponse,
|
challenge: RegistrationChallengeResponse,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct TenantSelectionResponse {
|
|
||||||
#[serde(rename = "access_token")]
|
|
||||||
_access_token: String,
|
|
||||||
#[serde(rename = "tenants")]
|
|
||||||
_tenants: Vec<TenantSummary>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct TenantListResponse {
|
|
||||||
tenants: Vec<TenantSummary>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct TenantSummary {
|
struct TenantSummary {
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
@@ -78,7 +69,7 @@ async fn login_and_me_roundtrip() -> Result<()> {
|
|||||||
let app = TestApp::new().await?;
|
let app = TestApp::new().await?;
|
||||||
|
|
||||||
let password = "s3cret";
|
let password = "s3cret";
|
||||||
app.insert_user("alice", password, "admin").await?;
|
app.insert_user("alice", TestUserRole::Owner).await?;
|
||||||
|
|
||||||
let (login, _) = login_with_session(&app, "alice", password).await?;
|
let (login, _) = login_with_session(&app, "alice", password).await?;
|
||||||
|
|
||||||
@@ -154,7 +145,7 @@ async fn passkey_register_start_creates_challenge() -> Result<()> {
|
|||||||
let app = TestApp::new().await?;
|
let app = TestApp::new().await?;
|
||||||
|
|
||||||
let password = "secret";
|
let password = "secret";
|
||||||
app.insert_user("passkey-user", password, "admin").await?;
|
app.insert_user("passkey-user", TestUserRole::Owner).await?;
|
||||||
|
|
||||||
let (login, _) = login_with_session(&app, "passkey-user", password).await?;
|
let (login, _) = login_with_session(&app, "passkey-user", password).await?;
|
||||||
|
|
||||||
@@ -195,7 +186,7 @@ async fn passkey_register_finish_rejects_unknown_challenge() -> Result<()> {
|
|||||||
let app = TestApp::new().await?;
|
let app = TestApp::new().await?;
|
||||||
|
|
||||||
let password = "secret";
|
let password = "secret";
|
||||||
app.insert_user("passkey-register", password, "admin")
|
app.insert_user("passkey-register", TestUserRole::Owner)
|
||||||
.await?;
|
.await?;
|
||||||
let (login, _) = login_with_session(&app, "passkey-register", password).await?;
|
let (login, _) = login_with_session(&app, "passkey-register", password).await?;
|
||||||
|
|
||||||
@@ -224,8 +215,9 @@ async fn passkey_login_start_requires_passkey() -> Result<()> {
|
|||||||
let _lock = acquire_db_lock().await;
|
let _lock = acquire_db_lock().await;
|
||||||
let app = TestApp::new().await?;
|
let app = TestApp::new().await?;
|
||||||
|
|
||||||
let password = "secret";
|
let _password = "secret";
|
||||||
app.insert_user("passkey-login", password, "admin").await?;
|
app.insert_user("passkey-login", TestUserRole::Owner)
|
||||||
|
.await?;
|
||||||
|
|
||||||
let payload = PasskeyLoginStartPayload {
|
let payload = PasskeyLoginStartPayload {
|
||||||
username: "passkey-login".to_string(),
|
username: "passkey-login".to_string(),
|
||||||
@@ -286,7 +278,9 @@ async fn list_passkeys_returns_entries() -> Result<()> {
|
|||||||
let app = TestApp::new().await?;
|
let app = TestApp::new().await?;
|
||||||
|
|
||||||
let password = "secret";
|
let password = "secret";
|
||||||
let user_id = app.insert_user("passkey-owner", password, "admin").await?;
|
let user_id = app
|
||||||
|
.insert_user("passkey-owner", TestUserRole::Owner)
|
||||||
|
.await?;
|
||||||
app.insert_passkey(user_id, Some("Laptop")).await?;
|
app.insert_passkey(user_id, Some("Laptop")).await?;
|
||||||
|
|
||||||
let (session, _) = login_with_session(&app, "passkey-owner", password).await?;
|
let (session, _) = login_with_session(&app, "passkey-owner", password).await?;
|
||||||
@@ -312,7 +306,9 @@ async fn delete_passkey_soft_revokes() -> Result<()> {
|
|||||||
let app = TestApp::new().await?;
|
let app = TestApp::new().await?;
|
||||||
|
|
||||||
let password = "secret";
|
let password = "secret";
|
||||||
let user_id = app.insert_user("passkey-delete", password, "admin").await?;
|
let user_id = app
|
||||||
|
.insert_user("passkey-delete", TestUserRole::Owner)
|
||||||
|
.await?;
|
||||||
let passkey_id = app.insert_passkey(user_id, Some("Phone")).await?;
|
let passkey_id = app.insert_passkey(user_id, Some("Phone")).await?;
|
||||||
app.insert_passkey(user_id, Some("Backup")).await?;
|
app.insert_passkey(user_id, Some("Backup")).await?;
|
||||||
let (session, _) = login_with_session(&app, "passkey-delete", password).await?;
|
let (session, _) = login_with_session(&app, "passkey-delete", password).await?;
|
||||||
@@ -347,7 +343,9 @@ async fn delete_passkey_prevents_last() -> Result<()> {
|
|||||||
let app = TestApp::new().await?;
|
let app = TestApp::new().await?;
|
||||||
|
|
||||||
let password = "secret";
|
let password = "secret";
|
||||||
let user_id = app.insert_user("passkey-guard", password, "admin").await?;
|
let user_id = app
|
||||||
|
.insert_user("passkey-guard", TestUserRole::Owner)
|
||||||
|
.await?;
|
||||||
let first_id = app.insert_passkey(user_id, Some("Key A")).await?;
|
let first_id = app.insert_passkey(user_id, Some("Key A")).await?;
|
||||||
let last_id = app.insert_passkey(user_id, Some("Key B")).await?;
|
let last_id = app.insert_passkey(user_id, Some("Key B")).await?;
|
||||||
let (session, _) = login_with_session(&app, "passkey-guard", password).await?;
|
let (session, _) = login_with_session(&app, "passkey-guard", password).await?;
|
||||||
@@ -406,8 +404,8 @@ async fn login_rejects_invalid_password() -> Result<()> {
|
|||||||
let _lock = acquire_db_lock().await;
|
let _lock = acquire_db_lock().await;
|
||||||
let app = TestApp::new().await?;
|
let app = TestApp::new().await?;
|
||||||
|
|
||||||
let password = "valid";
|
let _password = "valid";
|
||||||
app.insert_user("robin", password, "admin").await?;
|
app.insert_user("robin", TestUserRole::Owner).await?;
|
||||||
|
|
||||||
let payload = json!({ "username": "robin", "password": "wrong" });
|
let payload = json!({ "username": "robin", "password": "wrong" });
|
||||||
let response = app.post_json("/api/auth/login", &payload, None).await?;
|
let response = app.post_json("/api/auth/login", &payload, None).await?;
|
||||||
@@ -426,7 +424,7 @@ async fn refresh_rotates_refresh_token() -> Result<()> {
|
|||||||
let app = TestApp::new().await?;
|
let app = TestApp::new().await?;
|
||||||
|
|
||||||
let password = "rotate";
|
let password = "rotate";
|
||||||
app.insert_user("rita", password, "admin").await?;
|
app.insert_user("rita", TestUserRole::Owner).await?;
|
||||||
|
|
||||||
let (login, refresh_cookie) = login_with_session(&app, "rita", password).await?;
|
let (login, refresh_cookie) = login_with_session(&app, "rita", password).await?;
|
||||||
|
|
||||||
@@ -462,7 +460,7 @@ async fn logout_revokes_refresh_token() -> Result<()> {
|
|||||||
let app = TestApp::new().await?;
|
let app = TestApp::new().await?;
|
||||||
|
|
||||||
let password = "logout";
|
let password = "logout";
|
||||||
app.insert_user("logan", password, "admin").await?;
|
app.insert_user("logan", TestUserRole::Owner).await?;
|
||||||
|
|
||||||
let (login, refresh_cookie) = login_with_session(&app, "logan", password).await?;
|
let (login, refresh_cookie) = login_with_session(&app, "logan", password).await?;
|
||||||
|
|
||||||
@@ -508,7 +506,7 @@ async fn login_returns_tenant_selection_when_multiple_memberships() -> Result<()
|
|||||||
let app = TestApp::new().await?;
|
let app = TestApp::new().await?;
|
||||||
|
|
||||||
let password = "multipass";
|
let password = "multipass";
|
||||||
let user_id = app.insert_user("multipass", password, "admin").await?;
|
let user_id = app.insert_user("multipass", TestUserRole::Owner).await?;
|
||||||
|
|
||||||
let secondary_name = "secondary".to_string();
|
let secondary_name = "secondary".to_string();
|
||||||
let name_for_insert = secondary_name.clone();
|
let name_for_insert = secondary_name.clone();
|
||||||
@@ -522,10 +520,16 @@ async fn login_returns_tenant_selection_when_multiple_memberships() -> Result<()
|
|||||||
))
|
))
|
||||||
.execute(conn)?;
|
.execute(conn)?;
|
||||||
|
|
||||||
|
let owner_capability_set_id =
|
||||||
|
ensure_capability_set(conn, secondary_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,
|
||||||
tenant_id: secondary_id,
|
tenant_id: secondary_id,
|
||||||
|
capability_set_id: Some(owner_capability_set_id),
|
||||||
};
|
};
|
||||||
|
|
||||||
diesel::insert_into(user_memberships::table)
|
diesel::insert_into(user_memberships::table)
|
||||||
@@ -537,16 +541,13 @@ async fn login_returns_tenant_selection_when_multiple_memberships() -> Result<()
|
|||||||
|
|
||||||
let (login, refresh_cookie) = login_with_session(&app, "multipass", password).await?;
|
let (login, refresh_cookie) = login_with_session(&app, "multipass", password).await?;
|
||||||
|
|
||||||
let tenants_response = app
|
let tenants_response = app.get("/api/tenants", Some(&login.access_token)).await?;
|
||||||
.get("/api/auth/tenants", Some(&login.access_token))
|
|
||||||
.await?;
|
|
||||||
assert_eq!(tenants_response.status(), StatusCode::OK);
|
assert_eq!(tenants_response.status(), StatusCode::OK);
|
||||||
let tenants_body = body_to_vec(tenants_response.into_body()).await?;
|
let tenants_body = body_to_vec(tenants_response.into_body()).await?;
|
||||||
let tenant_list: TenantListResponse = serde_json::from_slice(&tenants_body)?;
|
let tenant_list: Vec<TenantSummary> = serde_json::from_slice(&tenants_body)?;
|
||||||
assert!(tenant_list.tenants.len() >= 2);
|
assert!(tenant_list.len() >= 2);
|
||||||
|
|
||||||
let secondary = tenant_list
|
let secondary = tenant_list
|
||||||
.tenants
|
|
||||||
.iter()
|
.iter()
|
||||||
.find(|tenant| tenant.name == secondary_name)
|
.find(|tenant| tenant.name == secondary_name)
|
||||||
.map(|t| t.id)
|
.map(|t| t.id)
|
||||||
@@ -592,10 +593,28 @@ async fn login_with_session(
|
|||||||
let tenant: papercrate::models::Tenant =
|
let tenant: papercrate::models::Tenant =
|
||||||
tenants::table.find(membership.tenant_id).first(conn)?;
|
tenants::table.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::table
|
||||||
|
.find(capability_set_id)
|
||||||
|
.select(capability_sets::cap_version)
|
||||||
|
.first::<i32>(conn)?;
|
||||||
|
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
|
let session_id = Uuid::new_v4();
|
||||||
let access_token = state
|
let access_token = state
|
||||||
.jwt
|
.jwt
|
||||||
.generate_token(user.id, tenant.id, &user.username)
|
.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))?;
|
.map_err(|err| anyhow!(err))?;
|
||||||
|
|
||||||
let session_value = generate_session_token();
|
let session_value = generate_session_token();
|
||||||
@@ -603,7 +622,7 @@ async fn login_with_session(
|
|||||||
let refresh_expires_at = now + ChronoDuration::days(state.config.refresh_token_expiry_days);
|
let refresh_expires_at = now + ChronoDuration::days(state.config.refresh_token_expiry_days);
|
||||||
|
|
||||||
let new_session = NewUserSession {
|
let new_session = NewUserSession {
|
||||||
id: Uuid::new_v4(),
|
id: session_id,
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
token_hash: session_hash,
|
token_hash: session_hash,
|
||||||
issued_at: now.naive_utc(),
|
issued_at: now.naive_utc(),
|
||||||
@@ -645,7 +664,9 @@ fn extract_refresh_cookie(headers: &axum::http::HeaderMap) -> Result<String> {
|
|||||||
|
|
||||||
fn generate_session_token() -> String {
|
fn generate_session_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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -654,3 +675,71 @@ fn hash_session_token(value: &str) -> String {
|
|||||||
hasher.update(value.as_bytes());
|
hasher.update(value.as_bytes());
|
||||||
hex::encode(hasher.finalize())
|
hex::encode(hasher.finalize())
|
||||||
}
|
}
|
||||||
|
#[tokio::test]
|
||||||
|
async fn tenant_selection_excludes_inactive_tenants() -> Result<()> {
|
||||||
|
let _lock = acquire_db_lock().await;
|
||||||
|
let app = TestApp::new().await?;
|
||||||
|
|
||||||
|
let user_id = app.insert_user("tenant-user", TestUserRole::Owner).await?;
|
||||||
|
|
||||||
|
let tenant_id = app
|
||||||
|
.with_conn(|conn| {
|
||||||
|
let tenant: papercrate::models::Tenant = tenant_dsl::tenants
|
||||||
|
.filter(tenant_dsl::name.eq("test_tenant"))
|
||||||
|
.first(conn)?;
|
||||||
|
Ok::<_, anyhow::Error>(tenant.id)
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
app.with_conn(move |conn| {
|
||||||
|
diesel::update(tenant_dsl::tenants.find(tenant_id))
|
||||||
|
.set(tenant_dsl::status.eq(TenantStatus::Suspended))
|
||||||
|
.execute(conn)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let magic_value = "tenant-status-token";
|
||||||
|
let token_hash = {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(magic_value.as_bytes());
|
||||||
|
hex::encode(hasher.finalize())
|
||||||
|
};
|
||||||
|
|
||||||
|
let user_id_for_token = user_id;
|
||||||
|
app.with_conn(move |conn| {
|
||||||
|
let token = MagicToken {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
user_id: user_id_for_token,
|
||||||
|
kind: MagicTokenKind::EmailLogin,
|
||||||
|
token_hash,
|
||||||
|
metadata: json!({}),
|
||||||
|
expires_at: (Utc::now() + ChronoDuration::hours(1)).naive_utc(),
|
||||||
|
max_uses: None,
|
||||||
|
used_count: 0,
|
||||||
|
created_at: Utc::now().naive_utc(),
|
||||||
|
created_by: None,
|
||||||
|
last_used_at: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
diesel::insert_into(magic_dsl::magic_tokens)
|
||||||
|
.values(&token)
|
||||||
|
.execute(conn)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let payload = json!({
|
||||||
|
"username": "tenant-user",
|
||||||
|
"magic_token": magic_value,
|
||||||
|
});
|
||||||
|
|
||||||
|
let response = app.post_json("/api/auth/login", &payload, None).await?;
|
||||||
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||||
|
let body = body_to_vec(response.into_body()).await?;
|
||||||
|
let err: ApiErrorResponse = serde_json::from_slice(&body)?;
|
||||||
|
assert_eq!(err.error, "no active tenants available");
|
||||||
|
|
||||||
|
app.cleanup().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use diesel::prelude::*;
|
||||||
|
use papercrate::models::ApiCapability;
|
||||||
|
use papercrate::test_support::{acquire_db_lock, body_to_vec, TestApp, TestUserRole};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
async fn set_user_capabilities(
|
||||||
|
app: &TestApp,
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
caps: &[ApiCapability],
|
||||||
|
) -> Result<()> {
|
||||||
|
let capabilities = caps.to_vec();
|
||||||
|
app.with_conn(move |conn| {
|
||||||
|
use papercrate::schema::user_memberships::dsl as memberships_dsl;
|
||||||
|
|
||||||
|
let membership = memberships_dsl::user_memberships
|
||||||
|
.filter(memberships_dsl::user_id.eq(user_id))
|
||||||
|
.first::<papercrate::models::UserMembership>(conn)?;
|
||||||
|
|
||||||
|
let capability_set = papercrate::auth::capability_sets::ensure_capability_set(
|
||||||
|
conn,
|
||||||
|
membership.tenant_id,
|
||||||
|
&capabilities,
|
||||||
|
)
|
||||||
|
.map_err(|err| anyhow!("failed to ensure capability set: {:?}", err))?;
|
||||||
|
|
||||||
|
diesel::update(memberships_dsl::user_memberships.find(membership.id))
|
||||||
|
.set(memberships_dsl::capability_set_id.eq(Some(capability_set.id)))
|
||||||
|
.execute(conn)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn documents_routes_enforce_capabilities() -> Result<()> {
|
||||||
|
let _lock = acquire_db_lock().await;
|
||||||
|
let app = TestApp::new().await?;
|
||||||
|
|
||||||
|
let password = "limited-docs";
|
||||||
|
let user_id = app.insert_user("limited-docs", TestUserRole::Owner).await?;
|
||||||
|
set_user_capabilities(&app, user_id, &[ApiCapability::DocumentsRead]).await?;
|
||||||
|
|
||||||
|
let token = app.login_token("limited-docs", password).await?;
|
||||||
|
|
||||||
|
let list = app.get("/api/documents", Some(&token)).await?;
|
||||||
|
assert_eq!(list.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let upload = app
|
||||||
|
.upload_document(
|
||||||
|
"/api/documents",
|
||||||
|
"limited.txt",
|
||||||
|
"text/plain",
|
||||||
|
b"limited",
|
||||||
|
None,
|
||||||
|
&token,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_eq!(upload.status(), StatusCode::FORBIDDEN);
|
||||||
|
let upload_body = body_to_vec(upload.into_body()).await?;
|
||||||
|
assert!(String::from_utf8_lossy(&upload_body).contains("missing"));
|
||||||
|
|
||||||
|
let capability_sets = app.get("/api/capability-sets", Some(&token)).await?;
|
||||||
|
assert_eq!(capability_sets.status(), StatusCode::FORBIDDEN);
|
||||||
|
let caps_body = body_to_vec(capability_sets.into_body()).await?;
|
||||||
|
assert!(String::from_utf8_lossy(&caps_body).contains("missing"));
|
||||||
|
|
||||||
|
app.cleanup().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn capability_set_routes_require_write_privilege() -> Result<()> {
|
||||||
|
let _lock = acquire_db_lock().await;
|
||||||
|
let app = TestApp::new().await?;
|
||||||
|
|
||||||
|
let password = "caps-reader";
|
||||||
|
let user_id = app.insert_user("caps-reader", TestUserRole::Member).await?;
|
||||||
|
set_user_capabilities(&app, user_id, &[ApiCapability::CapabilitySetsRead]).await?;
|
||||||
|
|
||||||
|
let token = app.login_token("caps-reader", password).await?;
|
||||||
|
|
||||||
|
let list = app.get("/api/capability-sets", Some(&token)).await?;
|
||||||
|
assert_eq!(list.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let create = app
|
||||||
|
.post_json(
|
||||||
|
"/api/capability-sets",
|
||||||
|
&json!({
|
||||||
|
"slug": "should-fail",
|
||||||
|
"capabilities": ["documents:read"]
|
||||||
|
}),
|
||||||
|
Some(&token),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_eq!(create.status(), StatusCode::FORBIDDEN);
|
||||||
|
let create_body = body_to_vec(create.into_body()).await?;
|
||||||
|
assert!(String::from_utf8_lossy(&create_body).contains("missing"));
|
||||||
|
|
||||||
|
app.cleanup().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user