Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41a9e8f76c | ||
|
|
30f3fe015b | ||
|
|
5fd4a41e64 | ||
|
|
dfadc8ba23 | ||
|
|
ddce0e39b3 | ||
|
|
4a428b9af6 | ||
|
|
768f8cb21c |
@@ -1,146 +0,0 @@
|
|||||||
name: ci
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
- staging
|
|
||||||
- dev
|
|
||||||
tags:
|
|
||||||
- '*'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
docker:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- service: frontend
|
|
||||||
context: frontend
|
|
||||||
dockerfile: frontend/Dockerfile
|
|
||||||
- service: backend
|
|
||||||
context: backend
|
|
||||||
dockerfile: backend/Dockerfile
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout with submodules
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
submodules: recursive
|
|
||||||
|
|
||||||
- name: Derive repository metadata
|
|
||||||
id: repo_meta
|
|
||||||
run: |
|
|
||||||
repo="${GITHUB_REPOSITORY}"
|
|
||||||
owner="${repo%%/*}"
|
|
||||||
name="${repo##*/}"
|
|
||||||
echo "repo_owner=$owner" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "repo_name=$name" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Compute base tag
|
|
||||||
id: compute_tag
|
|
||||||
env:
|
|
||||||
GITHUB_SHA: ${{ github.sha }}
|
|
||||||
GITHUB_REF_TYPE: ${{ github.ref_type }}
|
|
||||||
GITHUB_REF_NAME: ${{ github.ref_name }}
|
|
||||||
run: |
|
|
||||||
sha="${GITHUB_SHA}"
|
|
||||||
ref_type="${GITHUB_REF_TYPE}"
|
|
||||||
ref_name="${GITHUB_REF_NAME}"
|
|
||||||
|
|
||||||
short="${sha:0:7}"
|
|
||||||
tag="$short"
|
|
||||||
|
|
||||||
if [ "$ref_type" = "tag" ]; then
|
|
||||||
tag="$ref_name"
|
|
||||||
elif [ "$ref_name" = "dev" ]; then
|
|
||||||
tag="${tag}-dev"
|
|
||||||
elif [ "$ref_name" = "staging" ]; then
|
|
||||||
tag="${tag}-staging"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "base_tag=$tag" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Login to local registry
|
|
||||||
if: ${{ vars.REGISTRY_URL != '' }}
|
|
||||||
uses: docker/login-action@v2
|
|
||||||
with:
|
|
||||||
registry: ${{ vars.REGISTRY_URL }}
|
|
||||||
username: ${{ vars.REGISTRY_USER }}
|
|
||||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
|
||||||
|
|
||||||
- name: Login to GHCR
|
|
||||||
uses: docker/login-action@v2
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ github.token }}
|
|
||||||
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@v3
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Determine branch alias tag
|
|
||||||
id: branch_alias
|
|
||||||
env:
|
|
||||||
REF_NAME: ${{ github.ref_name }}
|
|
||||||
run: |
|
|
||||||
alias=""
|
|
||||||
case "${REF_NAME}" in
|
|
||||||
dev) alias="latest-dev" ;;
|
|
||||||
staging) alias="latest-staging" ;;
|
|
||||||
main) alias="latest" ;;
|
|
||||||
esac
|
|
||||||
echo "alias=$alias" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Assemble image tags
|
|
||||||
id: tag_list
|
|
||||||
env:
|
|
||||||
REGISTRY_URL: ${{ vars.REGISTRY_URL }}
|
|
||||||
REPO_NAME: ${{ steps.repo_meta.outputs.repo_name }}
|
|
||||||
SERVICE: ${{ matrix.service }}
|
|
||||||
BASE_TAG: ${{ steps.compute_tag.outputs.base_tag }}
|
|
||||||
GIT_SHA: ${{ github.sha }}
|
|
||||||
BRANCH_ALIAS: ${{ steps.branch_alias.outputs.alias }}
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
tags=""
|
|
||||||
|
|
||||||
if [ -n "${REGISTRY_URL}" ]; then
|
|
||||||
repo_tag="${REGISTRY_URL}/${REPO_NAME}-${SERVICE}"
|
|
||||||
tags="${tags}${repo_tag}:${BASE_TAG}\n${repo_tag}:${GIT_SHA}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
ghcr_tag="ghcr.io/papercrate-dms/${REPO_NAME}-${SERVICE}"
|
|
||||||
if [ -n "${tags}" ]; then
|
|
||||||
tags="${tags}\n"
|
|
||||||
fi
|
|
||||||
tags="${tags}${ghcr_tag}:${BASE_TAG}\n${ghcr_tag}:${GIT_SHA}"
|
|
||||||
|
|
||||||
if [ -n "${BRANCH_ALIAS}" ]; then
|
|
||||||
if [ -n "${REGISTRY_URL}" ]; then
|
|
||||||
tags="${tags}\n${repo_tag}:${BRANCH_ALIAS}"
|
|
||||||
fi
|
|
||||||
tags="${tags}\n${ghcr_tag}:${BRANCH_ALIAS}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
export TAGS="${tags}"
|
|
||||||
|
|
||||||
python -c 'import os; tags=[t.strip() for t in os.environ["TAGS"].split("\\n") if t.strip()]; print("tags=" + ",".join(tags))' | tee -a "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Build and Push ${{ matrix.service }} Image
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: ${{ matrix.context }}
|
|
||||||
file: ${{ matrix.dockerfile }}
|
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
push: true
|
|
||||||
provenance: false
|
|
||||||
tags: ${{ steps.tag_list.outputs.tags }}
|
|
||||||
cache-from: type=gha
|
|
||||||
cache-to: type=gha,mode=max
|
|
||||||
@@ -6,7 +6,6 @@
|
|||||||
/backend/.env
|
/backend/.env
|
||||||
/backend/.env.*
|
/backend/.env.*
|
||||||
/backend/.cargo/
|
/backend/.cargo/
|
||||||
backend/libpdfium.*
|
|
||||||
|
|
||||||
# Node/Frontend
|
# Node/Frontend
|
||||||
/frontend/node_modules/
|
/frontend/node_modules/
|
||||||
|
|||||||
-107
@@ -1,107 +0,0 @@
|
|||||||
# Development
|
|
||||||
|
|
||||||
This document collects runtime assumptions and workflows for local development,
|
|
||||||
integration testing, and infrastructure automation.
|
|
||||||
|
|
||||||
## Local Development
|
|
||||||
|
|
||||||
Use the provided `papercrate.tmux` to spin up the full stack in one tmux session:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
tmux -f papercrate.tmux attach
|
|
||||||
```
|
|
||||||
|
|
||||||
This creates windows for the compose stack, frontend dev server, backend API, and
|
|
||||||
background worker using the repository-relative paths defined in the tmux file.
|
|
||||||
Detach with `Ctrl+b d` and reattach later with the same command.
|
|
||||||
|
|
||||||
The development Postgres container now seeds two database roles:
|
|
||||||
|
|
||||||
- `papercrate_app_login` (password `papercrate_app`) is used by the backend and
|
|
||||||
is subject to row-level security policies.
|
|
||||||
- `papercrate` remains the owner role for running Diesel migrations or other
|
|
||||||
maintenance tasks.
|
|
||||||
|
|
||||||
When connecting manually to inspect RLS behaviour, switch to the application
|
|
||||||
role with `SET ROLE papercrate_app_login;` before querying tenant tables.
|
|
||||||
|
|
||||||
## Backend Integration Tests
|
|
||||||
|
|
||||||
Integration tests require a running Postgres instance (and, optionally, Quickwit
|
|
||||||
for OCR indexing). The repository includes a lightweight compose file for local
|
|
||||||
runs:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f docker-compose.test.yml up -d
|
|
||||||
export TEST_DATABASE_URL=postgres://papercrate:papercrate_test@localhost:5433/papercrate_test
|
|
||||||
# optional, enables Quickwit indexing jobs
|
|
||||||
export QUICKWIT_ENDPOINT=http://localhost:7280
|
|
||||||
export QUICKWIT_INDEX=documents
|
|
||||||
cargo test
|
|
||||||
```
|
|
||||||
|
|
||||||
Stop the database when you are done:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f docker-compose.test.yml down
|
|
||||||
```
|
|
||||||
|
|
||||||
The compose service uses tmpfs storage, giving each test run a clean database.
|
|
||||||
|
|
||||||
## Runtime Dependencies
|
|
||||||
|
|
||||||
- `ocrmypdf` (optional but recommended): Used by the OCR worker to extract text
|
|
||||||
from PDFs when no embedded text layer is available. Ensure it is installed and
|
|
||||||
available on the worker hosts if OCR is desired.
|
|
||||||
- Quickwit (optional): The Quickwit indexer is used to ingest extracted text for
|
|
||||||
search. Set `QUICKWIT_ENDPOINT` and `QUICKWIT_INDEX` in the environment when
|
|
||||||
running workers if you want indexing jobs to run. The local compose file starts
|
|
||||||
a Quickwit instance on `http://localhost:7280` and seeds the `documents` index
|
|
||||||
automatically.
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
The backend reads its settings from environment variables. In particular:
|
|
||||||
|
|
||||||
- `DATABASE_URL` – connection string for the primary Postgres database (required).
|
|
||||||
- `DATABASE_MAX_POOL_SIZE` – optional override for the r2d2 connection pool size.
|
|
||||||
Defaults to `2`; increase it in staging/production to match expected concurrency.
|
|
||||||
- `PROXY_DOWNLOADS` – set to `true` when the object store is only reachable from
|
|
||||||
the backend network. When enabled, `/api/download/{token}` and asset-object fetches
|
|
||||||
stream bytes through the API instead of redirecting clients to S3/Hetzner.
|
|
||||||
|
|
||||||
On startup each binary logs the effective configuration with secrets redacted
|
|
||||||
(for example, the database password is masked). This makes it easier to confirm
|
|
||||||
runtime settings in staging without exposing credentials.
|
|
||||||
|
|
||||||
## Running Migrations in Kubernetes
|
|
||||||
|
|
||||||
The backend container image ships the `diesel` CLI, so schema migrations can be
|
|
||||||
executed as a short-lived Job (or Helm hook) before rolling out new pods. Example
|
|
||||||
manifest:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
apiVersion: batch/v1
|
|
||||||
kind: Job
|
|
||||||
metadata:
|
|
||||||
name: papercrate-migrate
|
|
||||||
spec:
|
|
||||||
template:
|
|
||||||
spec:
|
|
||||||
restartPolicy: OnFailure
|
|
||||||
containers:
|
|
||||||
- name: migrate
|
|
||||||
image: ghcr.io/example/papercrate-backend:<TAG>
|
|
||||||
command: ["/usr/local/bin/diesel", "migration", "run"]
|
|
||||||
env:
|
|
||||||
- name: DATABASE_URL
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: papercrate-db
|
|
||||||
key: DATABASE_URL
|
|
||||||
```
|
|
||||||
|
|
||||||
Run the Job manually (`kubectl apply -f migrate-job.yaml`) or configure it as a
|
|
||||||
Helm pre-install/pre-upgrade hook so migrations run automatically on each
|
|
||||||
deployment. Once the Job succeeds, deploy/update the backend `Deployment` as
|
|
||||||
usual.
|
|
||||||
@@ -1,661 +0,0 @@
|
|||||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
|
||||||
Version 3, 19 November 2007
|
|
||||||
|
|
||||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
|
||||||
Everyone is permitted to copy and distribute verbatim copies
|
|
||||||
of this license document, but changing it is not allowed.
|
|
||||||
|
|
||||||
Preamble
|
|
||||||
|
|
||||||
The GNU Affero General Public License is a free, copyleft license for
|
|
||||||
software and other kinds of works, specifically designed to ensure
|
|
||||||
cooperation with the community in the case of network server software.
|
|
||||||
|
|
||||||
The licenses for most software and other practical works are designed
|
|
||||||
to take away your freedom to share and change the works. By contrast,
|
|
||||||
our General Public Licenses are intended to guarantee your freedom to
|
|
||||||
share and change all versions of a program--to make sure it remains free
|
|
||||||
software for all its users.
|
|
||||||
|
|
||||||
When we speak of free software, we are referring to freedom, not
|
|
||||||
price. Our General Public Licenses are designed to make sure that you
|
|
||||||
have the freedom to distribute copies of free software (and charge for
|
|
||||||
them if you wish), that you receive source code or can get it if you
|
|
||||||
want it, that you can change the software or use pieces of it in new
|
|
||||||
free programs, and that you know you can do these things.
|
|
||||||
|
|
||||||
Developers that use our General Public Licenses protect your rights
|
|
||||||
with two steps: (1) assert copyright on the software, and (2) offer
|
|
||||||
you this License which gives you legal permission to copy, distribute
|
|
||||||
and/or modify the software.
|
|
||||||
|
|
||||||
A secondary benefit of defending all users' freedom is that
|
|
||||||
improvements made in alternate versions of the program, if they
|
|
||||||
receive widespread use, become available for other developers to
|
|
||||||
incorporate. Many developers of free software are heartened and
|
|
||||||
encouraged by the resulting cooperation. However, in the case of
|
|
||||||
software used on network servers, this result may fail to come about.
|
|
||||||
The GNU General Public License permits making a modified version and
|
|
||||||
letting the public access it on a server without ever releasing its
|
|
||||||
source code to the public.
|
|
||||||
|
|
||||||
The GNU Affero General Public License is designed specifically to
|
|
||||||
ensure that, in such cases, the modified source code becomes available
|
|
||||||
to the community. It requires the operator of a network server to
|
|
||||||
provide the source code of the modified version running there to the
|
|
||||||
users of that server. Therefore, public use of a modified version, on
|
|
||||||
a publicly accessible server, gives the public access to the source
|
|
||||||
code of the modified version.
|
|
||||||
|
|
||||||
An older license, called the Affero General Public License and
|
|
||||||
published by Affero, was designed to accomplish similar goals. This is
|
|
||||||
a different license, not a version of the Affero GPL, but Affero has
|
|
||||||
released a new version of the Affero GPL which permits relicensing under
|
|
||||||
this license.
|
|
||||||
|
|
||||||
The precise terms and conditions for copying, distribution and
|
|
||||||
modification follow.
|
|
||||||
|
|
||||||
TERMS AND CONDITIONS
|
|
||||||
|
|
||||||
0. Definitions.
|
|
||||||
|
|
||||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
|
||||||
|
|
||||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
|
||||||
works, such as semiconductor masks.
|
|
||||||
|
|
||||||
"The Program" refers to any copyrightable work licensed under this
|
|
||||||
License. Each licensee is addressed as "you". "Licensees" and
|
|
||||||
"recipients" may be individuals or organizations.
|
|
||||||
|
|
||||||
To "modify" a work means to copy from or adapt all or part of the work
|
|
||||||
in a fashion requiring copyright permission, other than the making of an
|
|
||||||
exact copy. The resulting work is called a "modified version" of the
|
|
||||||
earlier work or a work "based on" the earlier work.
|
|
||||||
|
|
||||||
A "covered work" means either the unmodified Program or a work based
|
|
||||||
on the Program.
|
|
||||||
|
|
||||||
To "propagate" a work means to do anything with it that, without
|
|
||||||
permission, would make you directly or secondarily liable for
|
|
||||||
infringement under applicable copyright law, except executing it on a
|
|
||||||
computer or modifying a private copy. Propagation includes copying,
|
|
||||||
distribution (with or without modification), making available to the
|
|
||||||
public, and in some countries other activities as well.
|
|
||||||
|
|
||||||
To "convey" a work means any kind of propagation that enables other
|
|
||||||
parties to make or receive copies. Mere interaction with a user through
|
|
||||||
a computer network, with no transfer of a copy, is not conveying.
|
|
||||||
|
|
||||||
An interactive user interface displays "Appropriate Legal Notices"
|
|
||||||
to the extent that it includes a convenient and prominently visible
|
|
||||||
feature that (1) displays an appropriate copyright notice, and (2)
|
|
||||||
tells the user that there is no warranty for the work (except to the
|
|
||||||
extent that warranties are provided), that licensees may convey the
|
|
||||||
work under this License, and how to view a copy of this License. If
|
|
||||||
the interface presents a list of user commands or options, such as a
|
|
||||||
menu, a prominent item in the list meets this criterion.
|
|
||||||
|
|
||||||
1. Source Code.
|
|
||||||
|
|
||||||
The "source code" for a work means the preferred form of the work
|
|
||||||
for making modifications to it. "Object code" means any non-source
|
|
||||||
form of a work.
|
|
||||||
|
|
||||||
A "Standard Interface" means an interface that either is an official
|
|
||||||
standard defined by a recognized standards body, or, in the case of
|
|
||||||
interfaces specified for a particular programming language, one that
|
|
||||||
is widely used among developers working in that language.
|
|
||||||
|
|
||||||
The "System Libraries" of an executable work include anything, other
|
|
||||||
than the work as a whole, that (a) is included in the normal form of
|
|
||||||
packaging a Major Component, but which is not part of that Major
|
|
||||||
Component, and (b) serves only to enable use of the work with that
|
|
||||||
Major Component, or to implement a Standard Interface for which an
|
|
||||||
implementation is available to the public in source code form. A
|
|
||||||
"Major Component", in this context, means a major essential component
|
|
||||||
(kernel, window system, and so on) of the specific operating system
|
|
||||||
(if any) on which the executable work runs, or a compiler used to
|
|
||||||
produce the work, or an object code interpreter used to run it.
|
|
||||||
|
|
||||||
The "Corresponding Source" for a work in object code form means all
|
|
||||||
the source code needed to generate, install, and (for an executable
|
|
||||||
work) run the object code and to modify the work, including scripts to
|
|
||||||
control those activities. However, it does not include the work's
|
|
||||||
System Libraries, or general-purpose tools or generally available free
|
|
||||||
programs which are used unmodified in performing those activities but
|
|
||||||
which are not part of the work. For example, Corresponding Source
|
|
||||||
includes interface definition files associated with source files for
|
|
||||||
the work, and the source code for shared libraries and dynamically
|
|
||||||
linked subprograms that the work is specifically designed to require,
|
|
||||||
such as by intimate data communication or control flow between those
|
|
||||||
subprograms and other parts of the work.
|
|
||||||
|
|
||||||
The Corresponding Source need not include anything that users
|
|
||||||
can regenerate automatically from other parts of the Corresponding
|
|
||||||
Source.
|
|
||||||
|
|
||||||
The Corresponding Source for a work in source code form is that
|
|
||||||
same work.
|
|
||||||
|
|
||||||
2. Basic Permissions.
|
|
||||||
|
|
||||||
All rights granted under this License are granted for the term of
|
|
||||||
copyright on the Program, and are irrevocable provided the stated
|
|
||||||
conditions are met. This License explicitly affirms your unlimited
|
|
||||||
permission to run the unmodified Program. The output from running a
|
|
||||||
covered work is covered by this License only if the output, given its
|
|
||||||
content, constitutes a covered work. This License acknowledges your
|
|
||||||
rights of fair use or other equivalent, as provided by copyright law.
|
|
||||||
|
|
||||||
You may make, run and propagate covered works that you do not
|
|
||||||
convey, without conditions so long as your license otherwise remains
|
|
||||||
in force. You may convey covered works to others for the sole purpose
|
|
||||||
of having them make modifications exclusively for you, or provide you
|
|
||||||
with facilities for running those works, provided that you comply with
|
|
||||||
the terms of this License in conveying all material for which you do
|
|
||||||
not control copyright. Those thus making or running the covered works
|
|
||||||
for you must do so exclusively on your behalf, under your direction
|
|
||||||
and control, on terms that prohibit them from making any copies of
|
|
||||||
your copyrighted material outside their relationship with you.
|
|
||||||
|
|
||||||
Conveying under any other circumstances is permitted solely under
|
|
||||||
the conditions stated below. Sublicensing is not allowed; section 10
|
|
||||||
makes it unnecessary.
|
|
||||||
|
|
||||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
|
||||||
|
|
||||||
No covered work shall be deemed part of an effective technological
|
|
||||||
measure under any applicable law fulfilling obligations under article
|
|
||||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
|
||||||
similar laws prohibiting or restricting circumvention of such
|
|
||||||
measures.
|
|
||||||
|
|
||||||
When you convey a covered work, you waive any legal power to forbid
|
|
||||||
circumvention of technological measures to the extent such circumvention
|
|
||||||
is effected by exercising rights under this License with respect to
|
|
||||||
the covered work, and you disclaim any intention to limit operation or
|
|
||||||
modification of the work as a means of enforcing, against the work's
|
|
||||||
users, your or third parties' legal rights to forbid circumvention of
|
|
||||||
technological measures.
|
|
||||||
|
|
||||||
4. Conveying Verbatim Copies.
|
|
||||||
|
|
||||||
You may convey verbatim copies of the Program's source code as you
|
|
||||||
receive it, in any medium, provided that you conspicuously and
|
|
||||||
appropriately publish on each copy an appropriate copyright notice;
|
|
||||||
keep intact all notices stating that this License and any
|
|
||||||
non-permissive terms added in accord with section 7 apply to the code;
|
|
||||||
keep intact all notices of the absence of any warranty; and give all
|
|
||||||
recipients a copy of this License along with the Program.
|
|
||||||
|
|
||||||
You may charge any price or no price for each copy that you convey,
|
|
||||||
and you may offer support or warranty protection for a fee.
|
|
||||||
|
|
||||||
5. Conveying Modified Source Versions.
|
|
||||||
|
|
||||||
You may convey a work based on the Program, or the modifications to
|
|
||||||
produce it from the Program, in the form of source code under the
|
|
||||||
terms of section 4, provided that you also meet all of these conditions:
|
|
||||||
|
|
||||||
a) The work must carry prominent notices stating that you modified
|
|
||||||
it, and giving a relevant date.
|
|
||||||
|
|
||||||
b) The work must carry prominent notices stating that it is
|
|
||||||
released under this License and any conditions added under section
|
|
||||||
7. This requirement modifies the requirement in section 4 to
|
|
||||||
"keep intact all notices".
|
|
||||||
|
|
||||||
c) You must license the entire work, as a whole, under this
|
|
||||||
License to anyone who comes into possession of a copy. This
|
|
||||||
License will therefore apply, along with any applicable section 7
|
|
||||||
additional terms, to the whole of the work, and all its parts,
|
|
||||||
regardless of how they are packaged. This License gives no
|
|
||||||
permission to license the work in any other way, but it does not
|
|
||||||
invalidate such permission if you have separately received it.
|
|
||||||
|
|
||||||
d) If the work has interactive user interfaces, each must display
|
|
||||||
Appropriate Legal Notices; however, if the Program has interactive
|
|
||||||
interfaces that do not display Appropriate Legal Notices, your
|
|
||||||
work need not make them do so.
|
|
||||||
|
|
||||||
A compilation of a covered work with other separate and independent
|
|
||||||
works, which are not by their nature extensions of the covered work,
|
|
||||||
and which are not combined with it such as to form a larger program,
|
|
||||||
in or on a volume of a storage or distribution medium, is called an
|
|
||||||
"aggregate" if the compilation and its resulting copyright are not
|
|
||||||
used to limit the access or legal rights of the compilation's users
|
|
||||||
beyond what the individual works permit. Inclusion of a covered work
|
|
||||||
in an aggregate does not cause this License to apply to the other
|
|
||||||
parts of the aggregate.
|
|
||||||
|
|
||||||
6. Conveying Non-Source Forms.
|
|
||||||
|
|
||||||
You may convey a covered work in object code form under the terms
|
|
||||||
of sections 4 and 5, provided that you also convey the
|
|
||||||
machine-readable Corresponding Source under the terms of this License,
|
|
||||||
in one of these ways:
|
|
||||||
|
|
||||||
a) Convey the object code in, or embodied in, a physical product
|
|
||||||
(including a physical distribution medium), accompanied by the
|
|
||||||
Corresponding Source fixed on a durable physical medium
|
|
||||||
customarily used for software interchange.
|
|
||||||
|
|
||||||
b) Convey the object code in, or embodied in, a physical product
|
|
||||||
(including a physical distribution medium), accompanied by a
|
|
||||||
written offer, valid for at least three years and valid for as
|
|
||||||
long as you offer spare parts or customer support for that product
|
|
||||||
model, to give anyone who possesses the object code either (1) a
|
|
||||||
copy of the Corresponding Source for all the software in the
|
|
||||||
product that is covered by this License, on a durable physical
|
|
||||||
medium customarily used for software interchange, for a price no
|
|
||||||
more than your reasonable cost of physically performing this
|
|
||||||
conveying of source, or (2) access to copy the
|
|
||||||
Corresponding Source from a network server at no charge.
|
|
||||||
|
|
||||||
c) Convey individual copies of the object code with a copy of the
|
|
||||||
written offer to provide the Corresponding Source. This
|
|
||||||
alternative is allowed only occasionally and noncommercially, and
|
|
||||||
only if you received the object code with such an offer, in accord
|
|
||||||
with subsection 6b.
|
|
||||||
|
|
||||||
d) Convey the object code by offering access from a designated
|
|
||||||
place (gratis or for a charge), and offer equivalent access to the
|
|
||||||
Corresponding Source in the same way through the same place at no
|
|
||||||
further charge. You need not require recipients to copy the
|
|
||||||
Corresponding Source along with the object code. If the place to
|
|
||||||
copy the object code is a network server, the Corresponding Source
|
|
||||||
may be on a different server (operated by you or a third party)
|
|
||||||
that supports equivalent copying facilities, provided you maintain
|
|
||||||
clear directions next to the object code saying where to find the
|
|
||||||
Corresponding Source. Regardless of what server hosts the
|
|
||||||
Corresponding Source, you remain obligated to ensure that it is
|
|
||||||
available for as long as needed to satisfy these requirements.
|
|
||||||
|
|
||||||
e) Convey the object code using peer-to-peer transmission, provided
|
|
||||||
you inform other peers where the object code and Corresponding
|
|
||||||
Source of the work are being offered to the general public at no
|
|
||||||
charge under subsection 6d.
|
|
||||||
|
|
||||||
A separable portion of the object code, whose source code is excluded
|
|
||||||
from the Corresponding Source as a System Library, need not be
|
|
||||||
included in conveying the object code work.
|
|
||||||
|
|
||||||
A "User Product" is either (1) a "consumer product", which means any
|
|
||||||
tangible personal property which is normally used for personal, family,
|
|
||||||
or household purposes, or (2) anything designed or sold for incorporation
|
|
||||||
into a dwelling. In determining whether a product is a consumer product,
|
|
||||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
|
||||||
product received by a particular user, "normally used" refers to a
|
|
||||||
typical or common use of that class of product, regardless of the status
|
|
||||||
of the particular user or of the way in which the particular user
|
|
||||||
actually uses, or expects or is expected to use, the product. A product
|
|
||||||
is a consumer product regardless of whether the product has substantial
|
|
||||||
commercial, industrial or non-consumer uses, unless such uses represent
|
|
||||||
the only significant mode of use of the product.
|
|
||||||
|
|
||||||
"Installation Information" for a User Product means any methods,
|
|
||||||
procedures, authorization keys, or other information required to install
|
|
||||||
and execute modified versions of a covered work in that User Product from
|
|
||||||
a modified version of its Corresponding Source. The information must
|
|
||||||
suffice to ensure that the continued functioning of the modified object
|
|
||||||
code is in no case prevented or interfered with solely because
|
|
||||||
modification has been made.
|
|
||||||
|
|
||||||
If you convey an object code work under this section in, or with, or
|
|
||||||
specifically for use in, a User Product, and the conveying occurs as
|
|
||||||
part of a transaction in which the right of possession and use of the
|
|
||||||
User Product is transferred to the recipient in perpetuity or for a
|
|
||||||
fixed term (regardless of how the transaction is characterized), the
|
|
||||||
Corresponding Source conveyed under this section must be accompanied
|
|
||||||
by the Installation Information. But this requirement does not apply
|
|
||||||
if neither you nor any third party retains the ability to install
|
|
||||||
modified object code on the User Product (for example, the work has
|
|
||||||
been installed in ROM).
|
|
||||||
|
|
||||||
The requirement to provide Installation Information does not include a
|
|
||||||
requirement to continue to provide support service, warranty, or updates
|
|
||||||
for a work that has been modified or installed by the recipient, or for
|
|
||||||
the User Product in which it has been modified or installed. Access to a
|
|
||||||
network may be denied when the modification itself materially and
|
|
||||||
adversely affects the operation of the network or violates the rules and
|
|
||||||
protocols for communication across the network.
|
|
||||||
|
|
||||||
Corresponding Source conveyed, and Installation Information provided,
|
|
||||||
in accord with this section must be in a format that is publicly
|
|
||||||
documented (and with an implementation available to the public in
|
|
||||||
source code form), and must require no special password or key for
|
|
||||||
unpacking, reading or copying.
|
|
||||||
|
|
||||||
7. Additional Terms.
|
|
||||||
|
|
||||||
"Additional permissions" are terms that supplement the terms of this
|
|
||||||
License by making exceptions from one or more of its conditions.
|
|
||||||
Additional permissions that are applicable to the entire Program shall
|
|
||||||
be treated as though they were included in this License, to the extent
|
|
||||||
that they are valid under applicable law. If additional permissions
|
|
||||||
apply only to part of the Program, that part may be used separately
|
|
||||||
under those permissions, but the entire Program remains governed by
|
|
||||||
this License without regard to the additional permissions.
|
|
||||||
|
|
||||||
When you convey a copy of a covered work, you may at your option
|
|
||||||
remove any additional permissions from that copy, or from any part of
|
|
||||||
it. (Additional permissions may be written to require their own
|
|
||||||
removal in certain cases when you modify the work.) You may place
|
|
||||||
additional permissions on material, added by you to a covered work,
|
|
||||||
for which you have or can give appropriate copyright permission.
|
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, for material you
|
|
||||||
add to a covered work, you may (if authorized by the copyright holders of
|
|
||||||
that material) supplement the terms of this License with terms:
|
|
||||||
|
|
||||||
a) Disclaiming warranty or limiting liability differently from the
|
|
||||||
terms of sections 15 and 16 of this License; or
|
|
||||||
|
|
||||||
b) Requiring preservation of specified reasonable legal notices or
|
|
||||||
author attributions in that material or in the Appropriate Legal
|
|
||||||
Notices displayed by works containing it; or
|
|
||||||
|
|
||||||
c) Prohibiting misrepresentation of the origin of that material, or
|
|
||||||
requiring that modified versions of such material be marked in
|
|
||||||
reasonable ways as different from the original version; or
|
|
||||||
|
|
||||||
d) Limiting the use for publicity purposes of names of licensors or
|
|
||||||
authors of the material; or
|
|
||||||
|
|
||||||
e) Declining to grant rights under trademark law for use of some
|
|
||||||
trade names, trademarks, or service marks; or
|
|
||||||
|
|
||||||
f) Requiring indemnification of licensors and authors of that
|
|
||||||
material by anyone who conveys the material (or modified versions of
|
|
||||||
it) with contractual assumptions of liability to the recipient, for
|
|
||||||
any liability that these contractual assumptions directly impose on
|
|
||||||
those licensors and authors.
|
|
||||||
|
|
||||||
All other non-permissive additional terms are considered "further
|
|
||||||
restrictions" within the meaning of section 10. If the Program as you
|
|
||||||
received it, or any part of it, contains a notice stating that it is
|
|
||||||
governed by this License along with a term that is a further
|
|
||||||
restriction, you may remove that term. If a license document contains
|
|
||||||
a further restriction but permits relicensing or conveying under this
|
|
||||||
License, you may add to a covered work material governed by the terms
|
|
||||||
of that license document, provided that the further restriction does
|
|
||||||
not survive such relicensing or conveying.
|
|
||||||
|
|
||||||
If you add terms to a covered work in accord with this section, you
|
|
||||||
must place, in the relevant source files, a statement of the
|
|
||||||
additional terms that apply to those files, or a notice indicating
|
|
||||||
where to find the applicable terms.
|
|
||||||
|
|
||||||
Additional terms, permissive or non-permissive, may be stated in the
|
|
||||||
form of a separately written license, or stated as exceptions;
|
|
||||||
the above requirements apply either way.
|
|
||||||
|
|
||||||
8. Termination.
|
|
||||||
|
|
||||||
You may not propagate or modify a covered work except as expressly
|
|
||||||
provided under this License. Any attempt otherwise to propagate or
|
|
||||||
modify it is void, and will automatically terminate your rights under
|
|
||||||
this License (including any patent licenses granted under the third
|
|
||||||
paragraph of section 11).
|
|
||||||
|
|
||||||
However, if you cease all violation of this License, then your
|
|
||||||
license from a particular copyright holder is reinstated (a)
|
|
||||||
provisionally, unless and until the copyright holder explicitly and
|
|
||||||
finally terminates your license, and (b) permanently, if the copyright
|
|
||||||
holder fails to notify you of the violation by some reasonable means
|
|
||||||
prior to 60 days after the cessation.
|
|
||||||
|
|
||||||
Moreover, your license from a particular copyright holder is
|
|
||||||
reinstated permanently if the copyright holder notifies you of the
|
|
||||||
violation by some reasonable means, this is the first time you have
|
|
||||||
received notice of violation of this License (for any work) from that
|
|
||||||
copyright holder, and you cure the violation prior to 30 days after
|
|
||||||
your receipt of the notice.
|
|
||||||
|
|
||||||
Termination of your rights under this section does not terminate the
|
|
||||||
licenses of parties who have received copies or rights from you under
|
|
||||||
this License. If your rights have been terminated and not permanently
|
|
||||||
reinstated, you do not qualify to receive new licenses for the same
|
|
||||||
material under section 10.
|
|
||||||
|
|
||||||
9. Acceptance Not Required for Having Copies.
|
|
||||||
|
|
||||||
You are not required to accept this License in order to receive or
|
|
||||||
run a copy of the Program. Ancillary propagation of a covered work
|
|
||||||
occurring solely as a consequence of using peer-to-peer transmission
|
|
||||||
to receive a copy likewise does not require acceptance. However,
|
|
||||||
nothing other than this License grants you permission to propagate or
|
|
||||||
modify any covered work. These actions infringe copyright if you do
|
|
||||||
not accept this License. Therefore, by modifying or propagating a
|
|
||||||
covered work, you indicate your acceptance of this License to do so.
|
|
||||||
|
|
||||||
10. Automatic Licensing of Downstream Recipients.
|
|
||||||
|
|
||||||
Each time you convey a covered work, the recipient automatically
|
|
||||||
receives a license from the original licensors, to run, modify and
|
|
||||||
propagate that work, subject to this License. You are not responsible
|
|
||||||
for enforcing compliance by third parties with this License.
|
|
||||||
|
|
||||||
An "entity transaction" is a transaction transferring control of an
|
|
||||||
organization, or substantially all assets of one, or subdividing an
|
|
||||||
organization, or merging organizations. If propagation of a covered
|
|
||||||
work results from an entity transaction, each party to that
|
|
||||||
transaction who receives a copy of the work also receives whatever
|
|
||||||
licenses to the work the party's predecessor in interest had or could
|
|
||||||
give under the previous paragraph, plus a right to possession of the
|
|
||||||
Corresponding Source of the work from the predecessor in interest, if
|
|
||||||
the predecessor has it or can get it with reasonable efforts.
|
|
||||||
|
|
||||||
You may not impose any further restrictions on the exercise of the
|
|
||||||
rights granted or affirmed under this License. For example, you may
|
|
||||||
not impose a license fee, royalty, or other charge for exercise of
|
|
||||||
rights granted under this License, and you may not initiate litigation
|
|
||||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
|
||||||
any patent claim is infringed by making, using, selling, offering for
|
|
||||||
sale, or importing the Program or any portion of it.
|
|
||||||
|
|
||||||
11. Patents.
|
|
||||||
|
|
||||||
A "contributor" is a copyright holder who authorizes use under this
|
|
||||||
License of the Program or a work on which the Program is based. The
|
|
||||||
work thus licensed is called the contributor's "contributor version".
|
|
||||||
|
|
||||||
A contributor's "essential patent claims" are all patent claims
|
|
||||||
owned or controlled by the contributor, whether already acquired or
|
|
||||||
hereafter acquired, that would be infringed by some manner, permitted
|
|
||||||
by this License, of making, using, or selling its contributor version,
|
|
||||||
but do not include claims that would be infringed only as a
|
|
||||||
consequence of further modification of the contributor version. For
|
|
||||||
purposes of this definition, "control" includes the right to grant
|
|
||||||
patent sublicenses in a manner consistent with the requirements of
|
|
||||||
this License.
|
|
||||||
|
|
||||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
|
||||||
patent license under the contributor's essential patent claims, to
|
|
||||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
|
||||||
propagate the contents of its contributor version.
|
|
||||||
|
|
||||||
In the following three paragraphs, a "patent license" is any express
|
|
||||||
agreement or commitment, however denominated, not to enforce a patent
|
|
||||||
(such as an express permission to practice a patent or covenant not to
|
|
||||||
sue for patent infringement). To "grant" such a patent license to a
|
|
||||||
party means to make such an agreement or commitment not to enforce a
|
|
||||||
patent against the party.
|
|
||||||
|
|
||||||
If you convey a covered work, knowingly relying on a patent license,
|
|
||||||
and the Corresponding Source of the work is not available for anyone
|
|
||||||
to copy, free of charge and under the terms of this License, through a
|
|
||||||
publicly available network server or other readily accessible means,
|
|
||||||
then you must either (1) cause the Corresponding Source to be so
|
|
||||||
available, or (2) arrange to deprive yourself of the benefit of the
|
|
||||||
patent license for this particular work, or (3) arrange, in a manner
|
|
||||||
consistent with the requirements of this License, to extend the patent
|
|
||||||
license to downstream recipients. "Knowingly relying" means you have
|
|
||||||
actual knowledge that, but for the patent license, your conveying the
|
|
||||||
covered work in a country, or your recipient's use of the covered work
|
|
||||||
in a country, would infringe one or more identifiable patents in that
|
|
||||||
country that you have reason to believe are valid.
|
|
||||||
|
|
||||||
If, pursuant to or in connection with a single transaction or
|
|
||||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
|
||||||
covered work, and grant a patent license to some of the parties
|
|
||||||
receiving the covered work authorizing them to use, propagate, modify
|
|
||||||
or convey a specific copy of the covered work, then the patent license
|
|
||||||
you grant is automatically extended to all recipients of the covered
|
|
||||||
work and works based on it.
|
|
||||||
|
|
||||||
A patent license is "discriminatory" if it does not include within
|
|
||||||
the scope of its coverage, prohibits the exercise of, or is
|
|
||||||
conditioned on the non-exercise of one or more of the rights that are
|
|
||||||
specifically granted under this License. You may not convey a covered
|
|
||||||
work if you are a party to an arrangement with a third party that is
|
|
||||||
in the business of distributing software, under which you make payment
|
|
||||||
to the third party based on the extent of your activity of conveying
|
|
||||||
the work, and under which the third party grants, to any of the
|
|
||||||
parties who would receive the covered work from you, a discriminatory
|
|
||||||
patent license (a) in connection with copies of the covered work
|
|
||||||
conveyed by you (or copies made from those copies), or (b) primarily
|
|
||||||
for and in connection with specific products or compilations that
|
|
||||||
contain the covered work, unless you entered into that arrangement,
|
|
||||||
or that patent license was granted, prior to 28 March 2007.
|
|
||||||
|
|
||||||
Nothing in this License shall be construed as excluding or limiting
|
|
||||||
any implied license or other defenses to infringement that may
|
|
||||||
otherwise be available to you under applicable patent law.
|
|
||||||
|
|
||||||
12. No Surrender of Others' Freedom.
|
|
||||||
|
|
||||||
If conditions are imposed on you (whether by court order, agreement or
|
|
||||||
otherwise) that contradict the conditions of this License, they do not
|
|
||||||
excuse you from the conditions of this License. If you cannot convey a
|
|
||||||
covered work so as to satisfy simultaneously your obligations under this
|
|
||||||
License and any other pertinent obligations, then as a consequence you may
|
|
||||||
not convey it at all. For example, if you agree to terms that obligate you
|
|
||||||
to collect a royalty for further conveying from those to whom you convey
|
|
||||||
the Program, the only way you could satisfy both those terms and this
|
|
||||||
License would be to refrain entirely from conveying the Program.
|
|
||||||
|
|
||||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, if you modify the
|
|
||||||
Program, your modified version must prominently offer all users
|
|
||||||
interacting with it remotely through a computer network (if your version
|
|
||||||
supports such interaction) an opportunity to receive the Corresponding
|
|
||||||
Source of your version by providing access to the Corresponding Source
|
|
||||||
from a network server at no charge, through some standard or customary
|
|
||||||
means of facilitating copying of software. This Corresponding Source
|
|
||||||
shall include the Corresponding Source for any work covered by version 3
|
|
||||||
of the GNU General Public License that is incorporated pursuant to the
|
|
||||||
following paragraph.
|
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, you have
|
|
||||||
permission to link or combine any covered work with a work licensed
|
|
||||||
under version 3 of the GNU General Public License into a single
|
|
||||||
combined work, and to convey the resulting work. The terms of this
|
|
||||||
License will continue to apply to the part which is the covered work,
|
|
||||||
but the work with which it is combined will remain governed by version
|
|
||||||
3 of the GNU General Public License.
|
|
||||||
|
|
||||||
14. Revised Versions of this License.
|
|
||||||
|
|
||||||
The Free Software Foundation may publish revised and/or new versions of
|
|
||||||
the GNU Affero General Public License from time to time. Such new versions
|
|
||||||
will be similar in spirit to the present version, but may differ in detail to
|
|
||||||
address new problems or concerns.
|
|
||||||
|
|
||||||
Each version is given a distinguishing version number. If the
|
|
||||||
Program specifies that a certain numbered version of the GNU Affero General
|
|
||||||
Public License "or any later version" applies to it, you have the
|
|
||||||
option of following the terms and conditions either of that numbered
|
|
||||||
version or of any later version published by the Free Software
|
|
||||||
Foundation. If the Program does not specify a version number of the
|
|
||||||
GNU Affero General Public License, you may choose any version ever published
|
|
||||||
by the Free Software Foundation.
|
|
||||||
|
|
||||||
If the Program specifies that a proxy can decide which future
|
|
||||||
versions of the GNU Affero General Public License can be used, that proxy's
|
|
||||||
public statement of acceptance of a version permanently authorizes you
|
|
||||||
to choose that version for the Program.
|
|
||||||
|
|
||||||
Later license versions may give you additional or different
|
|
||||||
permissions. However, no additional obligations are imposed on any
|
|
||||||
author or copyright holder as a result of your choosing to follow a
|
|
||||||
later version.
|
|
||||||
|
|
||||||
15. Disclaimer of Warranty.
|
|
||||||
|
|
||||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
|
||||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
|
||||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
|
||||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
|
||||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
|
||||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
|
||||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
|
||||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
|
||||||
|
|
||||||
16. Limitation of Liability.
|
|
||||||
|
|
||||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
|
||||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
|
||||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
|
||||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
|
||||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
|
||||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
|
||||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
|
||||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
|
||||||
SUCH DAMAGES.
|
|
||||||
|
|
||||||
17. Interpretation of Sections 15 and 16.
|
|
||||||
|
|
||||||
If the disclaimer of warranty and limitation of liability provided
|
|
||||||
above cannot be given local legal effect according to their terms,
|
|
||||||
reviewing courts shall apply local law that most closely approximates
|
|
||||||
an absolute waiver of all civil liability in connection with the
|
|
||||||
Program, unless a warranty or assumption of liability accompanies a
|
|
||||||
copy of the Program in return for a fee.
|
|
||||||
|
|
||||||
END OF TERMS AND CONDITIONS
|
|
||||||
|
|
||||||
How to Apply These Terms to Your New Programs
|
|
||||||
|
|
||||||
If you develop a new program, and you want it to be of the greatest
|
|
||||||
possible use to the public, the best way to achieve this is to make it
|
|
||||||
free software which everyone can redistribute and change under these terms.
|
|
||||||
|
|
||||||
To do so, attach the following notices to the program. It is safest
|
|
||||||
to attach them to the start of each source file to most effectively
|
|
||||||
state the exclusion of warranty; and each file should have at least
|
|
||||||
the "copyright" line and a pointer to where the full notice is found.
|
|
||||||
|
|
||||||
<one line to give the program's name and a brief idea of what it does.>
|
|
||||||
Copyright (C) <year> <name of author>
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as published by
|
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
|
||||||
(at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
Also add information on how to contact you by electronic and paper mail.
|
|
||||||
|
|
||||||
If your software can interact with users remotely through a computer
|
|
||||||
network, you should also make sure that it provides a way for users to
|
|
||||||
get its source. For example, if your program is a web application, its
|
|
||||||
interface could display a "Source" link that leads users to an archive
|
|
||||||
of the code. There are many ways you could offer source, and different
|
|
||||||
solutions will be better for different programs; see section 13 for the
|
|
||||||
specific requirements.
|
|
||||||
|
|
||||||
You should also get your employer (if you work as a programmer) or school,
|
|
||||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
|
||||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
|
||||||
<https://www.gnu.org/licenses/>.
|
|
||||||
@@ -1,48 +1,19 @@
|
|||||||
# Papercrate
|
# Paperless-NEO
|
||||||
|
|
||||||

|
## Backend Integration Tests
|
||||||
|
|
||||||
## Single-Host Deployment (Docker Compose)
|
Integration tests require a running Postgres instance. The repository includes a lightweight compose file for local runs:
|
||||||
|
|
||||||
For self-hosting (for example on a Raspberry Pi), use the production-oriented
|
|
||||||
`docker-compose.yml`. Define the required secrets in a `.env` file alongside the
|
|
||||||
compose file before starting the stack:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cat <<'EOF' > .env
|
docker compose -f docker-compose.test.yml up -d
|
||||||
POSTGRES_PASSWORD=change-me
|
export TEST_DATABASE_URL=postgres://paperless:paperless_test@localhost:5433/paperless_test
|
||||||
MINIO_ROOT_PASSWORD=change-me-too
|
cargo test
|
||||||
JWT_SECRET=generate-a-long-random-string
|
|
||||||
WEBAUTHN_RP_ID=papercrate.local
|
|
||||||
WEBAUTHN_ORIGIN=http://papercrate.local:8080
|
|
||||||
# Optional overrides
|
|
||||||
# CORS_ALLOWED_ORIGIN=http://papercrate.local:8080
|
|
||||||
# REFRESH_COOKIE_SECURE=true
|
|
||||||
EOF
|
|
||||||
|
|
||||||
docker compose build
|
|
||||||
docker compose up -d
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Important:** the WebAuthn settings must match the public URL clients will use.
|
Stop the database when you are done:
|
||||||
The relying party (RP) identifier is the bare host name, while the origin must
|
|
||||||
include scheme and port. Adjust the values above if you serve Papercrate from a
|
|
||||||
different host, domain, or HTTPS endpoint—otherwise passkey registration and
|
|
||||||
login will fail.
|
|
||||||
|
|
||||||
The compose file builds the backend and frontend images locally, then launches
|
```bash
|
||||||
Postgres, MinIO, Quickwit, the API, background worker, WebDAV endpoint, and the
|
docker compose -f docker-compose.test.yml down
|
||||||
SPA frontend. Once the containers report healthy, visit `http://<host>:8080`
|
```
|
||||||
and use the passkey signup flow to provision the first tenant/user. Upgrades are
|
|
||||||
as simple as `git pull` followed by `docker compose up -d`.
|
|
||||||
|
|
||||||
## Screenshots
|
The compose service uses tmpfs storage, giving each test run a clean database.
|
||||||
|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
For development workflows (local stack, integration tests, migrations, and
|
|
||||||
configuration details) see [DEVELOPMENT.md](./DEVELOPMENT.md).
|
|
||||||
|
|||||||
Generated
+1060
-1699
File diff suppressed because it is too large
Load Diff
+17
-61
@@ -1,30 +1,30 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "papercrate"
|
name = "paperless-backend"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
# Web framework
|
# Web framework
|
||||||
axum = { version = "0.8", features = ["multipart"] }
|
axum = { version = "0.7", features = ["multipart"] }
|
||||||
tokio = { version = "1.48", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
tower = { version = "0.5", features = ["make", "util"] }
|
tower = { version = "0.4", features = ["make", "util"] }
|
||||||
tower-http = { version = "0.6", features = ["cors", "trace"] }
|
tower-http = { version = "0.5", features = ["cors", "trace"] }
|
||||||
axum-extra = { version = "0.12", features = ["typed-header"] }
|
axum-extra = { version = "0.9", features = ["typed-header"] }
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
diesel = { version = "2.3.3", features = ["postgres", "uuid", "chrono", "serde_json", "r2d2"] }
|
diesel = { version = "2.1", features = ["postgres", "uuid", "chrono", "serde_json", "r2d2"] }
|
||||||
diesel_migrations = "2.1"
|
diesel_migrations = "2.1"
|
||||||
uuid = { version = "1.6", features = ["v4", "serde"] }
|
uuid = { version = "1.6", features = ["v4", "serde"] }
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
|
||||||
# S3
|
# S3
|
||||||
rust-s3 = { version = "0.37", features = ["with-tokio", "tokio-rustls-tls"] }
|
aws-config = "1.1"
|
||||||
|
aws-sdk-s3 = "1.14"
|
||||||
|
aws-credential-types = "1.2"
|
||||||
|
|
||||||
# Serialization
|
# Serialization
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
envy = "0.4"
|
|
||||||
serde-aux = "4.4"
|
|
||||||
|
|
||||||
# Utilities
|
# Utilities
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
@@ -32,68 +32,24 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
|||||||
dotenv = "0.15"
|
dotenv = "0.15"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
hmac = "0.12"
|
|
||||||
bytes = "1.5"
|
bytes = "1.5"
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] }
|
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
|
||||||
pdfium-render = "0.8.36"
|
pdfium-render = "0.8"
|
||||||
mime_guess = "2.0"
|
mime_guess = "2.0"
|
||||||
tempfile = "3.10"
|
|
||||||
reqwest = { version = "0.12.24", default-features = false, features = ["json", "rustls-tls", "stream"] }
|
|
||||||
percent-encoding = "2.3"
|
|
||||||
base64 = "0.22"
|
|
||||||
quick-xml = "0.38"
|
|
||||||
futures-util = "0.3"
|
|
||||||
url = "2.5"
|
|
||||||
once_cell = "1.19"
|
|
||||||
regex = "1.11"
|
|
||||||
infer = "0.19"
|
|
||||||
utoipa = { version = "4.2", default-features = false, features = ["chrono", "uuid", "preserve_order"] }
|
|
||||||
clap = { version = "4.5", features = ["derive"] }
|
|
||||||
|
|
||||||
# Error handling
|
# Error handling
|
||||||
thiserror = "2.0"
|
thiserror = "1.0"
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
|
|
||||||
# Authentication & security
|
# Authentication & security
|
||||||
argon2 = "0.5"
|
argon2 = "0.5"
|
||||||
jsonwebtoken = { version = "10", features = ["rust_crypto"] }
|
jsonwebtoken = "9"
|
||||||
webauthn-rs = { version = "0.5", features = ["danger-allow-state-serialisation", "danger-credential-internals"] }
|
|
||||||
serde_bytes = "0.11"
|
|
||||||
serde_cbor_2 = "0.13"
|
|
||||||
|
|
||||||
# Misc
|
# Misc
|
||||||
rand = "0.9"
|
rand = "0.8"
|
||||||
hyper = "1.2"
|
|
||||||
http-body-util = "0.1"
|
|
||||||
chrono-tz = "0.8"
|
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
once_cell = "1.19"
|
once_cell = "1.19"
|
||||||
webauthn-rs-core = "0.5"
|
hyper = "1.2"
|
||||||
serde_yaml = "0.9"
|
http-body-util = "0.1"
|
||||||
|
|
||||||
[build-dependencies]
|
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
|
||||||
serde_yaml = "0.9"
|
|
||||||
serde_json = "1.0"
|
|
||||||
regex = "1.11"
|
|
||||||
|
|
||||||
[[bin]]
|
|
||||||
name = "backend"
|
|
||||||
path = "src/main.rs"
|
|
||||||
[[bin]]
|
|
||||||
name = "worker"
|
|
||||||
path = "src/bin/worker.rs"
|
|
||||||
|
|
||||||
[[bin]]
|
|
||||||
name = "webdav"
|
|
||||||
path = "src/bin/webdav.rs"
|
|
||||||
|
|
||||||
[[bin]]
|
|
||||||
name = "admin"
|
|
||||||
path = "src/bin/admin.rs"
|
|
||||||
|
|
||||||
[[bin]]
|
|
||||||
name = "openapi-dump"
|
|
||||||
path = "src/bin/openapi_dump.rs"
|
|
||||||
|
|||||||
@@ -1,143 +0,0 @@
|
|||||||
# syntax=docker/dockerfile:1.7
|
|
||||||
|
|
||||||
ARG RUST_VERSION=1
|
|
||||||
FROM --platform=$BUILDPLATFORM rust:${RUST_VERSION}-slim AS builder
|
|
||||||
ARG TARGETARCH
|
|
||||||
ENV TARGETARCH=${TARGETARCH}
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
RUN cat <<'SCRIPT' >/usr/local/bin/resolve-target.sh
|
|
||||||
#!/bin/sh
|
|
||||||
set -e
|
|
||||||
case "$1" in
|
|
||||||
amd64)
|
|
||||||
echo x86_64-unknown-linux-gnu
|
|
||||||
;;
|
|
||||||
arm64)
|
|
||||||
echo aarch64-unknown-linux-gnu
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "Unsupported TARGETARCH: $1" >&2
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
SCRIPT
|
|
||||||
RUN chmod +x /usr/local/bin/resolve-target.sh
|
|
||||||
|
|
||||||
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 \
|
|
||||||
pkg-config \
|
|
||||||
curl \
|
|
||||||
libssl-dev \
|
|
||||||
libpq-dev \
|
|
||||||
libjpeg-dev \
|
|
||||||
libpng-dev \
|
|
||||||
zlib1g-dev; \
|
|
||||||
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 build.rs ./
|
|
||||||
COPY src ./src
|
|
||||||
COPY migrations ./migrations
|
|
||||||
COPY tests ./tests
|
|
||||||
COPY resources ./resources
|
|
||||||
COPY diesel.toml ./
|
|
||||||
|
|
||||||
RUN set -eux; \
|
|
||||||
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
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends \
|
|
||||||
ca-certificates \
|
|
||||||
curl \
|
|
||||||
libssl3 \
|
|
||||||
libpq5 \
|
|
||||||
libjpeg62-turbo \
|
|
||||||
libpng16-16 \
|
|
||||||
ocrmypdf \
|
|
||||||
tesseract-ocr \
|
|
||||||
ghostscript \
|
|
||||||
qpdf \
|
|
||||||
ffmpeg \
|
|
||||||
&& rm -rf /var/lib/apt/lists/* \
|
|
||||||
&& mkdir -p /usr/local/lib \
|
|
||||||
&& useradd --system --create-home --uid 10001 appuser
|
|
||||||
|
|
||||||
COPY --from=builder /artifacts/backend /usr/local/bin/papercrate-backend
|
|
||||||
COPY --from=builder /artifacts/worker /usr/local/bin/papercrate-worker
|
|
||||||
COPY --from=builder /artifacts/webdav /usr/local/bin/papercrate-webdav
|
|
||||||
COPY --from=builder /artifacts/admin /usr/local/bin/papercrate-admin
|
|
||||||
COPY --from=builder /artifacts/libpdfium.so /usr/local/lib/libpdfium.so
|
|
||||||
RUN ldconfig
|
|
||||||
COPY migrations ./migrations
|
|
||||||
|
|
||||||
ENV RUST_LOG=info
|
|
||||||
USER appuser
|
|
||||||
EXPOSE 3000
|
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/local/bin/papercrate-backend"]
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
use std::env;
|
|
||||||
use std::fs;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
use regex::escape;
|
|
||||||
use serde::Deserialize;
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct CaseSuite {
|
|
||||||
cases: Vec<CaseName>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct CaseName {
|
|
||||||
name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct MonthSuite {
|
|
||||||
months: Vec<MonthDefinition>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct MonthDefinition {
|
|
||||||
name: String,
|
|
||||||
month: u32,
|
|
||||||
#[serde(default)]
|
|
||||||
locales: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sanitize(name: &str) -> String {
|
|
||||||
let mut out = String::with_capacity(name.len());
|
|
||||||
for ch in name.chars() {
|
|
||||||
if ch.is_ascii_alphanumeric() {
|
|
||||||
out.push(ch);
|
|
||||||
} else {
|
|
||||||
out.push('_');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if out.is_empty() {
|
|
||||||
"case".to_string()
|
|
||||||
} else if out.chars().next().unwrap().is_ascii_digit() {
|
|
||||||
format!("_{}", out)
|
|
||||||
} else {
|
|
||||||
out
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn quote(value: &str) -> String {
|
|
||||||
serde_json::to_string(value).expect("string literal")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn generate_tests() -> Result<String, Box<dyn std::error::Error>> {
|
|
||||||
let yaml_path = PathBuf::from("tests/data/issued_at_cases.yaml");
|
|
||||||
let contents = fs::read_to_string(&yaml_path)?;
|
|
||||||
let suite: CaseSuite = serde_yaml::from_str(&contents)?;
|
|
||||||
|
|
||||||
let mut output =
|
|
||||||
String::from("#[cfg(test)]\npub mod issued_at_generated_tests {\n use super::*;\n");
|
|
||||||
|
|
||||||
for case in suite.cases {
|
|
||||||
let ident = sanitize(&case.name);
|
|
||||||
output.push_str(&format!(
|
|
||||||
" #[test]\n fn {}() {{\n run_named_case(\"{}\");\n }}\n",
|
|
||||||
ident, case.name
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
output.push_str("}\n");
|
|
||||||
Ok(output)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn generate_months() -> Result<String, Box<dyn std::error::Error>> {
|
|
||||||
let yaml_path = PathBuf::from("resources/issued_at_months.yaml");
|
|
||||||
let contents = fs::read_to_string(&yaml_path)?;
|
|
||||||
let suite: MonthSuite = serde_yaml::from_str(&contents)?;
|
|
||||||
|
|
||||||
let mut pattern_parts = Vec::with_capacity(suite.months.len());
|
|
||||||
let mut entries = String::new();
|
|
||||||
for entry in &suite.months {
|
|
||||||
pattern_parts.push(escape(&entry.name));
|
|
||||||
let locales_literal = if entry.locales.is_empty() {
|
|
||||||
"&[]".to_string()
|
|
||||||
} else {
|
|
||||||
let joined = entry
|
|
||||||
.locales
|
|
||||||
.iter()
|
|
||||||
.map(|loc| quote(loc))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(", ");
|
|
||||||
format!("&[{}]", joined)
|
|
||||||
};
|
|
||||||
entries.push_str(&format!(
|
|
||||||
" MonthVariant {{ name: {}, month: {}, locales: {} }},\n",
|
|
||||||
quote(&entry.name),
|
|
||||||
entry.month,
|
|
||||||
locales_literal
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let pattern_literal = quote(&pattern_parts.join("|"));
|
|
||||||
let output = format!(
|
|
||||||
"pub(super) static MONTH_VARIANTS: &[MonthVariant] = &[\n{entries}];\n\n",
|
|
||||||
entries = entries
|
|
||||||
) + &format!(
|
|
||||||
"pub(super) const MONTH_PATTERN: &str = {};\n",
|
|
||||||
pattern_literal
|
|
||||||
);
|
|
||||||
Ok(output)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
println!("cargo:rerun-if-changed=tests/data/issued_at_cases.yaml");
|
|
||||||
println!("cargo:rerun-if-changed=resources/issued_at_months.yaml");
|
|
||||||
|
|
||||||
let out_dir = PathBuf::from(env::var("OUT_DIR")?);
|
|
||||||
fs::write(
|
|
||||||
out_dir.join("issued_at_generated_tests.rs"),
|
|
||||||
generate_tests()?,
|
|
||||||
)?;
|
|
||||||
fs::write(out_dir.join("issued_at_months.rs"), generate_months()?)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
use argon2::{
|
||||||
|
password_hash::{PasswordHasher, SaltString},
|
||||||
|
Argon2,
|
||||||
|
};
|
||||||
|
use rand::thread_rng;
|
||||||
|
use std::env;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let password = env::args()
|
||||||
|
.nth(1)
|
||||||
|
.expect("Usage: cargo run --example hash_password <password>");
|
||||||
|
let salt = SaltString::generate(&mut thread_rng());
|
||||||
|
let argon2 = Argon2::default();
|
||||||
|
let hash = argon2
|
||||||
|
.hash_password(password.as_bytes(), &salt)
|
||||||
|
.expect("hashing failed")
|
||||||
|
.to_string();
|
||||||
|
println!("{}", hash);
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -0,0 +1,11 @@
|
|||||||
|
DROP INDEX IF EXISTS idx_document_tags_tag;
|
||||||
|
DROP TABLE IF EXISTS document_tags;
|
||||||
|
DROP INDEX IF EXISTS idx_document_versions_document;
|
||||||
|
DROP TABLE IF EXISTS document_versions;
|
||||||
|
DROP INDEX IF EXISTS idx_documents_deleted_at;
|
||||||
|
DROP INDEX IF EXISTS idx_documents_folder;
|
||||||
|
DROP TABLE IF EXISTS documents;
|
||||||
|
DROP INDEX IF EXISTS idx_folders_parent;
|
||||||
|
DROP TABLE IF EXISTS folders;
|
||||||
|
DROP TABLE IF EXISTS tags;
|
||||||
|
DROP TABLE IF EXISTS users;
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||||
|
|
||||||
|
CREATE TABLE users (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
username VARCHAR(100) NOT NULL UNIQUE,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
role VARCHAR(16) NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE folders (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
parent_id UUID REFERENCES folders(id) ON DELETE SET NULL,
|
||||||
|
path_cache VARCHAR(1000),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT folders_parent_name_unique UNIQUE (parent_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_folders_parent ON folders(parent_id);
|
||||||
|
|
||||||
|
CREATE TABLE documents (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
filename VARCHAR(255) NOT NULL,
|
||||||
|
original_name VARCHAR(255) NOT NULL,
|
||||||
|
content_type VARCHAR(100),
|
||||||
|
folder_id UUID REFERENCES folders(id) ON DELETE SET NULL,
|
||||||
|
current_version INTEGER NOT NULL,
|
||||||
|
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
metadata JSONB NOT NULL DEFAULT '{}'::jsonb
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_documents_folder ON documents(folder_id);
|
||||||
|
CREATE INDEX idx_documents_deleted_at ON documents(deleted_at);
|
||||||
|
|
||||||
|
CREATE TABLE document_versions (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||||
|
version_number INTEGER NOT NULL,
|
||||||
|
s3_key VARCHAR(500) NOT NULL,
|
||||||
|
size_bytes BIGINT NOT NULL,
|
||||||
|
checksum VARCHAR(64) NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
operations_summary JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
CONSTRAINT document_versions_unique_version UNIQUE (document_id, version_number)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_document_versions_document ON document_versions(document_id);
|
||||||
|
|
||||||
|
CREATE TABLE tags (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
label VARCHAR(100) NOT NULL UNIQUE,
|
||||||
|
color VARCHAR(7),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE document_tags (
|
||||||
|
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||||
|
tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||||
|
assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
assigned_by UUID REFERENCES users(id),
|
||||||
|
PRIMARY KEY (document_id, tag_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_document_tags_tag ON document_tags(tag_id);
|
||||||
|
|
||||||
|
INSERT INTO users (id, username, password_hash, role)
|
||||||
|
VALUES (
|
||||||
|
gen_random_uuid(),
|
||||||
|
'admin',
|
||||||
|
'$argon2id$v=19$m=19456,t=2,p=1$UMkfsNut028fmZupy9JoQg$/YFvGQoEZ2hhMiDCyv68ZROF97GcwAxxRwRgwSbpX5U',
|
||||||
|
'admin'
|
||||||
|
);
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
DROP TRIGGER IF EXISTS trg_jobs_updated_at ON jobs;
|
||||||
|
DROP FUNCTION IF EXISTS touch_jobs_updated_at;
|
||||||
|
DROP INDEX IF EXISTS idx_jobs_job_type;
|
||||||
|
DROP INDEX IF EXISTS idx_jobs_status_run_after;
|
||||||
|
DROP TABLE IF EXISTS jobs;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
CREATE TABLE jobs (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
job_type TEXT NOT NULL,
|
||||||
|
payload JSONB NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'queued',
|
||||||
|
attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
run_after TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
last_error TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT jobs_status_check CHECK (status IN ('queued', 'processing', 'succeeded', 'failed'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_jobs_status_run_after ON jobs (status, run_after);
|
||||||
|
CREATE INDEX idx_jobs_job_type ON jobs (job_type);
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION touch_jobs_updated_at()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = now();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_jobs_updated_at
|
||||||
|
BEFORE UPDATE ON jobs
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION touch_jobs_updated_at();
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
DROP INDEX IF EXISTS idx_document_assets_type;
|
||||||
|
DROP INDEX IF EXISTS idx_document_assets_version;
|
||||||
|
DROP TABLE IF EXISTS document_assets;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
CREATE TABLE document_assets (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
document_version_id UUID NOT NULL REFERENCES document_versions(id) ON DELETE CASCADE,
|
||||||
|
asset_type TEXT NOT NULL,
|
||||||
|
s3_key TEXT NOT NULL,
|
||||||
|
mime_type TEXT NOT NULL,
|
||||||
|
width INTEGER,
|
||||||
|
height INTEGER,
|
||||||
|
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT document_assets_unique UNIQUE (document_version_id, asset_type)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_document_assets_version ON document_assets(document_version_id);
|
||||||
|
CREATE INDEX idx_document_assets_type ON document_assets(asset_type);
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
DROP INDEX IF EXISTS folders_parent_name_unique_idx;
|
||||||
|
|
||||||
|
ALTER TABLE folders
|
||||||
|
ADD CONSTRAINT folders_parent_name_unique UNIQUE (parent_id, name);
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE folders
|
||||||
|
DROP CONSTRAINT IF EXISTS folders_parent_name_unique;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX folders_parent_name_unique_idx
|
||||||
|
ON folders (COALESCE(parent_id, '00000000-0000-0000-0000-000000000000'::uuid), name);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE documents
|
||||||
|
DROP COLUMN issued_at;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE documents
|
||||||
|
ADD COLUMN issued_at TIMESTAMPTZ;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE documents
|
||||||
|
DROP COLUMN name;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
ALTER TABLE documents
|
||||||
|
ADD COLUMN name VARCHAR(255);
|
||||||
|
|
||||||
|
UPDATE documents
|
||||||
|
SET name = CASE
|
||||||
|
WHEN filename ~ '\\.[^./]+$' THEN regexp_replace(filename, '\\.[^./]+$', '')
|
||||||
|
ELSE filename
|
||||||
|
END;
|
||||||
|
|
||||||
|
ALTER TABLE documents
|
||||||
|
ALTER COLUMN name SET NOT NULL;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE documents
|
||||||
|
RENAME COLUMN title TO name;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE documents
|
||||||
|
RENAME COLUMN name TO title;
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
DROP TRIGGER IF EXISTS trg_jobs_updated_at ON jobs;
|
|
||||||
DROP FUNCTION IF EXISTS touch_jobs_updated_at();
|
|
||||||
|
|
||||||
DROP TABLE IF EXISTS webauthn_challenges;
|
|
||||||
DROP TABLE IF EXISTS user_passkeys;
|
|
||||||
DROP TABLE IF EXISTS webdav_tokens;
|
|
||||||
ALTER TABLE documents DROP CONSTRAINT IF EXISTS documents_current_version_fk;
|
|
||||||
DROP TABLE IF EXISTS document_asset_objects;
|
|
||||||
DROP TABLE IF EXISTS document_assets;
|
|
||||||
DROP TABLE IF EXISTS document_versions;
|
|
||||||
DROP TABLE IF EXISTS document_tags;
|
|
||||||
DROP TABLE IF EXISTS document_correspondents;
|
|
||||||
DROP TABLE IF EXISTS correspondents;
|
|
||||||
DROP TABLE IF EXISTS documents;
|
|
||||||
DROP TABLE IF EXISTS folders;
|
|
||||||
DROP TABLE IF EXISTS tags;
|
|
||||||
DROP TABLE IF EXISTS jobs;
|
|
||||||
DROP TABLE IF EXISTS refresh_tokens;
|
|
||||||
DROP TABLE IF EXISTS user_memberships;
|
|
||||||
DROP TABLE IF EXISTS users;
|
|
||||||
DROP TABLE IF EXISTS tenants;
|
|
||||||
|
|
||||||
DROP TYPE IF EXISTS tenant_status;
|
|
||||||
|
|
||||||
DROP EXTENSION IF EXISTS "pgcrypto";
|
|
||||||
@@ -1,295 +0,0 @@
|
|||||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
|
||||||
|
|
||||||
CREATE TYPE tenant_status AS ENUM ('creating', 'active', 'suspended', 'deleting', 'error');
|
|
||||||
|
|
||||||
CREATE TABLE tenants (
|
|
||||||
id UUID PRIMARY KEY,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
storage_root TEXT,
|
|
||||||
quickwit_index TEXT,
|
|
||||||
config JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
status tenant_status NOT NULL,
|
|
||||||
created_by UUID
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX tenants_storage_root_unique
|
|
||||||
ON tenants (storage_root)
|
|
||||||
WHERE storage_root IS NOT NULL;
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX tenants_quickwit_index_unique
|
|
||||||
ON tenants (quickwit_index)
|
|
||||||
WHERE quickwit_index IS NOT NULL;
|
|
||||||
|
|
||||||
CREATE TABLE users (
|
|
||||||
id UUID PRIMARY KEY,
|
|
||||||
username VARCHAR(100) NOT NULL UNIQUE,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE user_memberships (
|
|
||||||
id UUID PRIMARY KEY,
|
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
UNIQUE (user_id, tenant_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX user_memberships_tenant_id_idx ON user_memberships(tenant_id);
|
|
||||||
CREATE INDEX user_memberships_user_id_idx ON user_memberships(user_id);
|
|
||||||
|
|
||||||
CREATE TABLE folders (
|
|
||||||
id UUID PRIMARY KEY,
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
parent_id UUID REFERENCES folders(id) ON DELETE SET NULL,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES tenants(id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_folders_parent ON folders(parent_id);
|
|
||||||
CREATE INDEX folders_tenant_id_idx ON folders(tenant_id);
|
|
||||||
CREATE UNIQUE INDEX folders_tenant_parent_name_unique_idx
|
|
||||||
ON folders (
|
|
||||||
tenant_id,
|
|
||||||
COALESCE(parent_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
|
||||||
name
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE documents (
|
|
||||||
id UUID PRIMARY KEY,
|
|
||||||
filename VARCHAR(255) NOT NULL,
|
|
||||||
original_name VARCHAR(255) NOT NULL,
|
|
||||||
content_type VARCHAR(100),
|
|
||||||
folder_id UUID REFERENCES folders(id) ON DELETE SET NULL,
|
|
||||||
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
deleted_at TIMESTAMPTZ,
|
|
||||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
||||||
issued_at TIMESTAMPTZ,
|
|
||||||
title VARCHAR(255) NOT NULL,
|
|
||||||
current_version_id UUID NOT NULL,
|
|
||||||
tenant_id UUID NOT NULL REFERENCES tenants(id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_documents_folder ON documents(folder_id);
|
|
||||||
CREATE INDEX idx_documents_deleted_at ON documents(deleted_at);
|
|
||||||
CREATE INDEX documents_tenant_id_idx ON documents(tenant_id);
|
|
||||||
CREATE INDEX idx_documents_current_version_id ON documents(current_version_id);
|
|
||||||
CREATE INDEX idx_documents_folder_title
|
|
||||||
ON documents (
|
|
||||||
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
|
||||||
title
|
|
||||||
)
|
|
||||||
WHERE deleted_at IS NULL;
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX documents_tenant_folder_filename_unique
|
|
||||||
ON documents (
|
|
||||||
tenant_id,
|
|
||||||
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
|
||||||
filename
|
|
||||||
)
|
|
||||||
WHERE deleted_at IS NULL;
|
|
||||||
|
|
||||||
CREATE TABLE document_versions (
|
|
||||||
id UUID PRIMARY KEY,
|
|
||||||
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
|
||||||
version_number INT NOT NULL,
|
|
||||||
s3_key VARCHAR(500) NOT NULL,
|
|
||||||
size_bytes BIGINT NOT NULL,
|
|
||||||
checksum VARCHAR(64) NOT NULL,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
||||||
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
|
||||||
CONSTRAINT document_versions_unique_version UNIQUE (document_id, version_number)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_document_versions_document ON document_versions(document_id);
|
|
||||||
CREATE INDEX document_versions_tenant_id_idx ON document_versions(tenant_id);
|
|
||||||
|
|
||||||
ALTER TABLE documents
|
|
||||||
ADD CONSTRAINT documents_current_version_fk
|
|
||||||
FOREIGN KEY (current_version_id)
|
|
||||||
REFERENCES document_versions(id)
|
|
||||||
DEFERRABLE INITIALLY DEFERRED;
|
|
||||||
|
|
||||||
CREATE TABLE tags (
|
|
||||||
id UUID PRIMARY KEY,
|
|
||||||
label VARCHAR(100) NOT NULL,
|
|
||||||
color VARCHAR(7),
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES tenants(id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX tags_tenant_label_unique ON tags(tenant_id, label);
|
|
||||||
CREATE INDEX tags_tenant_id_idx ON tags(tenant_id);
|
|
||||||
|
|
||||||
CREATE TABLE document_tags (
|
|
||||||
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
|
||||||
tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
|
||||||
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
assigned_by UUID REFERENCES users(id),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
|
||||||
PRIMARY KEY (document_id, tag_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_document_tags_tag ON document_tags(tag_id);
|
|
||||||
CREATE INDEX document_tags_tenant_id_idx ON document_tags(tenant_id);
|
|
||||||
|
|
||||||
CREATE TABLE correspondents (
|
|
||||||
id UUID PRIMARY KEY,
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES tenants(id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX correspondents_tenant_name_unique
|
|
||||||
ON correspondents (tenant_id, name);
|
|
||||||
CREATE INDEX correspondents_tenant_id_idx ON correspondents(tenant_id);
|
|
||||||
|
|
||||||
CREATE TABLE document_correspondents (
|
|
||||||
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
|
||||||
correspondent_id UUID NOT NULL REFERENCES correspondents(id) ON DELETE CASCADE,
|
|
||||||
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
assigned_by UUID REFERENCES users(id),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
|
||||||
PRIMARY KEY (document_id, correspondent_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_document_correspondents_document ON document_correspondents(document_id);
|
|
||||||
CREATE INDEX idx_document_correspondents_correspondent ON document_correspondents(correspondent_id);
|
|
||||||
CREATE INDEX document_correspondents_tenant_id_idx ON document_correspondents(tenant_id);
|
|
||||||
|
|
||||||
CREATE TABLE document_assets (
|
|
||||||
id UUID PRIMARY KEY,
|
|
||||||
document_version_id UUID NOT NULL REFERENCES document_versions(id) ON DELETE CASCADE,
|
|
||||||
asset_type TEXT NOT NULL,
|
|
||||||
mime_type TEXT NOT NULL,
|
|
||||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
cardinality INTEGER,
|
|
||||||
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
|
||||||
CONSTRAINT document_assets_unique UNIQUE (document_version_id, asset_type),
|
|
||||||
CONSTRAINT document_assets_cardinality_positive CHECK (cardinality IS NULL OR cardinality >= 1)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_document_assets_version ON document_assets(document_version_id);
|
|
||||||
CREATE INDEX idx_document_assets_type ON document_assets(asset_type);
|
|
||||||
CREATE INDEX document_assets_tenant_id_idx ON document_assets(tenant_id);
|
|
||||||
|
|
||||||
CREATE TABLE document_asset_objects (
|
|
||||||
id UUID PRIMARY KEY,
|
|
||||||
asset_id UUID NOT NULL REFERENCES document_assets(id) ON DELETE CASCADE,
|
|
||||||
ordinal INTEGER NOT NULL,
|
|
||||||
s3_key TEXT NOT NULL,
|
|
||||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
||||||
tenant_id UUID NOT NULL REFERENCES 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 document_asset_objects(asset_id, ordinal);
|
|
||||||
CREATE INDEX document_asset_objects_tenant_id_idx ON document_asset_objects(tenant_id);
|
|
||||||
|
|
||||||
CREATE TABLE jobs (
|
|
||||||
id UUID PRIMARY KEY,
|
|
||||||
job_type TEXT NOT NULL,
|
|
||||||
payload JSONB NOT NULL,
|
|
||||||
status TEXT NOT NULL DEFAULT 'queued',
|
|
||||||
attempts INTEGER NOT NULL DEFAULT 0,
|
|
||||||
run_after TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
last_error TEXT,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
|
||||||
CONSTRAINT jobs_status_check CHECK (status IN ('queued', 'processing', 'succeeded', 'failed'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_jobs_status_run_after ON jobs(status, run_after);
|
|
||||||
CREATE INDEX idx_jobs_job_type ON jobs(job_type);
|
|
||||||
CREATE INDEX jobs_tenant_id_idx ON jobs(tenant_id);
|
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION touch_jobs_updated_at()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
NEW.updated_at = NOW();
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
CREATE TRIGGER trg_jobs_updated_at
|
|
||||||
BEFORE UPDATE ON jobs
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION touch_jobs_updated_at();
|
|
||||||
|
|
||||||
CREATE TABLE refresh_tokens (
|
|
||||||
id UUID PRIMARY KEY,
|
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
token_hash TEXT NOT NULL,
|
|
||||||
issued_at TIMESTAMPTZ NOT NULL,
|
|
||||||
expires_at TIMESTAMPTZ NOT NULL,
|
|
||||||
revoked_at TIMESTAMPTZ,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES tenants(id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id);
|
|
||||||
CREATE INDEX idx_refresh_tokens_token_hash ON refresh_tokens(token_hash);
|
|
||||||
CREATE INDEX refresh_tokens_tenant_id_idx ON refresh_tokens(tenant_id);
|
|
||||||
|
|
||||||
CREATE TABLE webdav_tokens (
|
|
||||||
id UUID PRIMARY KEY,
|
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
|
||||||
token_prefix TEXT NOT NULL,
|
|
||||||
token_hash TEXT NOT NULL,
|
|
||||||
label TEXT,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
last_used_at TIMESTAMPTZ,
|
|
||||||
expires_at TIMESTAMPTZ,
|
|
||||||
revoked_at TIMESTAMPTZ
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX webdav_tokens_token_prefix_key ON webdav_tokens(token_prefix);
|
|
||||||
CREATE INDEX webdav_tokens_user_tenant_idx ON webdav_tokens(user_id, tenant_id);
|
|
||||||
|
|
||||||
CREATE TABLE user_passkeys (
|
|
||||||
id UUID PRIMARY KEY,
|
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
credential_id BYTEA NOT NULL UNIQUE,
|
|
||||||
public_key BYTEA NOT NULL,
|
|
||||||
credential JSONB NOT NULL,
|
|
||||||
sign_count BIGINT NOT NULL,
|
|
||||||
transports TEXT[] NOT NULL DEFAULT '{}'::text[],
|
|
||||||
aaguid UUID,
|
|
||||||
nickname TEXT,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
last_used_at TIMESTAMPTZ,
|
|
||||||
revoked_at TIMESTAMPTZ,
|
|
||||||
revoked_by UUID,
|
|
||||||
revoked_reason TEXT
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX user_passkeys_user_id_idx ON user_passkeys(user_id);
|
|
||||||
|
|
||||||
CREATE TABLE webauthn_challenges (
|
|
||||||
id UUID PRIMARY KEY,
|
|
||||||
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
purpose TEXT NOT NULL,
|
|
||||||
challenge BYTEA NOT NULL,
|
|
||||||
state BYTEA NOT NULL,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
expires_at TIMESTAMPTZ NOT NULL,
|
|
||||||
CONSTRAINT webauthn_challenges_purpose_check CHECK (purpose IN ('registration', 'authentication'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX webauthn_challenges_user_id_idx ON webauthn_challenges(user_id);
|
|
||||||
CREATE INDEX webauthn_challenges_expires_at_idx ON webauthn_challenges(expires_at);
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
ALTER TABLE documents
|
|
||||||
RENAME COLUMN created_at TO uploaded_at;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
ALTER TABLE documents
|
|
||||||
RENAME COLUMN uploaded_at TO created_at;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
DROP TABLE magic_tokens;
|
|
||||||
DROP TYPE magic_token_kind;
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
CREATE TYPE magic_token_kind AS ENUM ('email_login', 'demo_login');
|
|
||||||
|
|
||||||
CREATE TABLE magic_tokens (
|
|
||||||
id UUID PRIMARY KEY,
|
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
kind magic_token_kind NOT NULL,
|
|
||||||
token_hash VARCHAR NOT NULL UNIQUE,
|
|
||||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
||||||
expires_at TIMESTAMPTZ NOT NULL,
|
|
||||||
max_uses INTEGER,
|
|
||||||
used_count INTEGER NOT NULL DEFAULT 0,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
|
||||||
last_used_at TIMESTAMPTZ
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX magic_tokens_token_hash_idx ON magic_tokens (token_hash);
|
|
||||||
CREATE INDEX magic_tokens_expires_at_idx ON magic_tokens (expires_at);
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
-- Move tables and types back to the public schema
|
|
||||||
ALTER TABLE tenant.webdav_tokens SET SCHEMA public;
|
|
||||||
ALTER TABLE tenant.user_memberships SET SCHEMA public;
|
|
||||||
ALTER TABLE tenant.refresh_tokens SET SCHEMA public;
|
|
||||||
ALTER TABLE tenant.tags SET SCHEMA public;
|
|
||||||
ALTER TABLE tenant.document_correspondents SET SCHEMA public;
|
|
||||||
ALTER TABLE tenant.document_tags SET SCHEMA public;
|
|
||||||
ALTER TABLE tenant.document_asset_objects SET SCHEMA public;
|
|
||||||
ALTER TABLE tenant.document_assets SET SCHEMA public;
|
|
||||||
ALTER TABLE tenant.document_versions SET SCHEMA public;
|
|
||||||
ALTER TABLE tenant.documents SET SCHEMA public;
|
|
||||||
ALTER TABLE tenant.folders SET SCHEMA public;
|
|
||||||
ALTER TABLE tenant.correspondents SET SCHEMA public;
|
|
||||||
|
|
||||||
ALTER FUNCTION shared.touch_jobs_updated_at() SET SCHEMA public;
|
|
||||||
|
|
||||||
ALTER TABLE shared.magic_tokens SET SCHEMA public;
|
|
||||||
ALTER TABLE shared.jobs SET SCHEMA public;
|
|
||||||
ALTER TABLE shared.webauthn_challenges SET SCHEMA public;
|
|
||||||
ALTER TABLE shared.user_passkeys SET SCHEMA public;
|
|
||||||
ALTER TABLE shared.users SET SCHEMA public;
|
|
||||||
ALTER TABLE shared.tenants SET SCHEMA public;
|
|
||||||
|
|
||||||
ALTER TYPE shared.magic_token_kind SET SCHEMA public;
|
|
||||||
ALTER TYPE shared.tenant_status SET SCHEMA public;
|
|
||||||
|
|
||||||
DROP SCHEMA IF EXISTS tenant CASCADE;
|
|
||||||
DROP SCHEMA IF EXISTS shared CASCADE;
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
CREATE SCHEMA IF NOT EXISTS shared;
|
|
||||||
CREATE SCHEMA IF NOT EXISTS tenant;
|
|
||||||
|
|
||||||
-- Move global types and tables into the shared schema
|
|
||||||
ALTER TYPE tenant_status SET SCHEMA shared;
|
|
||||||
ALTER TYPE magic_token_kind SET SCHEMA shared;
|
|
||||||
|
|
||||||
ALTER TABLE tenants SET SCHEMA shared;
|
|
||||||
ALTER TABLE users SET SCHEMA shared;
|
|
||||||
ALTER TABLE user_passkeys SET SCHEMA shared;
|
|
||||||
ALTER TABLE webauthn_challenges SET SCHEMA shared;
|
|
||||||
ALTER TABLE jobs SET SCHEMA shared;
|
|
||||||
ALTER TABLE magic_tokens SET SCHEMA shared;
|
|
||||||
|
|
||||||
ALTER FUNCTION touch_jobs_updated_at() SET SCHEMA shared;
|
|
||||||
|
|
||||||
-- Move tenant-scoped tables into the tenant schema
|
|
||||||
ALTER TABLE correspondents SET SCHEMA tenant;
|
|
||||||
ALTER TABLE folders SET SCHEMA tenant;
|
|
||||||
ALTER TABLE documents SET SCHEMA tenant;
|
|
||||||
ALTER TABLE document_versions SET SCHEMA tenant;
|
|
||||||
ALTER TABLE document_assets SET SCHEMA tenant;
|
|
||||||
ALTER TABLE document_asset_objects SET SCHEMA tenant;
|
|
||||||
ALTER TABLE document_tags SET SCHEMA tenant;
|
|
||||||
ALTER TABLE document_correspondents SET SCHEMA tenant;
|
|
||||||
ALTER TABLE tags SET SCHEMA tenant;
|
|
||||||
ALTER TABLE refresh_tokens SET SCHEMA tenant;
|
|
||||||
ALTER TABLE user_memberships SET SCHEMA tenant;
|
|
||||||
ALTER TABLE webdav_tokens SET SCHEMA tenant;
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'papercrate_app') THEN
|
|
||||||
RETURN;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
GRANT USAGE ON SCHEMA shared TO papercrate_app;
|
|
||||||
GRANT USAGE ON SCHEMA tenant TO papercrate_app;
|
|
||||||
GRANT SELECT ON ALL TABLES IN SCHEMA shared TO papercrate_app;
|
|
||||||
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA tenant TO papercrate_app;
|
|
||||||
|
|
||||||
ALTER DEFAULT PRIVILEGES IN SCHEMA shared GRANT SELECT ON TABLES TO papercrate_app;
|
|
||||||
ALTER DEFAULT PRIVILEGES IN SCHEMA tenant GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO papercrate_app;
|
|
||||||
END
|
|
||||||
$$;
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
DROP POLICY IF EXISTS tenant_membership_select_policy ON tenant.user_memberships;
|
|
||||||
ALTER TABLE tenant.user_memberships NO FORCE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE tenant.user_memberships DISABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.webdav_tokens;
|
|
||||||
ALTER TABLE tenant.webdav_tokens NO FORCE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE tenant.webdav_tokens DISABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.tags;
|
|
||||||
ALTER TABLE tenant.tags NO FORCE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE tenant.tags DISABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
DROP POLICY IF EXISTS tenant_refresh_token_policy ON tenant.refresh_tokens;
|
|
||||||
ALTER TABLE tenant.refresh_tokens NO FORCE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE tenant.refresh_tokens DISABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.folders;
|
|
||||||
ALTER TABLE tenant.folders NO FORCE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE tenant.folders DISABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.documents;
|
|
||||||
ALTER TABLE tenant.documents NO FORCE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE tenant.documents DISABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.document_versions;
|
|
||||||
ALTER TABLE tenant.document_versions NO FORCE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE tenant.document_versions DISABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.document_tags;
|
|
||||||
ALTER TABLE tenant.document_tags NO FORCE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE tenant.document_tags DISABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.document_correspondents;
|
|
||||||
ALTER TABLE tenant.document_correspondents NO FORCE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE tenant.document_correspondents DISABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.document_assets;
|
|
||||||
ALTER TABLE tenant.document_assets NO FORCE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE tenant.document_assets DISABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.document_asset_objects;
|
|
||||||
ALTER TABLE tenant.document_asset_objects NO FORCE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE tenant.document_asset_objects DISABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.correspondents;
|
|
||||||
ALTER TABLE tenant.correspondents NO FORCE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE tenant.correspondents DISABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
DROP FUNCTION IF EXISTS shared.current_refresh_token_hash();
|
|
||||||
DROP FUNCTION IF EXISTS shared.current_user_id();
|
|
||||||
DROP FUNCTION IF EXISTS shared.current_tenant_id();
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
CREATE OR REPLACE FUNCTION shared.current_tenant_id() RETURNS uuid AS $$
|
|
||||||
SELECT CASE
|
|
||||||
WHEN setting IS NULL OR setting = '' THEN NULL
|
|
||||||
ELSE setting::uuid
|
|
||||||
END
|
|
||||||
FROM (SELECT current_setting('papercrate.tenant_id', true) AS setting) s;
|
|
||||||
$$ LANGUAGE SQL STABLE;
|
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION shared.current_user_id() RETURNS uuid AS $$
|
|
||||||
SELECT CASE
|
|
||||||
WHEN setting IS NULL OR setting = '' THEN NULL
|
|
||||||
ELSE setting::uuid
|
|
||||||
END
|
|
||||||
FROM (SELECT current_setting('papercrate.user_id', true) AS setting) s;
|
|
||||||
$$ LANGUAGE SQL STABLE;
|
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION shared.current_refresh_token_hash() RETURNS text AS $$
|
|
||||||
SELECT NULLIF(current_setting('papercrate.refresh_token_hash', true), '')
|
|
||||||
$$ LANGUAGE SQL STABLE;
|
|
||||||
|
|
||||||
-- Helper to create tenant isolation policy
|
|
||||||
CREATE OR REPLACE FUNCTION shared.ensure_tenant_policy(table_reg regclass) RETURNS void AS $$
|
|
||||||
BEGIN
|
|
||||||
EXECUTE format('ALTER TABLE %s ENABLE ROW LEVEL SECURITY', table_reg);
|
|
||||||
EXECUTE format('ALTER TABLE %s FORCE ROW LEVEL SECURITY', table_reg);
|
|
||||||
EXECUTE format(
|
|
||||||
'CREATE POLICY tenant_isolation_policy ON %s USING (tenant_id = shared.current_tenant_id()) WITH CHECK (tenant_id = shared.current_tenant_id())',
|
|
||||||
table_reg
|
|
||||||
);
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
SELECT shared.ensure_tenant_policy('tenant.correspondents');
|
|
||||||
SELECT shared.ensure_tenant_policy('tenant.document_asset_objects');
|
|
||||||
SELECT shared.ensure_tenant_policy('tenant.document_assets');
|
|
||||||
SELECT shared.ensure_tenant_policy('tenant.document_correspondents');
|
|
||||||
SELECT shared.ensure_tenant_policy('tenant.document_tags');
|
|
||||||
SELECT shared.ensure_tenant_policy('tenant.document_versions');
|
|
||||||
SELECT shared.ensure_tenant_policy('tenant.documents');
|
|
||||||
SELECT shared.ensure_tenant_policy('tenant.folders');
|
|
||||||
SELECT shared.ensure_tenant_policy('tenant.tags');
|
|
||||||
SELECT shared.ensure_tenant_policy('tenant.webdav_tokens');
|
|
||||||
|
|
||||||
-- user_memberships has a special read policy to allow tenant discovery during login
|
|
||||||
ALTER TABLE tenant.user_memberships ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE tenant.user_memberships FORCE ROW LEVEL SECURITY;
|
|
||||||
CREATE POLICY tenant_membership_select_policy ON tenant.user_memberships
|
|
||||||
USING (
|
|
||||||
tenant_id = shared.current_tenant_id()
|
|
||||||
OR (
|
|
||||||
shared.current_user_id() IS NOT NULL
|
|
||||||
AND user_id = shared.current_user_id()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
WITH CHECK (tenant_id = shared.current_tenant_id());
|
|
||||||
|
|
||||||
ALTER TABLE tenant.refresh_tokens ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE tenant.refresh_tokens FORCE ROW LEVEL SECURITY;
|
|
||||||
CREATE POLICY tenant_refresh_token_policy ON tenant.refresh_tokens
|
|
||||||
USING (
|
|
||||||
tenant_id = shared.current_tenant_id()
|
|
||||||
OR (
|
|
||||||
shared.current_refresh_token_hash() IS NOT NULL
|
|
||||||
AND token_hash = shared.current_refresh_token_hash()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
WITH CHECK (tenant_id = shared.current_tenant_id());
|
|
||||||
|
|
||||||
DROP FUNCTION shared.ensure_tenant_policy(regclass);
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
DROP POLICY IF EXISTS tenant_webdav_token_policy ON tenant.webdav_tokens;
|
|
||||||
ALTER TABLE tenant.webdav_tokens NO FORCE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE tenant.webdav_tokens DISABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
DROP FUNCTION IF EXISTS shared.current_webdav_token_prefix();
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
CREATE OR REPLACE FUNCTION shared.current_webdav_token_prefix() RETURNS text AS $$
|
|
||||||
SELECT NULLIF(current_setting('papercrate.webdav_token_prefix', true), '')
|
|
||||||
$$ LANGUAGE SQL STABLE;
|
|
||||||
|
|
||||||
ALTER TABLE tenant.webdav_tokens ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE tenant.webdav_tokens FORCE ROW LEVEL SECURITY;
|
|
||||||
CREATE POLICY tenant_webdav_token_policy ON tenant.webdav_tokens
|
|
||||||
USING (
|
|
||||||
tenant_id = shared.current_tenant_id()
|
|
||||||
OR (
|
|
||||||
shared.current_webdav_token_prefix() IS NOT NULL
|
|
||||||
AND token_prefix = shared.current_webdav_token_prefix()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
WITH CHECK (tenant_id = shared.current_tenant_id());
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
DROP POLICY IF EXISTS tenant_api_token_policy ON tenant.api_tokens;
|
|
||||||
DROP FUNCTION IF EXISTS shared.current_api_token_prefix();
|
|
||||||
|
|
||||||
ALTER TABLE tenant.api_tokens DISABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE tenant.api_tokens NO FORCE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
ALTER TABLE tenant.api_tokens
|
|
||||||
DROP COLUMN IF EXISTS capabilities;
|
|
||||||
|
|
||||||
DROP TYPE IF EXISTS shared.api_token_capability;
|
|
||||||
|
|
||||||
ALTER TABLE tenant.api_tokens RENAME TO webdav_tokens;
|
|
||||||
ALTER INDEX tenant.api_tokens_token_prefix_key RENAME TO webdav_tokens_token_prefix_key;
|
|
||||||
ALTER INDEX tenant.api_tokens_user_tenant_idx RENAME TO webdav_tokens_user_tenant_idx;
|
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION shared.current_webdav_token_prefix() RETURNS text AS $$
|
|
||||||
SELECT NULLIF(current_setting('papercrate.webdav_token_prefix', true), '')
|
|
||||||
$$ LANGUAGE SQL STABLE;
|
|
||||||
|
|
||||||
ALTER TABLE tenant.webdav_tokens ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE tenant.webdav_tokens FORCE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
CREATE POLICY tenant_webdav_token_policy ON tenant.webdav_tokens
|
|
||||||
USING (
|
|
||||||
tenant_id = shared.current_tenant_id()
|
|
||||||
OR (
|
|
||||||
shared.current_webdav_token_prefix() IS NOT NULL
|
|
||||||
AND token_prefix = shared.current_webdav_token_prefix()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
WITH CHECK (tenant_id = shared.current_tenant_id());
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
ALTER TABLE tenant.webdav_tokens RENAME TO api_tokens;
|
|
||||||
ALTER INDEX tenant.webdav_tokens_token_prefix_key RENAME TO api_tokens_token_prefix_key;
|
|
||||||
ALTER INDEX tenant.webdav_tokens_user_tenant_idx RENAME TO api_tokens_user_tenant_idx;
|
|
||||||
|
|
||||||
DROP POLICY IF EXISTS tenant_webdav_token_policy ON tenant.api_tokens;
|
|
||||||
DROP FUNCTION IF EXISTS shared.current_webdav_token_prefix();
|
|
||||||
|
|
||||||
CREATE TYPE shared.api_token_capability AS ENUM ('api', 'webdav');
|
|
||||||
|
|
||||||
ALTER TABLE tenant.api_tokens
|
|
||||||
ADD COLUMN capabilities shared.api_token_capability[] NOT NULL DEFAULT ARRAY['webdav']::shared.api_token_capability[];
|
|
||||||
|
|
||||||
ALTER TABLE tenant.api_tokens ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE tenant.api_tokens FORCE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION shared.current_api_token_prefix() RETURNS text AS $$
|
|
||||||
SELECT NULLIF(current_setting('papercrate.api_token_prefix', true), '')
|
|
||||||
$$ LANGUAGE SQL STABLE;
|
|
||||||
|
|
||||||
CREATE POLICY tenant_api_token_policy ON tenant.api_tokens
|
|
||||||
USING (
|
|
||||||
tenant_id = shared.current_tenant_id()
|
|
||||||
OR (
|
|
||||||
shared.current_api_token_prefix() IS NOT NULL
|
|
||||||
AND token_prefix = shared.current_api_token_prefix()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
WITH CHECK (tenant_id = shared.current_tenant_id());
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
CREATE OR REPLACE FUNCTION shared.current_refresh_token_hash() RETURNS text AS $$
|
|
||||||
SELECT NULLIF(current_setting('papercrate.refresh_token_hash', true), '')
|
|
||||||
$$ LANGUAGE SQL STABLE;
|
|
||||||
|
|
||||||
ALTER POLICY tenant_user_session_policy ON tenant.user_sessions
|
|
||||||
USING (
|
|
||||||
tenant_id = shared.current_tenant_id()
|
|
||||||
OR (
|
|
||||||
shared.current_refresh_token_hash() IS NOT NULL
|
|
||||||
AND token_hash = shared.current_refresh_token_hash()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
ALTER POLICY tenant_user_session_policy ON tenant.user_sessions
|
|
||||||
WITH CHECK (tenant_id = shared.current_tenant_id());
|
|
||||||
|
|
||||||
ALTER POLICY tenant_user_session_policy ON tenant.user_sessions
|
|
||||||
RENAME TO tenant_refresh_token_policy;
|
|
||||||
|
|
||||||
ALTER INDEX tenant.idx_user_sessions_user_id RENAME TO idx_refresh_tokens_user_id;
|
|
||||||
ALTER INDEX tenant.idx_user_sessions_token_hash RENAME TO idx_refresh_tokens_token_hash;
|
|
||||||
ALTER INDEX tenant.user_sessions_tenant_id_idx RENAME TO refresh_tokens_tenant_id_idx;
|
|
||||||
|
|
||||||
ALTER TABLE tenant.user_sessions RENAME TO refresh_tokens;
|
|
||||||
|
|
||||||
DROP FUNCTION IF EXISTS shared.current_user_session_hash();
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
ALTER TABLE tenant.refresh_tokens RENAME TO user_sessions;
|
|
||||||
|
|
||||||
ALTER INDEX tenant.idx_refresh_tokens_user_id RENAME TO idx_user_sessions_user_id;
|
|
||||||
ALTER INDEX tenant.idx_refresh_tokens_token_hash RENAME TO idx_user_sessions_token_hash;
|
|
||||||
ALTER INDEX tenant.refresh_tokens_tenant_id_idx RENAME TO user_sessions_tenant_id_idx;
|
|
||||||
|
|
||||||
ALTER POLICY tenant_refresh_token_policy ON tenant.user_sessions
|
|
||||||
RENAME TO tenant_user_session_policy;
|
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION shared.current_user_session_hash() RETURNS text AS $$
|
|
||||||
SELECT NULLIF(current_setting('papercrate.user_session_hash', true), '')
|
|
||||||
$$ LANGUAGE SQL STABLE;
|
|
||||||
|
|
||||||
ALTER POLICY tenant_user_session_policy ON tenant.user_sessions
|
|
||||||
USING (
|
|
||||||
tenant_id = shared.current_tenant_id()
|
|
||||||
OR (
|
|
||||||
shared.current_user_session_hash() IS NOT NULL
|
|
||||||
AND token_hash = shared.current_user_session_hash()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
ALTER POLICY tenant_user_session_policy ON tenant.user_sessions
|
|
||||||
WITH CHECK (tenant_id = shared.current_tenant_id());
|
|
||||||
|
|
||||||
DROP FUNCTION IF EXISTS shared.current_refresh_token_hash();
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
ALTER TABLE tenant.document_tags
|
|
||||||
DROP CONSTRAINT IF EXISTS document_tags_assigned_by_fkey,
|
|
||||||
ADD CONSTRAINT document_tags_assigned_by_fkey
|
|
||||||
FOREIGN KEY (assigned_by)
|
|
||||||
REFERENCES shared.users (id)
|
|
||||||
ON DELETE NO ACTION;
|
|
||||||
|
|
||||||
ALTER TABLE tenant.document_correspondents
|
|
||||||
DROP CONSTRAINT IF EXISTS document_correspondents_assigned_by_fkey,
|
|
||||||
ADD CONSTRAINT document_correspondents_assigned_by_fkey
|
|
||||||
FOREIGN KEY (assigned_by)
|
|
||||||
REFERENCES shared.users (id)
|
|
||||||
ON DELETE NO ACTION;
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
ALTER TABLE tenant.document_tags
|
|
||||||
DROP CONSTRAINT IF EXISTS document_tags_assigned_by_fkey,
|
|
||||||
ADD CONSTRAINT document_tags_assigned_by_fkey
|
|
||||||
FOREIGN KEY (assigned_by)
|
|
||||||
REFERENCES shared.users (id)
|
|
||||||
ON DELETE SET NULL;
|
|
||||||
|
|
||||||
ALTER TABLE tenant.document_correspondents
|
|
||||||
DROP CONSTRAINT IF EXISTS document_correspondents_assigned_by_fkey,
|
|
||||||
ADD CONSTRAINT document_correspondents_assigned_by_fkey
|
|
||||||
FOREIGN KEY (assigned_by)
|
|
||||||
REFERENCES shared.users (id)
|
|
||||||
ON DELETE SET NULL;
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
CREATE TYPE api_token_capability AS ENUM ('api', 'webdav');
|
|
||||||
|
|
||||||
ALTER TABLE tenant.api_tokens
|
|
||||||
ADD COLUMN capabilities api_token_capability[] NOT NULL DEFAULT ARRAY[]::api_token_capability[];
|
|
||||||
|
|
||||||
UPDATE tenant.api_tokens t
|
|
||||||
SET capabilities = ARRAY['api']::api_token_capability[]
|
|
||||||
FROM tenant.capability_sets cs
|
|
||||||
WHERE t.capability_set_id = cs.id
|
|
||||||
AND cs.slug = 'owner';
|
|
||||||
|
|
||||||
UPDATE tenant.api_tokens t
|
|
||||||
SET capabilities = ARRAY['webdav']::api_token_capability[]
|
|
||||||
FROM tenant.capability_sets cs
|
|
||||||
WHERE t.capability_set_id = cs.id
|
|
||||||
AND cs.slug = 'webdav'
|
|
||||||
AND (t.capabilities IS NULL OR array_length(t.capabilities, 1) = 0);
|
|
||||||
|
|
||||||
ALTER TABLE tenant.api_tokens
|
|
||||||
DROP COLUMN capability_set_id;
|
|
||||||
|
|
||||||
ALTER TABLE tenant.user_memberships
|
|
||||||
DROP COLUMN capability_set_id;
|
|
||||||
|
|
||||||
DROP TABLE IF EXISTS tenant.capability_set_capabilities;
|
|
||||||
DROP TABLE IF EXISTS tenant.capability_sets;
|
|
||||||
|
|
||||||
DROP TYPE IF EXISTS api_capability;
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
CREATE TYPE api_capability AS ENUM (
|
|
||||||
'documents:read',
|
|
||||||
'documents:edit',
|
|
||||||
'documents:write',
|
|
||||||
'documents:upload',
|
|
||||||
'folders:read',
|
|
||||||
'folders:edit',
|
|
||||||
'folders:write',
|
|
||||||
'tags:read',
|
|
||||||
'tags:edit',
|
|
||||||
'tags:write',
|
|
||||||
'correspondents:read',
|
|
||||||
'correspondents:edit',
|
|
||||||
'correspondents:write',
|
|
||||||
'profile:read',
|
|
||||||
'profile:write',
|
|
||||||
'webdav:read',
|
|
||||||
'webdav:write',
|
|
||||||
'capability_sets:read',
|
|
||||||
'capability_sets:write'
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE tenant.capability_sets (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES shared.tenants(id) ON DELETE CASCADE,
|
|
||||||
slug TEXT NOT NULL,
|
|
||||||
cap_version INT NOT NULL DEFAULT 1,
|
|
||||||
is_system BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
UNIQUE (tenant_id, slug)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE tenant.capability_set_capabilities (
|
|
||||||
capability_set_id UUID NOT NULL REFERENCES tenant.capability_sets(id) ON DELETE CASCADE,
|
|
||||||
capability api_capability NOT NULL,
|
|
||||||
PRIMARY KEY (capability_set_id, capability)
|
|
||||||
);
|
|
||||||
|
|
||||||
ALTER TABLE tenant.api_tokens
|
|
||||||
ADD COLUMN capability_set_id UUID REFERENCES tenant.capability_sets(id);
|
|
||||||
|
|
||||||
ALTER TABLE tenant.user_memberships
|
|
||||||
ADD COLUMN capability_set_id UUID REFERENCES tenant.capability_sets(id);
|
|
||||||
|
|
||||||
WITH owner_sets AS (
|
|
||||||
INSERT INTO tenant.capability_sets (tenant_id, slug, is_system)
|
|
||||||
SELECT id, 'owner', TRUE
|
|
||||||
FROM shared.tenants
|
|
||||||
RETURNING id, tenant_id
|
|
||||||
),
|
|
||||||
user_sets AS (
|
|
||||||
INSERT INTO tenant.capability_sets (tenant_id, slug, is_system)
|
|
||||||
SELECT id, 'user', TRUE
|
|
||||||
FROM shared.tenants
|
|
||||||
RETURNING id, tenant_id
|
|
||||||
),
|
|
||||||
readonly_sets AS (
|
|
||||||
INSERT INTO tenant.capability_sets (tenant_id, slug, is_system)
|
|
||||||
SELECT id, 'readonly', TRUE
|
|
||||||
FROM shared.tenants
|
|
||||||
RETURNING id, tenant_id
|
|
||||||
),
|
|
||||||
webdav_sets AS (
|
|
||||||
INSERT INTO tenant.capability_sets (tenant_id, slug, is_system)
|
|
||||||
SELECT id, 'webdav', TRUE
|
|
||||||
FROM shared.tenants
|
|
||||||
RETURNING id, tenant_id
|
|
||||||
)
|
|
||||||
INSERT INTO tenant.capability_set_capabilities (capability_set_id, capability)
|
|
||||||
SELECT set_id,
|
|
||||||
capability
|
|
||||||
FROM (
|
|
||||||
SELECT os.id AS set_id,
|
|
||||||
UNNEST(ARRAY[
|
|
||||||
'documents:read'::api_capability,
|
|
||||||
'documents:edit'::api_capability,
|
|
||||||
'documents:write'::api_capability,
|
|
||||||
'documents:upload'::api_capability,
|
|
||||||
'folders:read'::api_capability,
|
|
||||||
'folders:edit'::api_capability,
|
|
||||||
'folders:write'::api_capability,
|
|
||||||
'tags:read'::api_capability,
|
|
||||||
'tags:edit'::api_capability,
|
|
||||||
'tags:write'::api_capability,
|
|
||||||
'correspondents:read'::api_capability,
|
|
||||||
'correspondents:edit'::api_capability,
|
|
||||||
'correspondents:write'::api_capability,
|
|
||||||
'profile:read'::api_capability,
|
|
||||||
'profile:write'::api_capability,
|
|
||||||
'webdav:read'::api_capability,
|
|
||||||
'webdav:write'::api_capability,
|
|
||||||
'capability_sets:read'::api_capability,
|
|
||||||
'capability_sets:write'::api_capability
|
|
||||||
]) AS capability
|
|
||||||
FROM owner_sets os
|
|
||||||
UNION ALL
|
|
||||||
SELECT us.id,
|
|
||||||
UNNEST(ARRAY[
|
|
||||||
'documents:read'::api_capability,
|
|
||||||
'documents:edit'::api_capability,
|
|
||||||
'documents:write'::api_capability,
|
|
||||||
'documents:upload'::api_capability,
|
|
||||||
'folders:read'::api_capability,
|
|
||||||
'folders:edit'::api_capability,
|
|
||||||
'folders:write'::api_capability,
|
|
||||||
'tags:read'::api_capability,
|
|
||||||
'tags:edit'::api_capability,
|
|
||||||
'tags:write'::api_capability,
|
|
||||||
'correspondents:read'::api_capability,
|
|
||||||
'correspondents:edit'::api_capability,
|
|
||||||
'correspondents:write'::api_capability,
|
|
||||||
'profile:read'::api_capability,
|
|
||||||
'profile:write'::api_capability
|
|
||||||
]) AS capability
|
|
||||||
FROM user_sets us
|
|
||||||
UNION ALL
|
|
||||||
SELECT rs.id,
|
|
||||||
UNNEST(ARRAY[
|
|
||||||
'documents:read'::api_capability,
|
|
||||||
'folders:read'::api_capability,
|
|
||||||
'tags:read'::api_capability,
|
|
||||||
'correspondents:read'::api_capability,
|
|
||||||
'webdav:read'::api_capability
|
|
||||||
]) AS capability
|
|
||||||
FROM readonly_sets rs
|
|
||||||
UNION ALL
|
|
||||||
SELECT ws.id,
|
|
||||||
UNNEST(ARRAY['webdav:read'::api_capability]) AS capability
|
|
||||||
FROM webdav_sets ws
|
|
||||||
) seeded;
|
|
||||||
|
|
||||||
UPDATE tenant.user_memberships um
|
|
||||||
SET capability_set_id = cs.id
|
|
||||||
FROM tenant.capability_sets cs
|
|
||||||
WHERE cs.tenant_id = um.tenant_id
|
|
||||||
AND cs.slug = 'owner';
|
|
||||||
|
|
||||||
UPDATE tenant.api_tokens t
|
|
||||||
SET capability_set_id = cs.id
|
|
||||||
FROM tenant.capability_sets cs
|
|
||||||
WHERE cs.tenant_id = t.tenant_id
|
|
||||||
AND cs.slug = 'owner';
|
|
||||||
|
|
||||||
UPDATE tenant.api_tokens t
|
|
||||||
SET capability_set_id = cs.id
|
|
||||||
FROM tenant.capability_sets cs
|
|
||||||
WHERE cs.tenant_id = t.tenant_id
|
|
||||||
AND cs.slug = 'webdav'
|
|
||||||
AND t.capability_set_id IS NULL;
|
|
||||||
|
|
||||||
ALTER TABLE tenant.api_tokens
|
|
||||||
ALTER COLUMN capability_set_id SET NOT NULL;
|
|
||||||
|
|
||||||
ALTER TABLE tenant.api_tokens
|
|
||||||
DROP COLUMN capabilities;
|
|
||||||
|
|
||||||
DROP TYPE IF EXISTS api_token_capability;
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
DROP INDEX IF EXISTS shared.jobs_purge_document_pending_unique;
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
CREATE UNIQUE INDEX jobs_purge_document_pending_unique
|
|
||||||
ON shared.jobs (
|
|
||||||
tenant_id,
|
|
||||||
((payload ->> 'document_id')::uuid)
|
|
||||||
)
|
|
||||||
WHERE job_type = 'purge-document'
|
|
||||||
AND payload ? 'document_id'
|
|
||||||
AND status IN ('queued', 'processing');
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
DROP INDEX IF EXISTS tenant.idx_documents_tenant_folder_updated_at;
|
|
||||||
DROP INDEX IF EXISTS tenant.idx_documents_tenant_folder_created_at;
|
|
||||||
DROP INDEX IF EXISTS tenant.idx_documents_tenant_folder_issued_at;
|
|
||||||
DROP INDEX IF EXISTS tenant.idx_documents_tenant_folder_title_order;
|
|
||||||
DROP COLLATION IF EXISTS unicode_ci;
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
CREATE COLLATION IF NOT EXISTS unicode_ci
|
|
||||||
(provider = icu, locale = 'und-u-ks-level2');
|
|
||||||
|
|
||||||
CREATE INDEX idx_documents_tenant_folder_title_order
|
|
||||||
ON tenant.documents (
|
|
||||||
tenant_id,
|
|
||||||
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
|
||||||
title COLLATE "unicode_ci"
|
|
||||||
)
|
|
||||||
WHERE deleted_at IS NULL;
|
|
||||||
|
|
||||||
CREATE INDEX idx_documents_tenant_folder_issued_at
|
|
||||||
ON tenant.documents (
|
|
||||||
tenant_id,
|
|
||||||
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
|
||||||
issued_at,
|
|
||||||
title COLLATE "unicode_ci"
|
|
||||||
)
|
|
||||||
WHERE deleted_at IS NULL;
|
|
||||||
|
|
||||||
CREATE INDEX idx_documents_tenant_folder_created_at
|
|
||||||
ON tenant.documents (
|
|
||||||
tenant_id,
|
|
||||||
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
|
||||||
created_at,
|
|
||||||
title COLLATE "unicode_ci"
|
|
||||||
)
|
|
||||||
WHERE deleted_at IS NULL;
|
|
||||||
|
|
||||||
CREATE INDEX idx_documents_tenant_folder_updated_at
|
|
||||||
ON tenant.documents (
|
|
||||||
tenant_id,
|
|
||||||
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
|
||||||
updated_at,
|
|
||||||
title COLLATE "unicode_ci"
|
|
||||||
)
|
|
||||||
WHERE deleted_at IS NULL;
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
ALTER TABLE shared.jobs
|
|
||||||
DROP CONSTRAINT jobs_tenant_id_fkey;
|
|
||||||
|
|
||||||
ALTER TABLE shared.jobs
|
|
||||||
ALTER COLUMN tenant_id SET NOT NULL;
|
|
||||||
|
|
||||||
ALTER TABLE shared.jobs
|
|
||||||
ADD CONSTRAINT jobs_tenant_id_fkey
|
|
||||||
FOREIGN KEY (tenant_id)
|
|
||||||
REFERENCES shared.tenants(id);
|
|
||||||
|
|
||||||
ALTER TABLE shared.jobs
|
|
||||||
DROP COLUMN result;
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
ALTER TABLE shared.jobs
|
|
||||||
ALTER COLUMN tenant_id DROP NOT NULL;
|
|
||||||
|
|
||||||
ALTER TABLE shared.jobs
|
|
||||||
DROP CONSTRAINT jobs_tenant_id_fkey;
|
|
||||||
|
|
||||||
ALTER TABLE shared.jobs
|
|
||||||
ADD CONSTRAINT jobs_tenant_id_fkey
|
|
||||||
FOREIGN KEY (tenant_id)
|
|
||||||
REFERENCES shared.tenants(id)
|
|
||||||
ON DELETE SET NULL;
|
|
||||||
|
|
||||||
ALTER TABLE shared.jobs
|
|
||||||
ADD COLUMN result JSONB;
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
-- diesel:run_in_transaction = false
|
|
||||||
|
|
||||||
-- Enum values cannot be removed safely; this down migration intentionally left empty.
|
|
||||||
SELECT 1;
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
-- diesel:run_in_transaction = false
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
BEGIN
|
|
||||||
EXECUTE 'ALTER TYPE tenant.api_capability ADD VALUE ''tenants:write''';
|
|
||||||
EXCEPTION
|
|
||||||
WHEN undefined_object THEN
|
|
||||||
BEGIN
|
|
||||||
EXECUTE 'ALTER TYPE api_capability ADD VALUE ''tenants:write''';
|
|
||||||
EXCEPTION
|
|
||||||
WHEN duplicate_object THEN NULL;
|
|
||||||
END;
|
|
||||||
WHEN duplicate_object THEN NULL;
|
|
||||||
END;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
BEGIN
|
|
||||||
EXECUTE 'ALTER TYPE tenant.api_capability ADD VALUE ''tenants:reset''';
|
|
||||||
EXCEPTION
|
|
||||||
WHEN undefined_object THEN
|
|
||||||
BEGIN
|
|
||||||
EXECUTE 'ALTER TYPE api_capability ADD VALUE ''tenants:reset''';
|
|
||||||
EXCEPTION
|
|
||||||
WHEN duplicate_object THEN NULL;
|
|
||||||
END;
|
|
||||||
WHEN duplicate_object THEN NULL;
|
|
||||||
END;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
BEGIN
|
|
||||||
EXECUTE 'ALTER TYPE tenant.api_capability ADD VALUE ''tenants:delete''';
|
|
||||||
EXCEPTION
|
|
||||||
WHEN undefined_object THEN
|
|
||||||
BEGIN
|
|
||||||
EXECUTE 'ALTER TYPE api_capability ADD VALUE ''tenants:delete''';
|
|
||||||
EXCEPTION
|
|
||||||
WHEN duplicate_object THEN NULL;
|
|
||||||
END;
|
|
||||||
WHEN duplicate_object THEN NULL;
|
|
||||||
END;
|
|
||||||
END $$;
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
-- diesel:run_in_transaction = false
|
|
||||||
|
|
||||||
DELETE FROM tenant.capability_set_capabilities
|
|
||||||
WHERE capability IN (
|
|
||||||
'tenants:write'::api_capability,
|
|
||||||
'tenants:reset'::api_capability,
|
|
||||||
'tenants:delete'::api_capability
|
|
||||||
)
|
|
||||||
AND capability_set_id IN (SELECT id FROM tenant.capability_sets WHERE slug = 'owner');
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
-- diesel:run_in_transaction = false
|
|
||||||
|
|
||||||
WITH owner_sets AS (
|
|
||||||
SELECT id FROM tenant.capability_sets WHERE slug = 'owner'
|
|
||||||
)
|
|
||||||
INSERT INTO tenant.capability_set_capabilities (capability_set_id, capability)
|
|
||||||
SELECT id, 'tenants:write'::api_capability FROM owner_sets
|
|
||||||
ON CONFLICT DO NOTHING;
|
|
||||||
|
|
||||||
WITH owner_sets AS (
|
|
||||||
SELECT id FROM tenant.capability_sets WHERE slug = 'owner'
|
|
||||||
)
|
|
||||||
INSERT INTO tenant.capability_set_capabilities (capability_set_id, capability)
|
|
||||||
SELECT id, 'tenants:reset'::api_capability FROM owner_sets
|
|
||||||
ON CONFLICT DO NOTHING;
|
|
||||||
|
|
||||||
WITH owner_sets AS (
|
|
||||||
SELECT id FROM tenant.capability_sets WHERE slug = 'owner'
|
|
||||||
)
|
|
||||||
INSERT INTO tenant.capability_set_capabilities (capability_set_id, capability)
|
|
||||||
SELECT id, 'tenants:delete'::api_capability FROM owner_sets
|
|
||||||
ON CONFLICT DO NOTHING;
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
CREATE TABLE tenant.document_asset_objects (
|
|
||||||
id UUID PRIMARY KEY,
|
|
||||||
asset_id UUID NOT NULL REFERENCES tenant.document_assets(id) ON DELETE CASCADE,
|
|
||||||
ordinal INT NOT NULL,
|
|
||||||
s3_key TEXT NOT NULL,
|
|
||||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
||||||
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
|
|
||||||
CONSTRAINT document_asset_objects_ordinal_positive CHECK (ordinal >= 1),
|
|
||||||
CONSTRAINT document_asset_objects_asset_ordinal_unique UNIQUE (asset_id, ordinal)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_document_asset_objects_asset_ordinal
|
|
||||||
ON tenant.document_asset_objects(asset_id, ordinal);
|
|
||||||
|
|
||||||
CREATE INDEX document_asset_objects_tenant_id_idx ON tenant.document_asset_objects(tenant_id);
|
|
||||||
|
|
||||||
ALTER TABLE tenant.document_assets ADD COLUMN cardinality INT;
|
|
||||||
UPDATE tenant.document_assets SET cardinality = 1;
|
|
||||||
|
|
||||||
INSERT INTO tenant.document_asset_objects (id, asset_id, ordinal, s3_key, metadata, tenant_id)
|
|
||||||
SELECT gen_random_uuid(), id, 1, s3_key, metadata, tenant_id
|
|
||||||
FROM tenant.document_assets;
|
|
||||||
|
|
||||||
ALTER TABLE tenant.document_assets DROP COLUMN s3_key;
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
-- Prevent concurrent inserts/updates during backfill.
|
|
||||||
LOCK TABLE tenant.document_asset_objects IN ACCESS EXCLUSIVE MODE;
|
|
||||||
LOCK TABLE tenant.document_assets IN ACCESS EXCLUSIVE MODE;
|
|
||||||
|
|
||||||
ALTER TABLE tenant.document_assets ADD COLUMN s3_key TEXT;
|
|
||||||
|
|
||||||
UPDATE tenant.document_assets AS da
|
|
||||||
SET s3_key = o.s3_key,
|
|
||||||
metadata = COALESCE(da.metadata, '{}'::jsonb) || COALESCE(o.metadata, '{}'::jsonb)
|
|
||||||
FROM tenant.document_asset_objects AS o
|
|
||||||
WHERE o.asset_id = da.id
|
|
||||||
AND o.ordinal = 1;
|
|
||||||
|
|
||||||
DELETE FROM tenant.document_asset_objects WHERE ordinal <> 1;
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF EXISTS (SELECT 1 FROM tenant.document_assets WHERE s3_key IS NULL) THEN
|
|
||||||
RAISE EXCEPTION 'cannot drop document_asset_objects: some assets are missing a populated ordinal 1 object (s3_key null)';
|
|
||||||
END IF;
|
|
||||||
END
|
|
||||||
$$;
|
|
||||||
|
|
||||||
ALTER TABLE tenant.document_assets ALTER COLUMN s3_key SET NOT NULL;
|
|
||||||
ALTER TABLE tenant.document_assets DROP COLUMN cardinality;
|
|
||||||
|
|
||||||
DROP TABLE tenant.document_asset_objects;
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
-- Revert column rename.
|
|
||||||
ALTER TABLE tenant.documents
|
|
||||||
RENAME COLUMN mime_type TO content_type;
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
-- Rename document content_type column to mime_type for consistency with API.
|
|
||||||
ALTER TABLE tenant.documents
|
|
||||||
RENAME COLUMN content_type TO mime_type;
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
UPDATE tenant.document_assets
|
|
||||||
SET asset_type = 'ocr-text'
|
|
||||||
WHERE asset_type = 'text-content';
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
UPDATE tenant.document_assets
|
|
||||||
SET asset_type = 'text-content'
|
|
||||||
WHERE asset_type = 'ocr-text';
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
DROP INDEX IF EXISTS tenant.idx_documents_title_trgm;
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
|
||||||
|
|
||||||
CREATE INDEX idx_documents_title_trgm
|
|
||||||
ON tenant.documents
|
|
||||||
USING gin (title gin_trgm_ops)
|
|
||||||
WHERE deleted_at IS NULL;
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'papercrate_app') THEN
|
|
||||||
CREATE ROLE papercrate_app NOLOGIN;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'papercrate_app_login') THEN
|
|
||||||
CREATE ROLE papercrate_app_login LOGIN PASSWORD 'papercrate_app';
|
|
||||||
GRANT papercrate_app TO papercrate_app_login;
|
|
||||||
END IF;
|
|
||||||
END
|
|
||||||
$$;
|
|
||||||
|
|
||||||
-- Ensure the login role inherits and uses a sensible search path by default
|
|
||||||
ALTER ROLE papercrate_app_login INHERIT;
|
|
||||||
ALTER ROLE papercrate_app_login SET search_path = 'tenant, shared, public';
|
|
||||||
|
|
||||||
GRANT CONNECT ON DATABASE papercrate TO papercrate_app;
|
|
||||||
GRANT CONNECT ON DATABASE papercrate TO papercrate_app_login;
|
|
||||||
GRANT USAGE ON SCHEMA public TO papercrate_app;
|
|
||||||
GRANT USAGE ON SCHEMA public TO papercrate_app_login;
|
|
||||||
@@ -1,648 +0,0 @@
|
|||||||
months:
|
|
||||||
- name: "january"
|
|
||||||
month: 1
|
|
||||||
locales:
|
|
||||||
- "en"
|
|
||||||
- name: "jan"
|
|
||||||
month: 1
|
|
||||||
locales:
|
|
||||||
- "en"
|
|
||||||
- name: "janvier"
|
|
||||||
month: 1
|
|
||||||
locales:
|
|
||||||
- "fr"
|
|
||||||
- name: "janv"
|
|
||||||
month: 1
|
|
||||||
locales:
|
|
||||||
- "fr"
|
|
||||||
- name: "januar"
|
|
||||||
month: 1
|
|
||||||
locales:
|
|
||||||
- "de"
|
|
||||||
- name: "janu\u00e1r"
|
|
||||||
month: 1
|
|
||||||
locales:
|
|
||||||
- "hu"
|
|
||||||
- name: "leden"
|
|
||||||
month: 1
|
|
||||||
locales:
|
|
||||||
- "cs"
|
|
||||||
- name: "sije\u010danj"
|
|
||||||
month: 1
|
|
||||||
locales:
|
|
||||||
- "hr"
|
|
||||||
- name: "sijecanj"
|
|
||||||
month: 1
|
|
||||||
locales:
|
|
||||||
- "hr"
|
|
||||||
- name: "stycze\u0144"
|
|
||||||
month: 1
|
|
||||||
locales:
|
|
||||||
- "pl"
|
|
||||||
- name: "styczen"
|
|
||||||
month: 1
|
|
||||||
locales:
|
|
||||||
- "pl"
|
|
||||||
- name: "ocak"
|
|
||||||
month: 1
|
|
||||||
locales:
|
|
||||||
- "tr"
|
|
||||||
- name: "february"
|
|
||||||
month: 2
|
|
||||||
locales:
|
|
||||||
- "en"
|
|
||||||
- name: "feb"
|
|
||||||
month: 2
|
|
||||||
locales:
|
|
||||||
- "en"
|
|
||||||
- name: "f\u00e9vrier"
|
|
||||||
month: 2
|
|
||||||
locales:
|
|
||||||
- "fr"
|
|
||||||
- name: "fevrier"
|
|
||||||
month: 2
|
|
||||||
locales:
|
|
||||||
- "fr"
|
|
||||||
- name: "f\u00e9vr"
|
|
||||||
month: 2
|
|
||||||
locales:
|
|
||||||
- "fr"
|
|
||||||
- name: "fevr"
|
|
||||||
month: 2
|
|
||||||
locales:
|
|
||||||
- "fr"
|
|
||||||
- name: "februar"
|
|
||||||
month: 2
|
|
||||||
locales:
|
|
||||||
- "de"
|
|
||||||
- name: "\u00fanor"
|
|
||||||
month: 2
|
|
||||||
locales:
|
|
||||||
- "cs"
|
|
||||||
- name: "unor"
|
|
||||||
month: 2
|
|
||||||
locales:
|
|
||||||
- "cs"
|
|
||||||
- name: "\u00fanora"
|
|
||||||
month: 2
|
|
||||||
locales:
|
|
||||||
- "cs"
|
|
||||||
- name: "unora"
|
|
||||||
month: 2
|
|
||||||
locales:
|
|
||||||
- "cs"
|
|
||||||
- name: "velja\u010da"
|
|
||||||
month: 2
|
|
||||||
locales:
|
|
||||||
- "hr"
|
|
||||||
- name: "veljaca"
|
|
||||||
month: 2
|
|
||||||
locales:
|
|
||||||
- "hr"
|
|
||||||
- name: "velja\u010de"
|
|
||||||
month: 2
|
|
||||||
locales:
|
|
||||||
- "hr"
|
|
||||||
- name: "veljace"
|
|
||||||
month: 2
|
|
||||||
locales:
|
|
||||||
- "hr"
|
|
||||||
- name: "luty"
|
|
||||||
month: 2
|
|
||||||
locales:
|
|
||||||
- "pl"
|
|
||||||
- name: "\u015fubat"
|
|
||||||
month: 2
|
|
||||||
locales:
|
|
||||||
- "tr"
|
|
||||||
- name: "subat"
|
|
||||||
month: 2
|
|
||||||
locales:
|
|
||||||
- "tr"
|
|
||||||
- name: "march"
|
|
||||||
month: 3
|
|
||||||
locales:
|
|
||||||
- "en"
|
|
||||||
- name: "mar"
|
|
||||||
month: 3
|
|
||||||
locales:
|
|
||||||
- "en"
|
|
||||||
- name: "m\u00e4rz"
|
|
||||||
month: 3
|
|
||||||
locales:
|
|
||||||
- "de"
|
|
||||||
- name: "maerz"
|
|
||||||
month: 3
|
|
||||||
locales:
|
|
||||||
- "de"
|
|
||||||
- name: "mars"
|
|
||||||
month: 3
|
|
||||||
locales:
|
|
||||||
- "fr"
|
|
||||||
- name: "m\u00e4r"
|
|
||||||
month: 3
|
|
||||||
locales:
|
|
||||||
- "de"
|
|
||||||
- name: "marz"
|
|
||||||
month: 3
|
|
||||||
locales:
|
|
||||||
- "de"
|
|
||||||
- name: "b\u0159ezen"
|
|
||||||
month: 3
|
|
||||||
locales:
|
|
||||||
- "cs"
|
|
||||||
- name: "brezen"
|
|
||||||
month: 3
|
|
||||||
locales:
|
|
||||||
- "cs"
|
|
||||||
- name: "b\u0159ezna"
|
|
||||||
month: 3
|
|
||||||
locales:
|
|
||||||
- "cs"
|
|
||||||
- name: "brezna"
|
|
||||||
month: 3
|
|
||||||
locales:
|
|
||||||
- "cs"
|
|
||||||
- name: "o\u017eujak"
|
|
||||||
month: 3
|
|
||||||
locales:
|
|
||||||
- "hr"
|
|
||||||
- name: "ozujak"
|
|
||||||
month: 3
|
|
||||||
locales:
|
|
||||||
- "hr"
|
|
||||||
- name: "o\u017eujka"
|
|
||||||
month: 3
|
|
||||||
locales:
|
|
||||||
- "hr"
|
|
||||||
- name: "ozujka"
|
|
||||||
month: 3
|
|
||||||
locales:
|
|
||||||
- "hr"
|
|
||||||
- name: "marzec"
|
|
||||||
month: 3
|
|
||||||
locales:
|
|
||||||
- "pl"
|
|
||||||
- name: "mart"
|
|
||||||
month: 3
|
|
||||||
locales:
|
|
||||||
- "sr"
|
|
||||||
- "bs"
|
|
||||||
- name: "m\u00e1rcius"
|
|
||||||
month: 3
|
|
||||||
locales:
|
|
||||||
- "hu"
|
|
||||||
- name: "martie"
|
|
||||||
month: 3
|
|
||||||
locales:
|
|
||||||
- "ro"
|
|
||||||
- name: "april"
|
|
||||||
month: 4
|
|
||||||
locales:
|
|
||||||
- "en"
|
|
||||||
- name: "apr"
|
|
||||||
month: 4
|
|
||||||
locales:
|
|
||||||
- "en"
|
|
||||||
- name: "avril"
|
|
||||||
month: 4
|
|
||||||
locales:
|
|
||||||
- "fr"
|
|
||||||
- name: "abril"
|
|
||||||
month: 4
|
|
||||||
locales:
|
|
||||||
- "es"
|
|
||||||
- "pt"
|
|
||||||
- name: "duben"
|
|
||||||
month: 4
|
|
||||||
locales:
|
|
||||||
- "cs"
|
|
||||||
- name: "travanj"
|
|
||||||
month: 4
|
|
||||||
locales:
|
|
||||||
- "hr"
|
|
||||||
- name: "nisan"
|
|
||||||
month: 4
|
|
||||||
locales:
|
|
||||||
- "tr"
|
|
||||||
- name: "kwiecie\u0144"
|
|
||||||
month: 4
|
|
||||||
locales:
|
|
||||||
- "pl"
|
|
||||||
- name: "kwiecien"
|
|
||||||
month: 4
|
|
||||||
locales:
|
|
||||||
- "pl"
|
|
||||||
- name: "aprile"
|
|
||||||
month: 4
|
|
||||||
locales:
|
|
||||||
- "it"
|
|
||||||
- name: "may"
|
|
||||||
month: 5
|
|
||||||
locales:
|
|
||||||
- "en"
|
|
||||||
- name: "mai"
|
|
||||||
month: 5
|
|
||||||
locales:
|
|
||||||
- "fr"
|
|
||||||
- "de"
|
|
||||||
- name: "mayo"
|
|
||||||
month: 5
|
|
||||||
locales:
|
|
||||||
- "es"
|
|
||||||
- name: "kv\u011bten"
|
|
||||||
month: 5
|
|
||||||
locales:
|
|
||||||
- "cs"
|
|
||||||
- name: "kveten"
|
|
||||||
month: 5
|
|
||||||
locales:
|
|
||||||
- "cs"
|
|
||||||
- name: "kv\u011btna"
|
|
||||||
month: 5
|
|
||||||
locales:
|
|
||||||
- "cs"
|
|
||||||
- name: "kvetna"
|
|
||||||
month: 5
|
|
||||||
locales:
|
|
||||||
- "cs"
|
|
||||||
- name: "svibanj"
|
|
||||||
month: 5
|
|
||||||
locales:
|
|
||||||
- "hr"
|
|
||||||
- name: "maj"
|
|
||||||
month: 5
|
|
||||||
locales:
|
|
||||||
- "pl"
|
|
||||||
- "bs"
|
|
||||||
- "sr"
|
|
||||||
- "hr"
|
|
||||||
- name: "may\u0131s"
|
|
||||||
month: 5
|
|
||||||
locales:
|
|
||||||
- "tr"
|
|
||||||
- name: "mayis"
|
|
||||||
month: 5
|
|
||||||
locales:
|
|
||||||
- "tr"
|
|
||||||
- name: "maggio"
|
|
||||||
month: 5
|
|
||||||
locales:
|
|
||||||
- "it"
|
|
||||||
- name: "m\u00e1j"
|
|
||||||
month: 5
|
|
||||||
locales:
|
|
||||||
- "hu"
|
|
||||||
- "sk"
|
|
||||||
- name: "june"
|
|
||||||
month: 6
|
|
||||||
locales:
|
|
||||||
- "en"
|
|
||||||
- name: "jun"
|
|
||||||
month: 6
|
|
||||||
locales:
|
|
||||||
- "en"
|
|
||||||
- "de"
|
|
||||||
- name: "juin"
|
|
||||||
month: 6
|
|
||||||
locales:
|
|
||||||
- "fr"
|
|
||||||
- name: "junio"
|
|
||||||
month: 6
|
|
||||||
locales:
|
|
||||||
- "es"
|
|
||||||
- name: "juni"
|
|
||||||
month: 6
|
|
||||||
locales:
|
|
||||||
- "de"
|
|
||||||
- name: "\u010derven"
|
|
||||||
month: 6
|
|
||||||
locales:
|
|
||||||
- "cs"
|
|
||||||
- name: "cerven"
|
|
||||||
month: 6
|
|
||||||
locales:
|
|
||||||
- "cs"
|
|
||||||
- name: "lipanj"
|
|
||||||
month: 6
|
|
||||||
locales:
|
|
||||||
- "hr"
|
|
||||||
- name: "haziran"
|
|
||||||
month: 6
|
|
||||||
locales:
|
|
||||||
- "tr"
|
|
||||||
- name: "czerwiec"
|
|
||||||
month: 6
|
|
||||||
locales:
|
|
||||||
- "pl"
|
|
||||||
- name: "j\u00fanius"
|
|
||||||
month: 6
|
|
||||||
locales:
|
|
||||||
- "hu"
|
|
||||||
- name: "giugno"
|
|
||||||
month: 6
|
|
||||||
locales:
|
|
||||||
- "it"
|
|
||||||
- name: "july"
|
|
||||||
month: 7
|
|
||||||
locales:
|
|
||||||
- "en"
|
|
||||||
- name: "jul"
|
|
||||||
month: 7
|
|
||||||
locales:
|
|
||||||
- "en"
|
|
||||||
- "de"
|
|
||||||
- name: "juillet"
|
|
||||||
month: 7
|
|
||||||
locales:
|
|
||||||
- "fr"
|
|
||||||
- name: "julio"
|
|
||||||
month: 7
|
|
||||||
locales:
|
|
||||||
- "es"
|
|
||||||
- name: "temmuz"
|
|
||||||
month: 7
|
|
||||||
locales:
|
|
||||||
- "tr"
|
|
||||||
- name: "\u010dervenec"
|
|
||||||
month: 7
|
|
||||||
locales:
|
|
||||||
- "cs"
|
|
||||||
- name: "cervenec"
|
|
||||||
month: 7
|
|
||||||
locales:
|
|
||||||
- "cs"
|
|
||||||
- name: "srpanj"
|
|
||||||
month: 7
|
|
||||||
locales:
|
|
||||||
- "hr"
|
|
||||||
- name: "lipiec"
|
|
||||||
month: 7
|
|
||||||
locales:
|
|
||||||
- "pl"
|
|
||||||
- name: "luglio"
|
|
||||||
month: 7
|
|
||||||
locales:
|
|
||||||
- "it"
|
|
||||||
- name: "j\u00falius"
|
|
||||||
month: 7
|
|
||||||
locales:
|
|
||||||
- "hu"
|
|
||||||
- name: "august"
|
|
||||||
month: 8
|
|
||||||
locales:
|
|
||||||
- "en"
|
|
||||||
- name: "aug"
|
|
||||||
month: 8
|
|
||||||
locales:
|
|
||||||
- "en"
|
|
||||||
- "de"
|
|
||||||
- name: "ao\u00fbt"
|
|
||||||
month: 8
|
|
||||||
locales:
|
|
||||||
- "fr"
|
|
||||||
- name: "aout"
|
|
||||||
month: 8
|
|
||||||
locales:
|
|
||||||
- "fr"
|
|
||||||
- name: "agosto"
|
|
||||||
month: 8
|
|
||||||
locales:
|
|
||||||
- "es"
|
|
||||||
- "pt"
|
|
||||||
- "it"
|
|
||||||
- name: "a\u011fustos"
|
|
||||||
month: 8
|
|
||||||
locales:
|
|
||||||
- "tr"
|
|
||||||
- name: "agustos"
|
|
||||||
month: 8
|
|
||||||
locales:
|
|
||||||
- "tr"
|
|
||||||
- name: "kolovoz"
|
|
||||||
month: 8
|
|
||||||
locales:
|
|
||||||
- "hr"
|
|
||||||
- name: "srpen"
|
|
||||||
month: 8
|
|
||||||
locales:
|
|
||||||
- "cs"
|
|
||||||
- name: "sierpie\u0144"
|
|
||||||
month: 8
|
|
||||||
locales:
|
|
||||||
- "pl"
|
|
||||||
- name: "sierpien"
|
|
||||||
month: 8
|
|
||||||
locales:
|
|
||||||
- "pl"
|
|
||||||
- name: "augustus"
|
|
||||||
month: 8
|
|
||||||
locales:
|
|
||||||
- "nl"
|
|
||||||
- name: "agost"
|
|
||||||
month: 8
|
|
||||||
locales:
|
|
||||||
- "it"
|
|
||||||
- "ca"
|
|
||||||
- name: "september"
|
|
||||||
month: 9
|
|
||||||
locales:
|
|
||||||
- "en"
|
|
||||||
- name: "sept"
|
|
||||||
month: 9
|
|
||||||
locales:
|
|
||||||
- "en"
|
|
||||||
- "fr"
|
|
||||||
- name: "sep"
|
|
||||||
month: 9
|
|
||||||
locales:
|
|
||||||
- "en"
|
|
||||||
- name: "septembre"
|
|
||||||
month: 9
|
|
||||||
locales:
|
|
||||||
- "fr"
|
|
||||||
- name: "septiembre"
|
|
||||||
month: 9
|
|
||||||
locales:
|
|
||||||
- "es"
|
|
||||||
- name: "eyl\u00fcl"
|
|
||||||
month: 9
|
|
||||||
locales:
|
|
||||||
- "tr"
|
|
||||||
- name: "eylul"
|
|
||||||
month: 9
|
|
||||||
locales:
|
|
||||||
- "tr"
|
|
||||||
- name: "z\u00e1\u0159\u00ed"
|
|
||||||
month: 9
|
|
||||||
locales:
|
|
||||||
- "cs"
|
|
||||||
- name: "zari"
|
|
||||||
month: 9
|
|
||||||
locales:
|
|
||||||
- "cs"
|
|
||||||
- name: "rujan"
|
|
||||||
month: 9
|
|
||||||
locales:
|
|
||||||
- "hr"
|
|
||||||
- name: "wrzesie\u0144"
|
|
||||||
month: 9
|
|
||||||
locales:
|
|
||||||
- "pl"
|
|
||||||
- name: "wrzesien"
|
|
||||||
month: 9
|
|
||||||
locales:
|
|
||||||
- "pl"
|
|
||||||
- name: "septembrie"
|
|
||||||
month: 9
|
|
||||||
locales:
|
|
||||||
- "ro"
|
|
||||||
- name: "settembre"
|
|
||||||
month: 9
|
|
||||||
locales:
|
|
||||||
- "it"
|
|
||||||
- name: "october"
|
|
||||||
month: 10
|
|
||||||
locales:
|
|
||||||
- "en"
|
|
||||||
- name: "oct"
|
|
||||||
month: 10
|
|
||||||
locales:
|
|
||||||
- "en"
|
|
||||||
- name: "oktober"
|
|
||||||
month: 10
|
|
||||||
locales:
|
|
||||||
- "de"
|
|
||||||
- name: "okt\u00f3ber"
|
|
||||||
month: 10
|
|
||||||
locales:
|
|
||||||
- "hu"
|
|
||||||
- name: "octobre"
|
|
||||||
month: 10
|
|
||||||
locales:
|
|
||||||
- "fr"
|
|
||||||
- name: "octubre"
|
|
||||||
month: 10
|
|
||||||
locales:
|
|
||||||
- "es"
|
|
||||||
- name: "\u0159\u00edjen"
|
|
||||||
month: 10
|
|
||||||
locales:
|
|
||||||
- "cs"
|
|
||||||
- name: "rijen"
|
|
||||||
month: 10
|
|
||||||
locales:
|
|
||||||
- "hr"
|
|
||||||
- name: "pa\u017adziernik"
|
|
||||||
month: 10
|
|
||||||
locales:
|
|
||||||
- "pl"
|
|
||||||
- name: "pazdziernik"
|
|
||||||
month: 10
|
|
||||||
locales:
|
|
||||||
- "pl"
|
|
||||||
- name: "ekim"
|
|
||||||
month: 10
|
|
||||||
locales:
|
|
||||||
- "tr"
|
|
||||||
- name: "octombrie"
|
|
||||||
month: 10
|
|
||||||
locales:
|
|
||||||
- "ro"
|
|
||||||
- name: "november"
|
|
||||||
month: 11
|
|
||||||
locales:
|
|
||||||
- "en"
|
|
||||||
- name: "nov"
|
|
||||||
month: 11
|
|
||||||
locales:
|
|
||||||
- "en"
|
|
||||||
- name: "novembre"
|
|
||||||
month: 11
|
|
||||||
locales:
|
|
||||||
- "fr"
|
|
||||||
- name: "noviembre"
|
|
||||||
month: 11
|
|
||||||
locales:
|
|
||||||
- "es"
|
|
||||||
- name: "studeni"
|
|
||||||
month: 11
|
|
||||||
locales:
|
|
||||||
- "hr"
|
|
||||||
- name: "kas\u0131m"
|
|
||||||
month: 11
|
|
||||||
locales:
|
|
||||||
- "tr"
|
|
||||||
- name: "kasim"
|
|
||||||
month: 11
|
|
||||||
locales:
|
|
||||||
- "tr"
|
|
||||||
- name: "listopad"
|
|
||||||
month: 11
|
|
||||||
locales:
|
|
||||||
- "pl"
|
|
||||||
- "cs"
|
|
||||||
- name: "novembro"
|
|
||||||
month: 11
|
|
||||||
locales:
|
|
||||||
- "pt"
|
|
||||||
- name: "listopadu"
|
|
||||||
month: 11
|
|
||||||
locales:
|
|
||||||
- "cs"
|
|
||||||
- name: "noiembrie"
|
|
||||||
month: 11
|
|
||||||
locales:
|
|
||||||
- "ro"
|
|
||||||
- name: "december"
|
|
||||||
month: 12
|
|
||||||
locales:
|
|
||||||
- "en"
|
|
||||||
- name: "dec"
|
|
||||||
month: 12
|
|
||||||
locales:
|
|
||||||
- "en"
|
|
||||||
- name: "dezember"
|
|
||||||
month: 12
|
|
||||||
locales:
|
|
||||||
- "de"
|
|
||||||
- name: "d\u00e9cembre"
|
|
||||||
month: 12
|
|
||||||
locales:
|
|
||||||
- "fr"
|
|
||||||
- name: "decembre"
|
|
||||||
month: 12
|
|
||||||
locales:
|
|
||||||
- "fr"
|
|
||||||
- name: "prosinec"
|
|
||||||
month: 12
|
|
||||||
locales:
|
|
||||||
- "cs"
|
|
||||||
- name: "prosinac"
|
|
||||||
month: 12
|
|
||||||
locales:
|
|
||||||
- "hr"
|
|
||||||
- name: "grudzie\u0144"
|
|
||||||
month: 12
|
|
||||||
locales:
|
|
||||||
- "pl"
|
|
||||||
- name: "grudzien"
|
|
||||||
month: 12
|
|
||||||
locales:
|
|
||||||
- "pl"
|
|
||||||
- name: "grudnia"
|
|
||||||
month: 12
|
|
||||||
locales:
|
|
||||||
- "pl"
|
|
||||||
- name: "aral\u0131k"
|
|
||||||
month: 12
|
|
||||||
locales:
|
|
||||||
- "tr"
|
|
||||||
- name: "aralik"
|
|
||||||
month: 12
|
|
||||||
locales:
|
|
||||||
- "tr"
|
|
||||||
- name: "decembrie"
|
|
||||||
month: 12
|
|
||||||
locales:
|
|
||||||
- "ro"
|
|
||||||
@@ -1,324 +0,0 @@
|
|||||||
use argon2::{
|
|
||||||
password_hash::{rand_core::OsRng as PasswordHashOsRng, PasswordHasher, SaltString},
|
|
||||||
Argon2,
|
|
||||||
};
|
|
||||||
use chrono::{NaiveDateTime, Utc};
|
|
||||||
use diesel::prelude::*;
|
|
||||||
use rand::{rngs::OsRng, TryRngCore};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
auth::capability_sets::{load_capabilities_for_set, load_capability_set},
|
|
||||||
error::AppError,
|
|
||||||
models::{ApiCapability, ApiToken, CapabilitySet, NewApiToken},
|
|
||||||
schema::api_tokens,
|
|
||||||
state::PgPooledConnection,
|
|
||||||
tenants::{apply_api_token_prefix, clear_api_token_prefix},
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::schema::api_tokens::dsl as api_tokens_dsl;
|
|
||||||
|
|
||||||
const TOKEN_PREFIX_LENGTH: usize = 12;
|
|
||||||
const TOKEN_SECRET_LENGTH: usize = 32;
|
|
||||||
|
|
||||||
/// Represents a newly issued API token and the raw secret that was generated for it.
|
|
||||||
pub struct IssuedApiToken {
|
|
||||||
pub token: String,
|
|
||||||
pub record: ApiToken,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Creates a new API token for the supplied user/tenant combination.
|
|
||||||
pub fn create_api_token(
|
|
||||||
conn: &mut PgPooledConnection,
|
|
||||||
user_id: Uuid,
|
|
||||||
tenant_id: Uuid,
|
|
||||||
label: Option<String>,
|
|
||||||
expires_at: Option<NaiveDateTime>,
|
|
||||||
capability_set_id: Uuid,
|
|
||||||
) -> Result<IssuedApiToken, AppError> {
|
|
||||||
let capability_set =
|
|
||||||
validate_capability_set_belongs_to_tenant(conn, capability_set_id, tenant_id)?;
|
|
||||||
|
|
||||||
let raw_secret = generate_secret()?;
|
|
||||||
let token_prefix = raw_secret[..TOKEN_PREFIX_LENGTH].to_string();
|
|
||||||
let token_hash = hash_secret(&raw_secret)?;
|
|
||||||
let new_token = NewApiToken {
|
|
||||||
id: Uuid::new_v4(),
|
|
||||||
user_id,
|
|
||||||
tenant_id,
|
|
||||||
token_prefix,
|
|
||||||
token_hash,
|
|
||||||
label,
|
|
||||||
expires_at,
|
|
||||||
capability_set_id: capability_set.id,
|
|
||||||
};
|
|
||||||
|
|
||||||
let record = diesel::insert_into(api_tokens::table)
|
|
||||||
.values(&new_token)
|
|
||||||
.get_result::<ApiToken>(conn)?;
|
|
||||||
|
|
||||||
Ok(IssuedApiToken {
|
|
||||||
token: raw_secret,
|
|
||||||
record,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Lists API tokens belonging to a user within an optional tenant scope.
|
|
||||||
pub fn list_api_tokens(
|
|
||||||
conn: &mut PgPooledConnection,
|
|
||||||
user_id: Uuid,
|
|
||||||
tenant_id: Option<Uuid>,
|
|
||||||
) -> Result<Vec<ApiToken>, AppError> {
|
|
||||||
let mut query = api_tokens::table
|
|
||||||
.filter(api_tokens::user_id.eq(user_id))
|
|
||||||
.into_boxed();
|
|
||||||
|
|
||||||
if let Some(tenant_id) = tenant_id {
|
|
||||||
query = query.filter(api_tokens::tenant_id.eq(tenant_id));
|
|
||||||
}
|
|
||||||
|
|
||||||
let tokens = query
|
|
||||||
.order(api_tokens::created_at.asc())
|
|
||||||
.load::<ApiToken>(conn)?;
|
|
||||||
|
|
||||||
Ok(tokens)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Regenerates the secret value for an API token.
|
|
||||||
pub fn regenerate_api_token(
|
|
||||||
conn: &mut PgPooledConnection,
|
|
||||||
token_id: Uuid,
|
|
||||||
user_id: Uuid,
|
|
||||||
tenant_id: Option<Uuid>,
|
|
||||||
) -> Result<IssuedApiToken, AppError> {
|
|
||||||
let record = find_user_token(conn, token_id, user_id, tenant_id)?;
|
|
||||||
|
|
||||||
if record.revoked_at.is_some() {
|
|
||||||
return Err(AppError::bad_request(
|
|
||||||
"cannot regenerate a revoked API token",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let raw_secret = generate_secret()?;
|
|
||||||
let token_prefix = raw_secret[..TOKEN_PREFIX_LENGTH].to_string();
|
|
||||||
let token_hash = hash_secret(&raw_secret)?;
|
|
||||||
|
|
||||||
let updated = diesel::update(api_tokens::table.find(record.id))
|
|
||||||
.set((
|
|
||||||
api_tokens::token_prefix.eq(&token_prefix),
|
|
||||||
api_tokens::token_hash.eq(&token_hash),
|
|
||||||
api_tokens::last_used_at.eq::<Option<NaiveDateTime>>(None),
|
|
||||||
))
|
|
||||||
.get_result::<ApiToken>(conn)?;
|
|
||||||
|
|
||||||
Ok(IssuedApiToken {
|
|
||||||
token: raw_secret,
|
|
||||||
record: updated,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Attempts to resolve an API token by its secret value while ensuring it provides the
|
|
||||||
/// requested capability.
|
|
||||||
pub fn find_active_token_by_secret(
|
|
||||||
conn: &mut PgPooledConnection,
|
|
||||||
tenant_id: Option<Uuid>,
|
|
||||||
secret: &str,
|
|
||||||
required_capability: Option<ApiCapability>,
|
|
||||||
) -> Result<Option<ApiToken>, AppError> {
|
|
||||||
if secret.len() < TOKEN_PREFIX_LENGTH {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let prefix = &secret[..TOKEN_PREFIX_LENGTH];
|
|
||||||
let candidates = with_api_token_prefix(conn, prefix, |conn| {
|
|
||||||
let mut query = api_tokens::table
|
|
||||||
.filter(api_tokens::token_prefix.eq(prefix))
|
|
||||||
.filter(api_tokens::revoked_at.is_null())
|
|
||||||
.into_boxed();
|
|
||||||
|
|
||||||
let now = Utc::now().naive_utc();
|
|
||||||
query = query.filter(
|
|
||||||
api_tokens::expires_at
|
|
||||||
.is_null()
|
|
||||||
.or(api_tokens::expires_at.gt(now)),
|
|
||||||
);
|
|
||||||
|
|
||||||
if let Some(tenant_id) = tenant_id {
|
|
||||||
query = query.filter(api_tokens::tenant_id.eq(tenant_id));
|
|
||||||
}
|
|
||||||
|
|
||||||
query.load::<ApiToken>(conn).map_err(AppError::from)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
for token in candidates {
|
|
||||||
if let Some(required) = required_capability {
|
|
||||||
let capabilities = load_capabilities_for_set(conn, token.capability_set_id)?;
|
|
||||||
if !capabilities.contains(&required) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if verify_token_secret(secret, &token.token_hash)? {
|
|
||||||
return Ok(Some(token));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Revokes an API token belonging to the specified user.
|
|
||||||
pub fn revoke_api_token(
|
|
||||||
conn: &mut PgPooledConnection,
|
|
||||||
token_id: Uuid,
|
|
||||||
user_id: Uuid,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let token = find_user_token(conn, token_id, user_id, None)?;
|
|
||||||
|
|
||||||
diesel::update(api_tokens::table.find(token.id))
|
|
||||||
.set(api_tokens::revoked_at.eq(Utc::now().naive_utc()))
|
|
||||||
.execute(conn)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Updates the last-used timestamp for a token.
|
|
||||||
pub fn touch_api_token(conn: &mut PgPooledConnection, token_id: Uuid) -> Result<(), AppError> {
|
|
||||||
diesel::update(api_tokens::table.filter(api_tokens::id.eq(token_id)))
|
|
||||||
.set(api_tokens::last_used_at.eq(Utc::now().naive_utc()))
|
|
||||||
.execute(conn)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Verifies a secret against its stored hash representation.
|
|
||||||
pub fn verify_token_secret(secret: &str, token_hash: &str) -> Result<bool, AppError> {
|
|
||||||
crate::auth::password::verify_password(secret, token_hash).map_err(|err| {
|
|
||||||
tracing::error!(error = ?err, "failed to verify token");
|
|
||||||
AppError::internal("failed to verify token")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn find_user_token(
|
|
||||||
conn: &mut PgPooledConnection,
|
|
||||||
token_id: Uuid,
|
|
||||||
user_id: Uuid,
|
|
||||||
tenant_id: Option<Uuid>,
|
|
||||||
) -> Result<ApiToken, AppError> {
|
|
||||||
let mut query = api_tokens_dsl::api_tokens
|
|
||||||
.filter(api_tokens_dsl::id.eq(token_id))
|
|
||||||
.filter(api_tokens_dsl::user_id.eq(user_id))
|
|
||||||
.into_boxed();
|
|
||||||
|
|
||||||
if let Some(tid) = tenant_id {
|
|
||||||
query = query.filter(api_tokens_dsl::tenant_id.eq(tid));
|
|
||||||
}
|
|
||||||
|
|
||||||
query
|
|
||||||
.first::<ApiToken>(conn)
|
|
||||||
.optional()
|
|
||||||
.map_err(AppError::from)?
|
|
||||||
.ok_or_else(AppError::not_found)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_capability_set_belongs_to_tenant(
|
|
||||||
conn: &mut PgPooledConnection,
|
|
||||||
capability_set_id: Uuid,
|
|
||||||
tenant_id: Uuid,
|
|
||||||
) -> Result<CapabilitySet, AppError> {
|
|
||||||
let capability_set = load_capability_set(conn, capability_set_id)?;
|
|
||||||
if capability_set.tenant_id != tenant_id {
|
|
||||||
return Err(AppError::bad_request(
|
|
||||||
"capability set does not belong to the tenant",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(capability_set)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn with_api_token_prefix<T, F>(
|
|
||||||
conn: &mut PgPooledConnection,
|
|
||||||
prefix: &str,
|
|
||||||
operation: F,
|
|
||||||
) -> Result<T, AppError>
|
|
||||||
where
|
|
||||||
F: FnOnce(&mut PgPooledConnection) -> Result<T, AppError>,
|
|
||||||
{
|
|
||||||
apply_api_token_prefix(conn, prefix)?;
|
|
||||||
let operation_result = operation(conn);
|
|
||||||
let clear_result = clear_api_token_prefix(conn);
|
|
||||||
|
|
||||||
if let Err(err) = clear_result {
|
|
||||||
return Err(err);
|
|
||||||
}
|
|
||||||
|
|
||||||
operation_result
|
|
||||||
}
|
|
||||||
|
|
||||||
fn generate_secret() -> Result<String, AppError> {
|
|
||||||
let mut buffer = [0u8; TOKEN_SECRET_LENGTH];
|
|
||||||
OsRng.try_fill_bytes(&mut buffer).map_err(|err| {
|
|
||||||
tracing::error!(error = ?err, "failed to generate token");
|
|
||||||
AppError::internal("failed to generate token")
|
|
||||||
})?;
|
|
||||||
Ok(hex::encode(buffer))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn hash_secret(secret: &str) -> Result<String, AppError> {
|
|
||||||
let mut salt_rng = PasswordHashOsRng;
|
|
||||||
let salt = SaltString::generate(&mut salt_rng);
|
|
||||||
let hash = Argon2::default()
|
|
||||||
.hash_password(secret.as_bytes(), &salt)
|
|
||||||
.map_err(|err| {
|
|
||||||
tracing::error!(error = ?err, "failed to hash token");
|
|
||||||
AppError::internal("failed to hash token")
|
|
||||||
})?;
|
|
||||||
Ok(hash.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::auth::capability_sets::{
|
|
||||||
compute_slug, normalize_capabilities, owner_capabilities, webdav_capabilities,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn generated_secret_has_expected_length() {
|
|
||||||
let secret = generate_secret().unwrap();
|
|
||||||
assert_eq!(secret.len(), TOKEN_SECRET_LENGTH * 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn hash_and_verify_secret_round_trip() {
|
|
||||||
let secret = generate_secret().unwrap();
|
|
||||||
let hash = hash_secret(&secret).unwrap();
|
|
||||||
assert!(verify_token_secret(&secret, &hash).unwrap());
|
|
||||||
assert!(!verify_token_secret("wrong", &hash).unwrap());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn normalize_capabilities_deduplicates() {
|
|
||||||
let mut caps = owner_capabilities().to_vec();
|
|
||||||
caps.push(ApiCapability::DocumentsRead);
|
|
||||||
let normalized = normalize_capabilities(caps).unwrap();
|
|
||||||
assert_eq!(normalized.len(), owner_capabilities().len());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn normalize_capabilities_rejects_empty() {
|
|
||||||
assert!(normalize_capabilities(Vec::new()).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn compute_slug_matches_system_sets() {
|
|
||||||
let owner_slug = compute_slug(owner_capabilities());
|
|
||||||
assert_eq!(owner_slug, "owner");
|
|
||||||
|
|
||||||
let webdav_slug = compute_slug(webdav_capabilities());
|
|
||||||
assert_eq!(webdav_slug, "webdav");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn prefix_length_is_less_than_secret_length() {
|
|
||||||
assert!(TOKEN_PREFIX_LENGTH < TOKEN_SECRET_LENGTH * 2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use axum::{
|
|
||||||
http::{Request, StatusCode},
|
|
||||||
response::IntoResponse,
|
|
||||||
};
|
|
||||||
use tower::{Layer, Service};
|
|
||||||
|
|
||||||
use crate::{auth::AuthenticatedUser, error::AppError, models::ApiCapability};
|
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
|
||||||
pub enum CapabilityStrategy {
|
|
||||||
All,
|
|
||||||
Any,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct RequireCapabilitiesLayer {
|
|
||||||
required: Arc<Vec<ApiCapability>>,
|
|
||||||
strategy: CapabilityStrategy,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RequireCapabilitiesLayer {
|
|
||||||
pub fn all<I>(caps: I) -> Self
|
|
||||||
where
|
|
||||||
I: IntoIterator<Item = ApiCapability>,
|
|
||||||
{
|
|
||||||
Self {
|
|
||||||
required: Arc::new(caps.into_iter().collect()),
|
|
||||||
strategy: CapabilityStrategy::All,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn any<I>(caps: I) -> Self
|
|
||||||
where
|
|
||||||
I: IntoIterator<Item = ApiCapability>,
|
|
||||||
{
|
|
||||||
Self {
|
|
||||||
required: Arc::new(caps.into_iter().collect()),
|
|
||||||
strategy: CapabilityStrategy::Any,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<S> Layer<S> for RequireCapabilitiesLayer {
|
|
||||||
type Service = RequireCapabilities<S>;
|
|
||||||
|
|
||||||
fn layer(&self, inner: S) -> Self::Service {
|
|
||||||
RequireCapabilities {
|
|
||||||
inner,
|
|
||||||
required: Arc::clone(&self.required),
|
|
||||||
strategy: self.strategy,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct RequireCapabilities<S> {
|
|
||||||
inner: S,
|
|
||||||
required: Arc<Vec<ApiCapability>>,
|
|
||||||
strategy: CapabilityStrategy,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<S, B> Service<Request<B>> for RequireCapabilities<S>
|
|
||||||
where
|
|
||||||
S: Service<Request<B>, Response = axum::response::Response> + Send,
|
|
||||||
S::Future: Send + 'static,
|
|
||||||
B: Send + 'static,
|
|
||||||
{
|
|
||||||
type Response = S::Response;
|
|
||||||
type Error = S::Error;
|
|
||||||
type Future = std::pin::Pin<
|
|
||||||
Box<dyn std::future::Future<Output = Result<Self::Response, Self::Error>> + Send>,
|
|
||||||
>;
|
|
||||||
|
|
||||||
fn poll_ready(
|
|
||||||
&mut self,
|
|
||||||
cx: &mut std::task::Context<'_>,
|
|
||||||
) -> std::task::Poll<Result<(), Self::Error>> {
|
|
||||||
self.inner.poll_ready(cx)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn call(&mut self, req: Request<B>) -> Self::Future {
|
|
||||||
if self.required.is_empty() {
|
|
||||||
let fut = self.inner.call(req);
|
|
||||||
return Box::pin(async move { fut.await });
|
|
||||||
}
|
|
||||||
|
|
||||||
let (parts, body) = req.into_parts();
|
|
||||||
let user = match parts.extensions.get::<AuthenticatedUser>() {
|
|
||||||
Some(user) => user,
|
|
||||||
None => {
|
|
||||||
let response = AppError::unauthorized().into_response();
|
|
||||||
return Box::pin(async move { Ok(response) });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let allowed = match self.strategy {
|
|
||||||
CapabilityStrategy::All => self
|
|
||||||
.required
|
|
||||||
.iter()
|
|
||||||
.all(|cap| user.capabilities.contains(cap)),
|
|
||||||
CapabilityStrategy::Any => self
|
|
||||||
.required
|
|
||||||
.iter()
|
|
||||||
.any(|cap| user.capabilities.contains(cap)),
|
|
||||||
};
|
|
||||||
|
|
||||||
if !allowed {
|
|
||||||
let response = AppError::new(StatusCode::FORBIDDEN, "missing required capability")
|
|
||||||
.with_code("missing_capability")
|
|
||||||
.into_response();
|
|
||||||
return Box::pin(async move { Ok(response) });
|
|
||||||
}
|
|
||||||
|
|
||||||
let req = Request::from_parts(parts, body);
|
|
||||||
let fut = self.inner.call(req);
|
|
||||||
Box::pin(async move { fut.await })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,323 +0,0 @@
|
|||||||
use chrono::Utc;
|
|
||||||
use diesel::{pg::Pg, prelude::*, Connection};
|
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
error::AppError,
|
|
||||||
models::{ApiCapability, CapabilitySet, NewCapabilitySet, NewCapabilitySetCapability},
|
|
||||||
schema::{
|
|
||||||
capability_set_capabilities, capability_set_capabilities::dsl as csc_dsl, capability_sets,
|
|
||||||
capability_sets::dsl as cs_dsl,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const OWNER_CAPABILITIES: [ApiCapability; 22] = [
|
|
||||||
ApiCapability::CorrespondentsEdit,
|
|
||||||
ApiCapability::CorrespondentsRead,
|
|
||||||
ApiCapability::CorrespondentsWrite,
|
|
||||||
ApiCapability::DocumentsEdit,
|
|
||||||
ApiCapability::DocumentsRead,
|
|
||||||
ApiCapability::DocumentsUpload,
|
|
||||||
ApiCapability::DocumentsWrite,
|
|
||||||
ApiCapability::FoldersEdit,
|
|
||||||
ApiCapability::FoldersRead,
|
|
||||||
ApiCapability::FoldersWrite,
|
|
||||||
ApiCapability::ProfileRead,
|
|
||||||
ApiCapability::ProfileWrite,
|
|
||||||
ApiCapability::TagsEdit,
|
|
||||||
ApiCapability::TagsRead,
|
|
||||||
ApiCapability::TagsWrite,
|
|
||||||
ApiCapability::WebdavRead,
|
|
||||||
ApiCapability::WebdavWrite,
|
|
||||||
ApiCapability::CapabilitySetsRead,
|
|
||||||
ApiCapability::CapabilitySetsWrite,
|
|
||||||
ApiCapability::TenantsWrite,
|
|
||||||
ApiCapability::TenantsReset,
|
|
||||||
ApiCapability::TenantsDelete,
|
|
||||||
];
|
|
||||||
|
|
||||||
const USER_CAPABILITIES: [ApiCapability; 16] = [
|
|
||||||
ApiCapability::CorrespondentsEdit,
|
|
||||||
ApiCapability::CorrespondentsRead,
|
|
||||||
ApiCapability::CorrespondentsWrite,
|
|
||||||
ApiCapability::DocumentsEdit,
|
|
||||||
ApiCapability::DocumentsRead,
|
|
||||||
ApiCapability::DocumentsUpload,
|
|
||||||
ApiCapability::DocumentsWrite,
|
|
||||||
ApiCapability::FoldersEdit,
|
|
||||||
ApiCapability::FoldersRead,
|
|
||||||
ApiCapability::FoldersWrite,
|
|
||||||
ApiCapability::ProfileRead,
|
|
||||||
ApiCapability::ProfileWrite,
|
|
||||||
ApiCapability::TagsEdit,
|
|
||||||
ApiCapability::TagsRead,
|
|
||||||
ApiCapability::TagsWrite,
|
|
||||||
ApiCapability::WebdavRead,
|
|
||||||
];
|
|
||||||
|
|
||||||
const READONLY_CAPABILITIES: [ApiCapability; 5] = [
|
|
||||||
ApiCapability::CorrespondentsRead,
|
|
||||||
ApiCapability::DocumentsRead,
|
|
||||||
ApiCapability::FoldersRead,
|
|
||||||
ApiCapability::TagsRead,
|
|
||||||
ApiCapability::WebdavRead,
|
|
||||||
];
|
|
||||||
|
|
||||||
const WEBDAV_CAPABILITIES: [ApiCapability; 1] = [ApiCapability::WebdavRead];
|
|
||||||
|
|
||||||
pub fn owner_capabilities() -> &'static [ApiCapability] {
|
|
||||||
&OWNER_CAPABILITIES
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn user_capabilities() -> &'static [ApiCapability] {
|
|
||||||
&USER_CAPABILITIES
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn readonly_capabilities() -> &'static [ApiCapability] {
|
|
||||||
&READONLY_CAPABILITIES
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn webdav_capabilities() -> &'static [ApiCapability] {
|
|
||||||
&WEBDAV_CAPABILITIES
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_system_slug(slug: &str) -> bool {
|
|
||||||
matches!(slug, "owner" | "user" | "readonly" | "webdav")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn create_capability_set<C>(
|
|
||||||
conn: &mut C,
|
|
||||||
tenant_id: Uuid,
|
|
||||||
slug: &str,
|
|
||||||
capabilities: Vec<ApiCapability>,
|
|
||||||
) -> Result<CapabilitySet, AppError>
|
|
||||||
where
|
|
||||||
C: Connection<Backend = Pg> + diesel::connection::LoadConnection,
|
|
||||||
{
|
|
||||||
let normalized = normalize_capabilities(capabilities)?;
|
|
||||||
|
|
||||||
conn.transaction::<CapabilitySet, AppError, _>(|conn| {
|
|
||||||
if cs_dsl::capability_sets
|
|
||||||
.filter(cs_dsl::tenant_id.eq(tenant_id))
|
|
||||||
.filter(cs_dsl::slug.eq(slug))
|
|
||||||
.first::<CapabilitySet>(conn)
|
|
||||||
.optional()
|
|
||||||
.map_err(AppError::from)?
|
|
||||||
.is_some()
|
|
||||||
{
|
|
||||||
return Err(AppError::conflict("capability set slug already exists"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let set = NewCapabilitySet {
|
|
||||||
id: Uuid::new_v4(),
|
|
||||||
tenant_id,
|
|
||||||
slug: slug.to_owned(),
|
|
||||||
cap_version: 1,
|
|
||||||
is_system: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
diesel::insert_into(capability_sets::table)
|
|
||||||
.values(&set)
|
|
||||||
.execute(conn)
|
|
||||||
.map_err(AppError::from)?;
|
|
||||||
|
|
||||||
persist_capabilities(conn, set.id, &normalized)?;
|
|
||||||
|
|
||||||
capability_sets::table
|
|
||||||
.find(set.id)
|
|
||||||
.first::<CapabilitySet>(conn)
|
|
||||||
.map_err(AppError::from)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn normalize_capabilities(
|
|
||||||
mut capabilities: Vec<ApiCapability>,
|
|
||||||
) -> Result<Vec<ApiCapability>, AppError> {
|
|
||||||
if capabilities.is_empty() {
|
|
||||||
return Err(AppError::bad_request("at least one capability is required"));
|
|
||||||
}
|
|
||||||
|
|
||||||
capabilities.sort_by(|a, b| a.as_str().cmp(b.as_str()));
|
|
||||||
capabilities.dedup();
|
|
||||||
Ok(capabilities)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn load_capabilities_for_set<C>(
|
|
||||||
conn: &mut C,
|
|
||||||
capability_set_id: Uuid,
|
|
||||||
) -> Result<Vec<ApiCapability>, AppError>
|
|
||||||
where
|
|
||||||
C: Connection<Backend = Pg> + diesel::connection::LoadConnection,
|
|
||||||
{
|
|
||||||
let mut capabilities: Vec<ApiCapability> = csc_dsl::capability_set_capabilities
|
|
||||||
.filter(csc_dsl::capability_set_id.eq(capability_set_id))
|
|
||||||
.select(csc_dsl::capability)
|
|
||||||
.load(conn)
|
|
||||||
.map_err(AppError::from)?;
|
|
||||||
|
|
||||||
capabilities.sort_by(|a, b| a.as_str().cmp(b.as_str()));
|
|
||||||
Ok(capabilities)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn ensure_capability_set<C>(
|
|
||||||
conn: &mut C,
|
|
||||||
tenant_id: Uuid,
|
|
||||||
capabilities: &[ApiCapability],
|
|
||||||
) -> Result<CapabilitySet, AppError>
|
|
||||||
where
|
|
||||||
C: Connection<Backend = Pg> + diesel::connection::LoadConnection,
|
|
||||||
{
|
|
||||||
if capabilities.is_empty() {
|
|
||||||
return Err(AppError::bad_request("at least one capability is required"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let slug = compute_slug(capabilities);
|
|
||||||
conn.transaction::<CapabilitySet, AppError, _>(|conn| {
|
|
||||||
if let Some(existing) = cs_dsl::capability_sets
|
|
||||||
.filter(cs_dsl::tenant_id.eq(tenant_id))
|
|
||||||
.filter(cs_dsl::slug.eq(&slug))
|
|
||||||
.first::<CapabilitySet>(conn)
|
|
||||||
.optional()
|
|
||||||
.map_err(AppError::from)?
|
|
||||||
{
|
|
||||||
ensure_capability_membership(conn, &existing, capabilities)?;
|
|
||||||
return Ok(existing);
|
|
||||||
}
|
|
||||||
|
|
||||||
let set = NewCapabilitySet {
|
|
||||||
id: Uuid::new_v4(),
|
|
||||||
tenant_id,
|
|
||||||
slug: slug.clone(),
|
|
||||||
cap_version: 1,
|
|
||||||
is_system: matches!(slug.as_str(), "owner" | "user" | "readonly" | "webdav"),
|
|
||||||
};
|
|
||||||
|
|
||||||
diesel::insert_into(capability_sets::table)
|
|
||||||
.values(&set)
|
|
||||||
.execute(conn)
|
|
||||||
.map_err(AppError::from)?;
|
|
||||||
|
|
||||||
persist_capabilities(conn, set.id, capabilities)?;
|
|
||||||
|
|
||||||
Ok(capability_sets::table
|
|
||||||
.find(set.id)
|
|
||||||
.first::<CapabilitySet>(conn)
|
|
||||||
.map_err(AppError::from)?)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn refresh_capability_set<C>(
|
|
||||||
conn: &mut C,
|
|
||||||
set: &CapabilitySet,
|
|
||||||
capabilities: &[ApiCapability],
|
|
||||||
) -> Result<CapabilitySet, AppError>
|
|
||||||
where
|
|
||||||
C: Connection<Backend = Pg> + diesel::connection::LoadConnection,
|
|
||||||
{
|
|
||||||
conn.transaction::<CapabilitySet, AppError, _>(|conn| {
|
|
||||||
diesel::delete(
|
|
||||||
csc_dsl::capability_set_capabilities.filter(csc_dsl::capability_set_id.eq(set.id)),
|
|
||||||
)
|
|
||||||
.execute(conn)
|
|
||||||
.map_err(AppError::from)?;
|
|
||||||
|
|
||||||
persist_capabilities(conn, set.id, capabilities)?;
|
|
||||||
|
|
||||||
diesel::update(capability_sets::table.find(set.id))
|
|
||||||
.set((
|
|
||||||
cs_dsl::cap_version.eq(set.cap_version + 1),
|
|
||||||
cs_dsl::updated_at.eq(Utc::now().naive_utc()),
|
|
||||||
))
|
|
||||||
.execute(conn)
|
|
||||||
.map_err(AppError::from)?;
|
|
||||||
|
|
||||||
capability_sets::table
|
|
||||||
.find(set.id)
|
|
||||||
.first::<CapabilitySet>(conn)
|
|
||||||
.map_err(AppError::from)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn load_capability_set<C>(conn: &mut C, id: Uuid) -> Result<CapabilitySet, AppError>
|
|
||||||
where
|
|
||||||
C: Connection<Backend = Pg> + diesel::connection::LoadConnection,
|
|
||||||
{
|
|
||||||
capability_sets::table
|
|
||||||
.find(id)
|
|
||||||
.first::<CapabilitySet>(conn)
|
|
||||||
.map_err(AppError::from)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn compute_slug(capabilities: &[ApiCapability]) -> String {
|
|
||||||
if capabilities == owner_capabilities() {
|
|
||||||
return "owner".to_string();
|
|
||||||
}
|
|
||||||
|
|
||||||
if capabilities == user_capabilities() {
|
|
||||||
return "user".to_string();
|
|
||||||
}
|
|
||||||
|
|
||||||
if capabilities == readonly_capabilities() {
|
|
||||||
return "readonly".to_string();
|
|
||||||
}
|
|
||||||
|
|
||||||
if capabilities == webdav_capabilities() {
|
|
||||||
return "webdav".to_string();
|
|
||||||
}
|
|
||||||
|
|
||||||
let joined = capabilities
|
|
||||||
.iter()
|
|
||||||
.map(|cap| cap.as_str())
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(",");
|
|
||||||
|
|
||||||
let digest = Sha256::digest(joined.as_bytes());
|
|
||||||
let hex = hex::encode(digest);
|
|
||||||
format!("caps-{}", &hex[..12])
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ensure_capability_membership<C>(
|
|
||||||
conn: &mut C,
|
|
||||||
set: &CapabilitySet,
|
|
||||||
desired: &[ApiCapability],
|
|
||||||
) -> Result<(), AppError>
|
|
||||||
where
|
|
||||||
C: Connection<Backend = Pg> + diesel::connection::LoadConnection,
|
|
||||||
{
|
|
||||||
let current = load_capabilities_for_set(conn, set.id)?;
|
|
||||||
if current == desired {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let _ = refresh_capability_set(conn, set, desired)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn persist_capabilities<C>(
|
|
||||||
conn: &mut C,
|
|
||||||
set_id: Uuid,
|
|
||||||
capabilities: &[ApiCapability],
|
|
||||||
) -> Result<(), AppError>
|
|
||||||
where
|
|
||||||
C: Connection<Backend = Pg> + diesel::connection::LoadConnection,
|
|
||||||
{
|
|
||||||
if capabilities.is_empty() {
|
|
||||||
return Err(AppError::bad_request("at least one capability is required"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let records: Vec<NewCapabilitySetCapability> = capabilities
|
|
||||||
.iter()
|
|
||||||
.map(|cap| NewCapabilitySetCapability {
|
|
||||||
capability_set_id: set_id,
|
|
||||||
capability: *cap,
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
diesel::insert_into(capability_set_capabilities::table)
|
|
||||||
.values(&records)
|
|
||||||
.execute(conn)
|
|
||||||
.map_err(AppError::from)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
+6
-191
@@ -2,29 +2,10 @@ use anyhow::Result;
|
|||||||
use chrono::{Duration, Utc};
|
use chrono::{Duration, Utc};
|
||||||
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
|
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use utoipa::ToSchema;
|
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::config::AppConfig;
|
use crate::config::AppConfig;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, ToSchema)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum PrincipalKind {
|
|
||||||
UserSession,
|
|
||||||
ApiToken,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct AccessTokenContext {
|
|
||||||
pub user_id: Uuid,
|
|
||||||
pub tenant_id: Uuid,
|
|
||||||
pub username: String,
|
|
||||||
pub principal_kind: PrincipalKind,
|
|
||||||
pub principal_id: Uuid,
|
|
||||||
pub capability_set_id: Uuid,
|
|
||||||
pub cap_version: i32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct JwtService {
|
pub struct JwtService {
|
||||||
encoding: EncodingKey,
|
encoding: EncodingKey,
|
||||||
@@ -32,44 +13,26 @@ pub struct JwtService {
|
|||||||
issuer: String,
|
issuer: String,
|
||||||
audience: String,
|
audience: String,
|
||||||
expiry: Duration,
|
expiry: Duration,
|
||||||
download_audience: String,
|
|
||||||
download_expiry: Duration,
|
|
||||||
selector_audience: String,
|
|
||||||
selector_expiry: Duration,
|
|
||||||
signup_audience: String,
|
|
||||||
signup_expiry: Duration,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl JwtService {
|
impl JwtService {
|
||||||
pub fn from_config(config: &AppConfig) -> Result<Self> {
|
pub fn from_config(config: &AppConfig) -> Result<Self> {
|
||||||
let access_expiry = Duration::minutes(config.jwt_expiry_minutes);
|
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
encoding: EncodingKey::from_secret(config.jwt_secret.as_bytes()),
|
encoding: EncodingKey::from_secret(config.jwt_secret.as_bytes()),
|
||||||
decoding: DecodingKey::from_secret(config.jwt_secret.as_bytes()),
|
decoding: DecodingKey::from_secret(config.jwt_secret.as_bytes()),
|
||||||
issuer: config.jwt_issuer.clone(),
|
issuer: config.jwt_issuer.clone(),
|
||||||
audience: config.jwt_audience.clone(),
|
audience: config.jwt_audience.clone(),
|
||||||
expiry: access_expiry,
|
expiry: Duration::minutes(config.jwt_expiry_minutes),
|
||||||
download_audience: config.download_token_audience.clone(),
|
|
||||||
download_expiry: Duration::minutes(config.download_token_expiry_minutes),
|
|
||||||
selector_audience: format!("{}:tenant-selector", config.jwt_audience),
|
|
||||||
selector_expiry: access_expiry,
|
|
||||||
signup_audience: format!("{}:signup", config.jwt_audience),
|
|
||||||
signup_expiry: Duration::minutes(15),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn generate_token(&self, context: AccessTokenContext) -> Result<String> {
|
pub fn generate_token(&self, user_id: Uuid, username: &str, role: &str) -> Result<String> {
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
let exp = now + self.expiry;
|
let exp = now + self.expiry;
|
||||||
let claims = Claims {
|
let claims = Claims {
|
||||||
sub: context.user_id,
|
sub: user_id,
|
||||||
tenant_id: context.tenant_id,
|
username: username.to_owned(),
|
||||||
username: context.username,
|
role: role.to_owned(),
|
||||||
principal_kind: context.principal_kind,
|
|
||||||
principal_id: context.principal_id,
|
|
||||||
capability_set_id: context.capability_set_id,
|
|
||||||
cap_version: context.cap_version,
|
|
||||||
iss: self.issuer.clone(),
|
iss: self.issuer.clone(),
|
||||||
aud: self.audience.clone(),
|
aud: self.audience.clone(),
|
||||||
iat: now.timestamp() as usize,
|
iat: now.timestamp() as usize,
|
||||||
@@ -86,161 +49,13 @@ impl JwtService {
|
|||||||
let data = decode::<Claims>(token, &self.decoding, &validation)?;
|
let data = decode::<Claims>(token, &self.decoding, &validation)?;
|
||||||
Ok(data.claims)
|
Ok(data.claims)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn generate_download_token(
|
|
||||||
&self,
|
|
||||||
document_id: Uuid,
|
|
||||||
version_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::Document {
|
|
||||||
doc_id: document_id,
|
|
||||||
version_id,
|
|
||||||
},
|
|
||||||
user_id,
|
|
||||||
tenant_id,
|
|
||||||
iss: self.issuer.clone(),
|
|
||||||
aud: self.download_audience.clone(),
|
|
||||||
iat: now.timestamp() as usize,
|
|
||||||
exp: exp.timestamp() as usize,
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(encode(&Header::default(), &claims, &self.encoding)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn generate_asset_download_token(
|
|
||||||
&self,
|
|
||||||
asset_id: Uuid,
|
|
||||||
user_id: Uuid,
|
|
||||||
tenant_id: Uuid,
|
|
||||||
) -> Result<String> {
|
|
||||||
let now = Utc::now();
|
|
||||||
let exp = now + self.download_expiry;
|
|
||||||
let claims = DownloadClaims {
|
|
||||||
subject: DownloadSubject::Asset { asset_id },
|
|
||||||
user_id,
|
|
||||||
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 verify_download_token(&self, token: &str) -> Result<DownloadClaims> {
|
|
||||||
let mut validation = Validation::default();
|
|
||||||
validation.set_audience(&[self.download_audience.clone()]);
|
|
||||||
validation.set_issuer(&[self.issuer.clone()]);
|
|
||||||
let data = decode::<DownloadClaims>(token, &self.decoding, &validation)?;
|
|
||||||
Ok(data.claims)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn generate_tenant_selector_token(&self, user_id: Uuid) -> Result<String> {
|
|
||||||
let now = Utc::now();
|
|
||||||
let exp = now + self.selector_expiry;
|
|
||||||
let claims = TenantSelectionClaims {
|
|
||||||
sub: user_id,
|
|
||||||
iss: self.issuer.clone(),
|
|
||||||
aud: self.selector_audience.clone(),
|
|
||||||
iat: now.timestamp() as usize,
|
|
||||||
exp: exp.timestamp() as usize,
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(encode(&Header::default(), &claims, &self.encoding)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn verify_tenant_selector_token(&self, token: &str) -> Result<TenantSelectionClaims> {
|
|
||||||
let mut validation = Validation::default();
|
|
||||||
validation.set_audience(&[self.selector_audience.clone()]);
|
|
||||||
validation.set_issuer(&[self.issuer.clone()]);
|
|
||||||
let data = decode::<TenantSelectionClaims>(token, &self.decoding, &validation)?;
|
|
||||||
Ok(data.claims)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn generate_signup_token(
|
|
||||||
&self,
|
|
||||||
user_id: Uuid,
|
|
||||||
challenge_id: Uuid,
|
|
||||||
username: String,
|
|
||||||
) -> Result<String> {
|
|
||||||
let now = Utc::now();
|
|
||||||
let exp = now + self.signup_expiry;
|
|
||||||
let claims = SignupClaims {
|
|
||||||
sub: user_id,
|
|
||||||
challenge_id,
|
|
||||||
username,
|
|
||||||
iss: self.issuer.clone(),
|
|
||||||
aud: self.signup_audience.clone(),
|
|
||||||
iat: now.timestamp() as usize,
|
|
||||||
exp: exp.timestamp() as usize,
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(encode(&Header::default(), &claims, &self.encoding)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn verify_signup_token(&self, token: &str) -> Result<SignupClaims> {
|
|
||||||
let mut validation = Validation::default();
|
|
||||||
validation.set_audience(&[self.signup_audience.clone()]);
|
|
||||||
validation.set_issuer(&[self.issuer.clone()]);
|
|
||||||
let data = decode::<SignupClaims>(token, &self.decoding, &validation)?;
|
|
||||||
Ok(data.claims)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Claims {
|
pub struct Claims {
|
||||||
pub sub: Uuid,
|
pub sub: 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,
|
|
||||||
pub iss: String,
|
|
||||||
pub aud: String,
|
|
||||||
pub iat: 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)]
|
|
||||||
pub struct DownloadClaims {
|
|
||||||
#[serde(flatten)]
|
|
||||||
pub subject: DownloadSubject,
|
|
||||||
pub user_id: Uuid,
|
|
||||||
pub tenant_id: Uuid,
|
|
||||||
pub iss: String,
|
|
||||||
pub aud: String,
|
|
||||||
pub iat: usize,
|
|
||||||
pub exp: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct TenantSelectionClaims {
|
|
||||||
pub sub: Uuid,
|
|
||||||
pub iss: String,
|
|
||||||
pub aud: String,
|
|
||||||
pub iat: usize,
|
|
||||||
pub exp: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct SignupClaims {
|
|
||||||
pub sub: Uuid,
|
|
||||||
pub challenge_id: Uuid,
|
|
||||||
pub username: String,
|
pub username: String,
|
||||||
|
pub role: String,
|
||||||
pub iss: String,
|
pub iss: String,
|
||||||
pub aud: String,
|
pub aud: String,
|
||||||
pub iat: usize,
|
pub iat: usize,
|
||||||
|
|||||||
+11
-190
@@ -1,116 +1,30 @@
|
|||||||
pub mod api_tokens;
|
|
||||||
pub mod capability_guard;
|
|
||||||
pub mod capability_sets;
|
|
||||||
pub mod jwt;
|
pub mod jwt;
|
||||||
pub mod passkeys;
|
|
||||||
pub mod password;
|
pub mod password;
|
||||||
|
|
||||||
use std::sync::{Arc, Mutex};
|
use axum::{async_trait, extract::FromRequestParts, http::request::Parts};
|
||||||
|
|
||||||
use axum::{
|
|
||||||
extract::FromRequestParts,
|
|
||||||
http::{request::Parts, StatusCode},
|
|
||||||
};
|
|
||||||
use axum_extra::headers::{authorization::Bearer, Authorization};
|
use axum_extra::headers::{authorization::Bearer, Authorization};
|
||||||
use axum_extra::TypedHeader;
|
use axum_extra::TypedHeader;
|
||||||
use diesel::{pg::PgConnection, prelude::*};
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use utoipa::ToSchema;
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{error::AppError, state::AppState};
|
||||||
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},
|
|
||||||
};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::auth::jwt::PrincipalKind;
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct TenantMembershipUser {
|
|
||||||
pub user_id: Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FromRequestParts<AppState> for TenantMembershipUser {
|
|
||||||
type Rejection = AppError;
|
|
||||||
|
|
||||||
#[allow(refining_impl_trait)]
|
|
||||||
fn from_request_parts<'a>(
|
|
||||||
parts: &'a mut Parts,
|
|
||||||
state: &AppState,
|
|
||||||
) -> impl std::future::Future<Output = Result<Self, Self::Rejection>> + Send + 'a {
|
|
||||||
let state = state.clone();
|
|
||||||
async move {
|
|
||||||
let TypedHeader(Authorization(bearer)) =
|
|
||||||
TypedHeader::<Authorization<Bearer>>::from_request_parts(parts, &state)
|
|
||||||
.await
|
|
||||||
.map_err(|_| AppError::unauthorized())?;
|
|
||||||
|
|
||||||
if let Ok(claims) = state.jwt.verify_token(bearer.token()) {
|
|
||||||
return Ok(Self {
|
|
||||||
user_id: claims.sub,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let selector = state
|
|
||||||
.jwt
|
|
||||||
.verify_tenant_selector_token(bearer.token())
|
|
||||||
.map_err(|_| AppError::unauthorized())?;
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
user_id: selector.sub,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct TenantConnectionHolder {
|
|
||||||
inner: Arc<Mutex<Option<PgPooledConnection>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TenantConnectionHolder {
|
|
||||||
pub fn new(conn: PgPooledConnection) -> Self {
|
|
||||||
Self {
|
|
||||||
inner: Arc::new(Mutex::new(Some(conn))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn into_conn(self) -> Option<PgPooledConnection> {
|
|
||||||
self.inner.lock().ok()?.take()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
|
||||||
pub struct AuthenticatedUser {
|
pub struct AuthenticatedUser {
|
||||||
pub user_id: uuid::Uuid,
|
pub user_id: uuid::Uuid,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub tenant_id: uuid::Uuid,
|
pub role: String,
|
||||||
pub principal_kind: PrincipalKind,
|
|
||||||
pub principal_id: Uuid,
|
|
||||||
pub capability_set_id: Uuid,
|
|
||||||
pub cap_version: i32,
|
|
||||||
pub capabilities: Vec<ApiCapability>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
impl FromRequestParts<AppState> for AuthenticatedUser {
|
impl FromRequestParts<AppState> for AuthenticatedUser {
|
||||||
type Rejection = AppError;
|
type Rejection = AppError;
|
||||||
|
|
||||||
#[allow(refining_impl_trait)]
|
async fn from_request_parts(
|
||||||
fn from_request_parts<'a>(
|
parts: &mut Parts,
|
||||||
parts: &'a mut Parts,
|
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
) -> impl std::future::Future<Output = Result<Self, Self::Rejection>> + Send + 'a {
|
) -> Result<Self, Self::Rejection> {
|
||||||
let state = state.clone();
|
|
||||||
async move {
|
|
||||||
if let Some(user) = parts.extensions.get::<AuthenticatedUser>() {
|
|
||||||
return Ok(user.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
let TypedHeader(Authorization(bearer)) =
|
let TypedHeader(Authorization(bearer)) =
|
||||||
TypedHeader::<Authorization<Bearer>>::from_request_parts(parts, &state)
|
TypedHeader::<Authorization<Bearer>>::from_request_parts(parts, state)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::unauthorized())?;
|
.map_err(|_| AppError::unauthorized())?;
|
||||||
|
|
||||||
@@ -119,103 +33,10 @@ 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)?;
|
Ok(AuthenticatedUser {
|
||||||
let capability_set = load_capability_set(&mut tenant_conn, claims.capability_set_id)
|
|
||||||
.map_err(|_| AppError::unauthorized())?;
|
|
||||||
|
|
||||||
if capability_set.cap_version != claims.cap_version {
|
|
||||||
return Err(AppError::unauthorized());
|
|
||||||
}
|
|
||||||
|
|
||||||
let capabilities = load_capabilities_for_set(&mut tenant_conn, capability_set.id)
|
|
||||||
.map_err(|_| AppError::unauthorized())?;
|
|
||||||
|
|
||||||
let user = AuthenticatedUser {
|
|
||||||
user_id: claims.sub,
|
user_id: claims.sub,
|
||||||
username: claims.username,
|
username: claims.username,
|
||||||
tenant_id: claims.tenant_id,
|
role: claims.role,
|
||||||
principal_kind: claims.principal_kind,
|
|
||||||
principal_id: claims.principal_id,
|
|
||||||
capability_set_id: claims.capability_set_id,
|
|
||||||
cap_version: claims.cap_version,
|
|
||||||
capabilities,
|
|
||||||
};
|
|
||||||
|
|
||||||
parts.extensions.insert(user.clone());
|
|
||||||
parts
|
|
||||||
.extensions
|
|
||||||
.insert(TenantConnectionHolder::new(tenant_conn));
|
|
||||||
|
|
||||||
Ok(user)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct TenantScopedConn {
|
|
||||||
pub conn: PgPooledConnection,
|
|
||||||
pub tenant_id: Uuid,
|
|
||||||
pub user_id: Uuid,
|
|
||||||
pub user: AuthenticatedUser,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TenantScopedConn {
|
|
||||||
pub fn conn(&mut self) -> &mut PgPooledConnection {
|
|
||||||
&mut self.conn
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FromRequestParts<AppState> for TenantScopedConn {
|
|
||||||
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 user = AuthenticatedUser::from_request_parts(parts, &state).await?;
|
|
||||||
let tenant_id = user.tenant_id;
|
|
||||||
let mut conn = if let Some(holder) = parts.extensions.remove::<TenantConnectionHolder>()
|
|
||||||
{
|
|
||||||
holder
|
|
||||||
.into_conn()
|
|
||||||
.ok_or_else(|| AppError::internal("tenant connection unavailable"))?
|
|
||||||
} else {
|
|
||||||
state.db_for_tenant(tenant_id)?
|
|
||||||
};
|
|
||||||
|
|
||||||
ensure_active_tenant_with_conn(&mut conn, tenant_id)?;
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
conn,
|
|
||||||
tenant_id,
|
|
||||||
user_id: user.user_id,
|
|
||||||
user,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn ensure_active_tenant(state: &AppState, tenant_id: Uuid) -> AppResult<()> {
|
|
||||||
let mut conn = state.db_unscoped()?;
|
|
||||||
ensure_active_tenant_with_conn(&mut conn, tenant_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn ensure_active_tenant_with_conn(
|
|
||||||
conn: &mut PgConnection,
|
|
||||||
tenant_id: Uuid,
|
|
||||||
) -> AppResult<()> {
|
|
||||||
use tenant_dsl::tenants;
|
|
||||||
|
|
||||||
let status: TenantStatus = tenants
|
|
||||||
.find(tenant_id)
|
|
||||||
.select(tenant_dsl::status)
|
|
||||||
.first(conn)?;
|
|
||||||
|
|
||||||
if status != TenantStatus::Active {
|
|
||||||
return Err(AppError::new(StatusCode::FORBIDDEN, "tenant is not active"));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,592 +0,0 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
|
||||||
use chrono::{Duration as ChronoDuration, NaiveDateTime, Utc};
|
|
||||||
use diesel::{dsl::count_star, prelude::*};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use utoipa::ToSchema;
|
|
||||||
use uuid::Uuid;
|
|
||||||
use webauthn_rs::prelude::{Credential, *};
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
config::AppConfig,
|
|
||||||
error::{AppError, AppResult},
|
|
||||||
models::{NewUserPasskey, NewWebauthnChallenge, User, UserPasskey, WebauthnChallenge},
|
|
||||||
schema::{user_passkeys::dsl as passkey_dsl, webauthn_challenges::dsl as challenge_dsl},
|
|
||||||
};
|
|
||||||
|
|
||||||
const PURPOSE_REGISTRATION: &str = "registration";
|
|
||||||
const PURPOSE_AUTHENTICATION: &str = "authentication";
|
|
||||||
const DEFAULT_CHALLENGE_TTL_MINUTES: i64 = 10;
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct PasskeyService {
|
|
||||||
webauthn: Arc<Webauthn>,
|
|
||||||
challenge_ttl: ChronoDuration,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct PreparedPasskey {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub credential_id: Vec<u8>,
|
|
||||||
pub public_key: Vec<u8>,
|
|
||||||
pub credential: serde_json::Value,
|
|
||||||
pub sign_count: i64,
|
|
||||||
pub transports: Vec<Option<String>>,
|
|
||||||
pub aaguid: Option<Uuid>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PreparedPasskey {
|
|
||||||
pub fn into_new_user_passkey(self, user_id: Uuid, nickname: Option<String>) -> NewUserPasskey {
|
|
||||||
NewUserPasskey {
|
|
||||||
id: self.id,
|
|
||||||
user_id,
|
|
||||||
credential_id: self.credential_id,
|
|
||||||
public_key: self.public_key,
|
|
||||||
credential: self.credential,
|
|
||||||
sign_count: self.sign_count,
|
|
||||||
transports: self.transports,
|
|
||||||
aaguid: self.aaguid,
|
|
||||||
nickname,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct RegistrationChallengeResponse {
|
|
||||||
pub challenge_id: Uuid,
|
|
||||||
#[serde(flatten)]
|
|
||||||
#[schema(value_type = Object)]
|
|
||||||
pub challenge: CreationChallengeResponse,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct AuthenticationChallengeResponse {
|
|
||||||
pub challenge_id: Uuid,
|
|
||||||
#[serde(flatten)]
|
|
||||||
#[schema(value_type = Object)]
|
|
||||||
pub challenge: RequestChallengeResponse,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct PasskeySummary {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub nickname: Option<String>,
|
|
||||||
pub created_at: NaiveDateTime,
|
|
||||||
pub last_used_at: Option<NaiveDateTime>,
|
|
||||||
pub transports: Vec<String>,
|
|
||||||
pub revoked_at: Option<NaiveDateTime>,
|
|
||||||
pub revoked_reason: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PasskeyService {
|
|
||||||
pub fn try_new(config: &AppConfig) -> Result<Option<Self>> {
|
|
||||||
let rp_id = match config.webauthn_rp_id.as_deref().map(str::trim) {
|
|
||||||
Some(rp_id) if !rp_id.is_empty() => rp_id,
|
|
||||||
_ => return Ok(None),
|
|
||||||
};
|
|
||||||
let rp_origin = match config.webauthn_origin.as_ref().map(|s| s.trim()) {
|
|
||||||
Some(origin) if !origin.is_empty() => origin,
|
|
||||||
_ => return Ok(None),
|
|
||||||
};
|
|
||||||
|
|
||||||
let origin = Url::parse(rp_origin).context("invalid webauthn_origin")?;
|
|
||||||
|
|
||||||
let builder = WebauthnBuilder::new(rp_id, &origin)
|
|
||||||
.context("failed to initialise WebAuthn builder")?
|
|
||||||
.rp_name(&config.webauthn_rp_name)
|
|
||||||
.allow_subdomains(false)
|
|
||||||
.allow_any_port(false);
|
|
||||||
|
|
||||||
let webauthn = builder
|
|
||||||
.build()
|
|
||||||
.context("failed to build WebAuthn instance")?;
|
|
||||||
|
|
||||||
Ok(Some(Self {
|
|
||||||
webauthn: Arc::new(webauthn),
|
|
||||||
challenge_ttl: ChronoDuration::minutes(DEFAULT_CHALLENGE_TTL_MINUTES),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn prune_expired(&self, conn: &mut PgConnection) {
|
|
||||||
let now = Utc::now().naive_utc();
|
|
||||||
let _ = diesel::delete(
|
|
||||||
challenge_dsl::webauthn_challenges.filter(challenge_dsl::expires_at.le(now)),
|
|
||||||
)
|
|
||||||
.execute(conn);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn begin_registration(
|
|
||||||
&self,
|
|
||||||
conn: &mut PgConnection,
|
|
||||||
user_id: Uuid,
|
|
||||||
username: &str,
|
|
||||||
challenge_user_id: Option<Uuid>,
|
|
||||||
exclude: Option<Vec<CredentialID>>,
|
|
||||||
) -> AppResult<RegistrationChallengeResponse> {
|
|
||||||
self.prune_expired(conn);
|
|
||||||
|
|
||||||
let (challenge, state) = self
|
|
||||||
.webauthn
|
|
||||||
.start_passkey_registration(user_id, username, username, exclude)
|
|
||||||
.map_err(|err| {
|
|
||||||
tracing::error!(error = %err, "failed to start passkey registration");
|
|
||||||
AppError::internal("failed to start passkey registration")
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let challenge_id = Uuid::new_v4();
|
|
||||||
let expires_at = (Utc::now() + self.challenge_ttl).naive_utc();
|
|
||||||
let challenge_bytes: Vec<u8> = challenge.public_key.challenge.clone().into();
|
|
||||||
let state_bytes = serde_json::to_vec(&state)
|
|
||||||
.context("failed to encode passkey registration state")
|
|
||||||
.map_err(AppError::internal)?;
|
|
||||||
|
|
||||||
let record = NewWebauthnChallenge {
|
|
||||||
id: challenge_id,
|
|
||||||
user_id: challenge_user_id,
|
|
||||||
purpose: PURPOSE_REGISTRATION.to_string(),
|
|
||||||
challenge: challenge_bytes,
|
|
||||||
state: state_bytes,
|
|
||||||
expires_at,
|
|
||||||
};
|
|
||||||
|
|
||||||
diesel::insert_into(challenge_dsl::webauthn_challenges)
|
|
||||||
.values(&record)
|
|
||||||
.execute(conn)?;
|
|
||||||
|
|
||||||
Ok(RegistrationChallengeResponse {
|
|
||||||
challenge_id,
|
|
||||||
challenge,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn start_registration(
|
|
||||||
&self,
|
|
||||||
conn: &mut PgConnection,
|
|
||||||
user: &User,
|
|
||||||
) -> AppResult<RegistrationChallengeResponse> {
|
|
||||||
let existing: Vec<UserPasskey> = passkey_dsl::user_passkeys
|
|
||||||
.filter(passkey_dsl::user_id.eq(user.id))
|
|
||||||
.filter(passkey_dsl::revoked_at.is_null())
|
|
||||||
.load(conn)?;
|
|
||||||
|
|
||||||
let exclude = if existing.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(
|
|
||||||
existing
|
|
||||||
.iter()
|
|
||||||
.map(|pk| CredentialID::from(pk.credential_id.clone()))
|
|
||||||
.collect(),
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
self.begin_registration(conn, user.id, &user.username, Some(user.id), exclude)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn start_signup_registration(
|
|
||||||
&self,
|
|
||||||
conn: &mut PgConnection,
|
|
||||||
user_id: Uuid,
|
|
||||||
username: &str,
|
|
||||||
) -> AppResult<RegistrationChallengeResponse> {
|
|
||||||
self.begin_registration(conn, user_id, username, None, None)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn complete_registration(
|
|
||||||
&self,
|
|
||||||
conn: &mut PgConnection,
|
|
||||||
challenge_id: Uuid,
|
|
||||||
credential: &RegisterPublicKeyCredential,
|
|
||||||
expected_user: Option<Uuid>,
|
|
||||||
) -> AppResult<PreparedPasskey> {
|
|
||||||
let record: WebauthnChallenge = challenge_dsl::webauthn_challenges
|
|
||||||
.find(challenge_id)
|
|
||||||
.first(conn)
|
|
||||||
.map_err(|err| {
|
|
||||||
if matches!(err, diesel::result::Error::NotFound) {
|
|
||||||
AppError::bad_request("challenge not found")
|
|
||||||
} else {
|
|
||||||
AppError::from(err)
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if record.purpose != PURPOSE_REGISTRATION {
|
|
||||||
return Err(AppError::bad_request("challenge is not for registration"));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(expected) = expected_user {
|
|
||||||
if record.user_id != Some(expected) {
|
|
||||||
return Err(AppError::unauthorized());
|
|
||||||
}
|
|
||||||
} else if record.user_id.is_some() {
|
|
||||||
return Err(AppError::bad_request(
|
|
||||||
"unexpected user context for signup registration",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
if record.expires_at < Utc::now().naive_utc() {
|
|
||||||
diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?;
|
|
||||||
return Err(AppError::bad_request("challenge expired"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let state: PasskeyRegistration = serde_json::from_slice(&record.state)
|
|
||||||
.context("failed to decode registration state")
|
|
||||||
.map_err(AppError::internal)?;
|
|
||||||
|
|
||||||
let passkey = self
|
|
||||||
.webauthn
|
|
||||||
.finish_passkey_registration(credential, &state)
|
|
||||||
.map_err(|err| {
|
|
||||||
tracing::warn!(error = %err, "passkey registration validation failed");
|
|
||||||
AppError::bad_request("invalid passkey attestation")
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let credential_struct: Credential = passkey.clone().into();
|
|
||||||
let credential_id_vec: Vec<u8> = credential_struct.cred_id.clone().into();
|
|
||||||
|
|
||||||
let duplicate = passkey_dsl::user_passkeys
|
|
||||||
.filter(passkey_dsl::credential_id.eq(&credential_id_vec))
|
|
||||||
.first::<UserPasskey>(conn)
|
|
||||||
.optional()?;
|
|
||||||
if duplicate.is_some() {
|
|
||||||
diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?;
|
|
||||||
return Err(AppError::conflict("credential already registered"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let public_key_bytes = serde_cbor_2::to_vec(&credential_struct.cred)
|
|
||||||
.context("failed to encode credential public key")
|
|
||||||
.map_err(AppError::internal)?;
|
|
||||||
|
|
||||||
let transports: Vec<Option<String>> = credential_struct
|
|
||||||
.transports
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_default()
|
|
||||||
.into_iter()
|
|
||||||
.map(|transport| Some(transport.as_ref().to_string()))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let aaguid = match credential_struct.attestation.metadata {
|
|
||||||
AttestationMetadata::Packed { aaguid } | AttestationMetadata::Tpm { aaguid, .. } => {
|
|
||||||
Some(aaguid)
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let credential_json = serde_json::to_value(&passkey)
|
|
||||||
.context("failed to serialise passkey")
|
|
||||||
.map_err(AppError::internal)?;
|
|
||||||
|
|
||||||
diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?;
|
|
||||||
|
|
||||||
Ok(PreparedPasskey {
|
|
||||||
id: Uuid::new_v4(),
|
|
||||||
credential_id: credential_id_vec,
|
|
||||||
public_key: public_key_bytes,
|
|
||||||
credential: credential_json,
|
|
||||||
sign_count: credential_struct.counter as i64,
|
|
||||||
transports,
|
|
||||||
aaguid,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn finish_registration(
|
|
||||||
&self,
|
|
||||||
conn: &mut PgConnection,
|
|
||||||
user: &User,
|
|
||||||
challenge_id: Uuid,
|
|
||||||
credential: RegisterPublicKeyCredential,
|
|
||||||
nickname: Option<String>,
|
|
||||||
) -> AppResult<UserPasskey> {
|
|
||||||
let prepared =
|
|
||||||
self.complete_registration(conn, challenge_id, &credential, Some(user.id))?;
|
|
||||||
|
|
||||||
let new_passkey = prepared.into_new_user_passkey(user.id, nickname);
|
|
||||||
|
|
||||||
diesel::insert_into(passkey_dsl::user_passkeys)
|
|
||||||
.values(&new_passkey)
|
|
||||||
.execute(conn)?;
|
|
||||||
|
|
||||||
let created: UserPasskey = passkey_dsl::user_passkeys
|
|
||||||
.find(new_passkey.id)
|
|
||||||
.select(UserPasskey::as_select())
|
|
||||||
.first(conn)?;
|
|
||||||
|
|
||||||
Ok(created)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn start_authentication(
|
|
||||||
&self,
|
|
||||||
conn: &mut PgConnection,
|
|
||||||
user: &User,
|
|
||||||
) -> AppResult<AuthenticationChallengeResponse> {
|
|
||||||
self.prune_expired(conn);
|
|
||||||
|
|
||||||
let stored: Vec<UserPasskey> = passkey_dsl::user_passkeys
|
|
||||||
.filter(passkey_dsl::user_id.eq(user.id))
|
|
||||||
.filter(passkey_dsl::revoked_at.is_null())
|
|
||||||
.select(UserPasskey::as_select())
|
|
||||||
.load(conn)?;
|
|
||||||
|
|
||||||
if stored.is_empty() {
|
|
||||||
return Err(AppError::bad_request("no passkeys registered"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut passkeys = Vec::with_capacity(stored.len());
|
|
||||||
for pk in &stored {
|
|
||||||
let passkey: Passkey = serde_json::from_value(pk.credential.clone())
|
|
||||||
.context("failed to parse stored passkey")
|
|
||||||
.map_err(AppError::internal)?;
|
|
||||||
passkeys.push(passkey);
|
|
||||||
}
|
|
||||||
|
|
||||||
let (challenge, state) = self
|
|
||||||
.webauthn
|
|
||||||
.start_passkey_authentication(&passkeys)
|
|
||||||
.map_err(|err| {
|
|
||||||
tracing::error!(error = %err, "failed to start passkey authentication");
|
|
||||||
AppError::internal("failed to start passkey authentication")
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let challenge_id = Uuid::new_v4();
|
|
||||||
let expires_at = (Utc::now() + self.challenge_ttl).naive_utc();
|
|
||||||
let challenge_bytes: Vec<u8> = challenge.public_key.challenge.clone().into();
|
|
||||||
let state_bytes = serde_json::to_vec(&state)
|
|
||||||
.context("failed to encode authentication state")
|
|
||||||
.map_err(AppError::internal)?;
|
|
||||||
|
|
||||||
let record = NewWebauthnChallenge {
|
|
||||||
id: challenge_id,
|
|
||||||
user_id: Some(user.id),
|
|
||||||
purpose: PURPOSE_AUTHENTICATION.to_string(),
|
|
||||||
challenge: challenge_bytes,
|
|
||||||
state: state_bytes,
|
|
||||||
expires_at,
|
|
||||||
};
|
|
||||||
|
|
||||||
diesel::insert_into(challenge_dsl::webauthn_challenges)
|
|
||||||
.values(&record)
|
|
||||||
.execute(conn)?;
|
|
||||||
|
|
||||||
Ok(AuthenticationChallengeResponse {
|
|
||||||
challenge_id,
|
|
||||||
challenge,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn list_for_user(
|
|
||||||
&self,
|
|
||||||
conn: &mut PgConnection,
|
|
||||||
user_id: Uuid,
|
|
||||||
) -> AppResult<Vec<PasskeySummary>> {
|
|
||||||
let passkeys: Vec<UserPasskey> = passkey_dsl::user_passkeys
|
|
||||||
.filter(passkey_dsl::user_id.eq(user_id))
|
|
||||||
.order(passkey_dsl::created_at.asc())
|
|
||||||
.select(UserPasskey::as_select())
|
|
||||||
.load(conn)?;
|
|
||||||
|
|
||||||
Ok(passkeys.into_iter().map(PasskeySummary::from).collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn active_passkey_count(&self, conn: &mut PgConnection, user_id: Uuid) -> AppResult<i64> {
|
|
||||||
let count: i64 = passkey_dsl::user_passkeys
|
|
||||||
.filter(passkey_dsl::user_id.eq(user_id))
|
|
||||||
.filter(passkey_dsl::revoked_at.is_null())
|
|
||||||
.select(count_star())
|
|
||||||
.first(conn)?;
|
|
||||||
Ok(count)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn consume_signup_challenge(
|
|
||||||
&self,
|
|
||||||
conn: &mut PgConnection,
|
|
||||||
challenge_id: Uuid,
|
|
||||||
credential: &RegisterPublicKeyCredential,
|
|
||||||
) -> AppResult<PreparedPasskey> {
|
|
||||||
self.complete_registration(conn, challenge_id, credential, None)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn revoke_passkey(
|
|
||||||
&self,
|
|
||||||
conn: &mut PgConnection,
|
|
||||||
user_id: Uuid,
|
|
||||||
passkey_id: Uuid,
|
|
||||||
reason: Option<String>,
|
|
||||||
) -> AppResult<()> {
|
|
||||||
let now = Utc::now().naive_utc();
|
|
||||||
let updated = diesel::update(
|
|
||||||
passkey_dsl::user_passkeys
|
|
||||||
.filter(passkey_dsl::id.eq(passkey_id))
|
|
||||||
.filter(passkey_dsl::user_id.eq(user_id))
|
|
||||||
.filter(passkey_dsl::revoked_at.is_null()),
|
|
||||||
)
|
|
||||||
.set((
|
|
||||||
passkey_dsl::revoked_at.eq(Some(now)),
|
|
||||||
passkey_dsl::revoked_reason.eq(reason),
|
|
||||||
passkey_dsl::updated_at.eq(now),
|
|
||||||
))
|
|
||||||
.execute(conn)?;
|
|
||||||
|
|
||||||
if updated == 0 {
|
|
||||||
return Err(AppError::not_found());
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn finish_authentication(
|
|
||||||
&self,
|
|
||||||
conn: &mut PgConnection,
|
|
||||||
challenge_id: Uuid,
|
|
||||||
credential: PublicKeyCredential,
|
|
||||||
) -> AppResult<(User, UserPasskey, AuthenticationResult)> {
|
|
||||||
let record: WebauthnChallenge = challenge_dsl::webauthn_challenges
|
|
||||||
.find(challenge_id)
|
|
||||||
.first(conn)
|
|
||||||
.map_err(|err| {
|
|
||||||
if matches!(err, diesel::result::Error::NotFound) {
|
|
||||||
AppError::bad_request("challenge not found")
|
|
||||||
} else {
|
|
||||||
AppError::from(err)
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if record.purpose != PURPOSE_AUTHENTICATION {
|
|
||||||
return Err(AppError::bad_request("challenge is not for authentication"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let user_id = record
|
|
||||||
.user_id
|
|
||||||
.ok_or_else(|| AppError::bad_request("challenge missing user context"))?;
|
|
||||||
|
|
||||||
if record.expires_at < Utc::now().naive_utc() {
|
|
||||||
diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?;
|
|
||||||
return Err(AppError::bad_request("challenge expired"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let state: PasskeyAuthentication = serde_json::from_slice(&record.state)
|
|
||||||
.context("failed to decode authentication state")
|
|
||||||
.map_err(AppError::internal)?;
|
|
||||||
|
|
||||||
let auth_result = self
|
|
||||||
.webauthn
|
|
||||||
.finish_passkey_authentication(&credential, &state)
|
|
||||||
.map_err(|err| {
|
|
||||||
tracing::warn!(error = %err, "passkey authentication failed");
|
|
||||||
AppError::unauthorized()
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let credential_id_vec: Vec<u8> = auth_result.cred_id().clone().into();
|
|
||||||
|
|
||||||
let mut passkey: UserPasskey = passkey_dsl::user_passkeys
|
|
||||||
.filter(passkey_dsl::user_id.eq(user_id))
|
|
||||||
.filter(passkey_dsl::credential_id.eq(&credential_id_vec))
|
|
||||||
.filter(passkey_dsl::revoked_at.is_null())
|
|
||||||
.select(UserPasskey::as_select())
|
|
||||||
.first(conn)
|
|
||||||
.map_err(|err| {
|
|
||||||
if matches!(err, diesel::result::Error::NotFound) {
|
|
||||||
AppError::unauthorized()
|
|
||||||
} else {
|
|
||||||
AppError::from(err)
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let mut passkey_model: Passkey = serde_json::from_value(passkey.credential.clone())
|
|
||||||
.context("failed to parse stored passkey")
|
|
||||||
.map_err(AppError::internal)?;
|
|
||||||
|
|
||||||
if auth_result.needs_update() {
|
|
||||||
let _ = passkey_model.update_credential(&auth_result);
|
|
||||||
}
|
|
||||||
|
|
||||||
let credential_struct: Credential = passkey_model.clone().into();
|
|
||||||
let public_key_bytes = serde_cbor_2::to_vec(&credential_struct.cred)
|
|
||||||
.context("failed to encode credential public key")
|
|
||||||
.map_err(AppError::internal)?;
|
|
||||||
|
|
||||||
let transports: Vec<Option<String>> = credential_struct
|
|
||||||
.transports
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_default()
|
|
||||||
.into_iter()
|
|
||||||
.map(|transport| Some(transport.as_ref().to_string()))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let credential_json = serde_json::to_value(&passkey_model)
|
|
||||||
.context("failed to serialise passkey")
|
|
||||||
.map_err(AppError::internal)?;
|
|
||||||
|
|
||||||
let now = Utc::now().naive_utc();
|
|
||||||
diesel::update(passkey_dsl::user_passkeys.find(passkey.id))
|
|
||||||
.set((
|
|
||||||
passkey_dsl::sign_count.eq(auth_result.counter() as i64),
|
|
||||||
passkey_dsl::transports.eq(&transports),
|
|
||||||
passkey_dsl::credential.eq(credential_json.clone()),
|
|
||||||
passkey_dsl::public_key.eq(public_key_bytes),
|
|
||||||
passkey_dsl::last_used_at.eq(Some(now)),
|
|
||||||
passkey_dsl::updated_at.eq(now),
|
|
||||||
))
|
|
||||||
.execute(conn)?;
|
|
||||||
|
|
||||||
passkey.sign_count = auth_result.counter() as i64;
|
|
||||||
passkey.transports = transports;
|
|
||||||
passkey.credential = credential_json;
|
|
||||||
passkey.last_used_at = Some(now);
|
|
||||||
passkey.updated_at = now;
|
|
||||||
|
|
||||||
diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?;
|
|
||||||
|
|
||||||
let user = crate::schema::users::table
|
|
||||||
.find(user_id)
|
|
||||||
.first::<User>(conn)?;
|
|
||||||
|
|
||||||
Ok((user, passkey, auth_result))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<UserPasskey> for PasskeySummary {
|
|
||||||
fn from(passkey: UserPasskey) -> Self {
|
|
||||||
let transports = passkey
|
|
||||||
.transports
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|value| value)
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Self {
|
|
||||||
id: passkey.id,
|
|
||||||
nickname: passkey.nickname,
|
|
||||||
created_at: passkey.created_at,
|
|
||||||
last_used_at: passkey.last_used_at,
|
|
||||||
transports,
|
|
||||||
revoked_at: passkey.revoked_at,
|
|
||||||
revoked_reason: passkey.revoked_reason,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct PasskeyRegistrationFinishPayload {
|
|
||||||
pub challenge_id: Uuid,
|
|
||||||
#[schema(value_type = Object)]
|
|
||||||
pub credential: RegisterPublicKeyCredential,
|
|
||||||
#[serde(default)]
|
|
||||||
pub nickname: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct PasskeyLoginStartPayload {
|
|
||||||
pub username: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct PasskeyLoginFinishPayload {
|
|
||||||
pub challenge_id: Uuid,
|
|
||||||
#[schema(value_type = Object)]
|
|
||||||
pub credential: PublicKeyCredential,
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
use argon2::{
|
use argon2::{
|
||||||
password_hash::{
|
password_hash::{PasswordHash, PasswordVerifier},
|
||||||
rand_core::OsRng as PasswordHashOsRng, PasswordHash, PasswordHasher, PasswordVerifier,
|
|
||||||
SaltString,
|
|
||||||
},
|
|
||||||
Argon2,
|
Argon2,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -13,12 +10,3 @@ pub fn verify_password(password: &str, password_hash: &str) -> Result<bool> {
|
|||||||
.verify_password(password.as_bytes(), &parsed_hash)
|
.verify_password(password.as_bytes(), &parsed_hash)
|
||||||
.is_ok())
|
.is_ok())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn hash_password(password: &str) -> Result<String> {
|
|
||||||
let mut rng = PasswordHashOsRng;
|
|
||||||
let salt = SaltString::generate(&mut rng);
|
|
||||||
let hash = Argon2::default()
|
|
||||||
.hash_password(password.as_bytes(), &salt)
|
|
||||||
.map_err(|err| anyhow!(err))?;
|
|
||||||
Ok(hash.to_string())
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,888 +0,0 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use anyhow::{anyhow, bail, Context, Result};
|
|
||||||
use chrono::{Duration as ChronoDuration, Utc};
|
|
||||||
use clap::{Parser, Subcommand, ValueEnum};
|
|
||||||
use diesel::{dsl::exists, pg::PgConnection, prelude::*, select};
|
|
||||||
use diesel_migrations::MigrationHarness;
|
|
||||||
use rand::{rngs::OsRng, TryRngCore};
|
|
||||||
use reqwest::{Client, Method, StatusCode};
|
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
use tokio::task;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use papercrate::{
|
|
||||||
auth::capability_sets::{ensure_capability_set, owner_capabilities},
|
|
||||||
config::{redact_database_url, AppConfig},
|
|
||||||
db::{self, PgPool},
|
|
||||||
documents::search::ensure_quickwit_index,
|
|
||||||
jobs::{enqueue_job, JOB_ANALYZE_DOCUMENT, JOB_DELETE_TENANT},
|
|
||||||
migrations::MIGRATIONS,
|
|
||||||
models::{
|
|
||||||
DocumentAsset, MagicToken, MagicTokenKind, NewUser, NewUserMembership, Tenant,
|
|
||||||
TenantStatus, User,
|
|
||||||
},
|
|
||||||
s3,
|
|
||||||
schema::{document_assets, documents, magic_tokens, tenants, user_memberships, users},
|
|
||||||
storage::{ObjectStorage, S3Storage, TenantStorage},
|
|
||||||
tenants::{apply_tenant_guc, clear_tenant_context, TenantService},
|
|
||||||
utils::{text::normalize_identifier, tracing::init_tracing},
|
|
||||||
workers::tenants::{build_delete_proof_message, sign_delete_proof, DeleteAction},
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Parser)]
|
|
||||||
#[command(
|
|
||||||
name = "papercrate-admin",
|
|
||||||
version,
|
|
||||||
about = "Papercrate administration utility"
|
|
||||||
)]
|
|
||||||
struct Cli {
|
|
||||||
#[command(subcommand)]
|
|
||||||
command: Command,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
|
||||||
enum Command {
|
|
||||||
CreateUser {
|
|
||||||
username: String,
|
|
||||||
},
|
|
||||||
ListUsers,
|
|
||||||
DeleteUser {
|
|
||||||
username: String,
|
|
||||||
},
|
|
||||||
CreateTenant {
|
|
||||||
name: String,
|
|
||||||
#[arg(long = "storage-root")]
|
|
||||||
storage_root: Option<String>,
|
|
||||||
#[arg(long = "quickwit-index")]
|
|
||||||
quickwit_index: Option<String>,
|
|
||||||
},
|
|
||||||
DeleteTenant {
|
|
||||||
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 {
|
|
||||||
username: String,
|
|
||||||
tenant_id: Uuid,
|
|
||||||
},
|
|
||||||
RemoveUserFromTenant {
|
|
||||||
username: String,
|
|
||||||
tenant_id: Uuid,
|
|
||||||
},
|
|
||||||
ReanalyzeDocuments {
|
|
||||||
tenant_id: Uuid,
|
|
||||||
},
|
|
||||||
ListTenants,
|
|
||||||
DeleteAssets {
|
|
||||||
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 {
|
|
||||||
tenant_id: Uuid,
|
|
||||||
},
|
|
||||||
QuickwitDelete {
|
|
||||||
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 {
|
|
||||||
username: String,
|
|
||||||
#[arg(long = "ttl-minutes", default_value_t = 10)]
|
|
||||||
ttl_minutes: i64,
|
|
||||||
#[arg(
|
|
||||||
long = "max-uses",
|
|
||||||
value_name = "MAX_USES",
|
|
||||||
help = "Maximum number of uses before the token is rejected (default: unlimited)"
|
|
||||||
)]
|
|
||||||
max_uses: Option<i32>,
|
|
||||||
#[arg(long = "kind", value_enum, default_value_t = MagicTokenKindArg::EmailLogin)]
|
|
||||||
kind: MagicTokenKindArg,
|
|
||||||
},
|
|
||||||
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)]
|
|
||||||
enum MagicTokenKindArg {
|
|
||||||
#[value(name = "email_login")]
|
|
||||||
EmailLogin,
|
|
||||||
#[value(name = "demo_login")]
|
|
||||||
DemoLogin,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<MagicTokenKindArg> for MagicTokenKind {
|
|
||||||
fn from(value: MagicTokenKindArg) -> Self {
|
|
||||||
match value {
|
|
||||||
MagicTokenKindArg::EmailLogin => MagicTokenKind::EmailLogin,
|
|
||||||
MagicTokenKindArg::DemoLogin => MagicTokenKind::DemoLogin,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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]
|
|
||||||
async fn main() -> Result<()> {
|
|
||||||
init_tracing("info");
|
|
||||||
let cli = Cli::parse();
|
|
||||||
let config = AppConfig::load_and_log("admin")?;
|
|
||||||
let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?;
|
|
||||||
|
|
||||||
match cli.command {
|
|
||||||
Command::CreateUser { username } => create_user(&pool, &username)?,
|
|
||||||
Command::ListUsers => list_users(&pool)?,
|
|
||||||
Command::DeleteUser { username } => delete_user(&pool, &username)?,
|
|
||||||
Command::CreateTenant {
|
|
||||||
name,
|
|
||||||
storage_root,
|
|
||||||
quickwit_index,
|
|
||||||
} => create_tenant(&pool, &name, storage_root, quickwit_index)?,
|
|
||||||
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 {
|
|
||||||
username,
|
|
||||||
tenant_id,
|
|
||||||
} => add_user_to_tenant(&pool, &username, tenant_id)?,
|
|
||||||
Command::RemoveUserFromTenant {
|
|
||||||
username,
|
|
||||||
tenant_id,
|
|
||||||
} => remove_user_from_tenant(&pool, &username, tenant_id)?,
|
|
||||||
Command::ReanalyzeDocuments { tenant_id } => reanalyze_documents(&pool, tenant_id)?,
|
|
||||||
Command::ListTenants => list_tenants(&pool)?,
|
|
||||||
Command::DeleteAssets {
|
|
||||||
tenant_id,
|
|
||||||
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 } => {
|
|
||||||
quickwit_index(&config, &pool, tenant_id, Method::POST).await?
|
|
||||||
}
|
|
||||||
Command::QuickwitDelete { tenant_id } => {
|
|
||||||
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 {
|
|
||||||
username,
|
|
||||||
ttl_minutes,
|
|
||||||
max_uses,
|
|
||||||
kind,
|
|
||||||
} => {
|
|
||||||
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(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn migrate_database(database_url: String) -> Result<()> {
|
|
||||||
let redacted = redact_database_url(&database_url);
|
|
||||||
tracing::info!(database_url = %redacted, "running pending migrations");
|
|
||||||
|
|
||||||
let result = task::spawn_blocking(move || -> Result<()> {
|
|
||||||
let mut conn =
|
|
||||||
PgConnection::establish(&database_url).context("failed to connect to database")?;
|
|
||||||
conn.run_pending_migrations(MIGRATIONS)
|
|
||||||
.map_err(|err| anyhow!("failed to run migrations: {err}"))?;
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.context("migration task panicked")?;
|
|
||||||
|
|
||||||
result?;
|
|
||||||
tracing::info!(database_url = %redacted, "migrations completed");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn create_user(pool: &PgPool, username: &str) -> Result<()> {
|
|
||||||
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 exists: bool =
|
|
||||||
select(exists(users::table.filter(users::username.eq(&username)))).get_result(&mut conn)?;
|
|
||||||
if exists {
|
|
||||||
bail!("user '{}' already exists", username);
|
|
||||||
}
|
|
||||||
|
|
||||||
let new_user = NewUser {
|
|
||||||
id: Uuid::new_v4(),
|
|
||||||
username: username.clone(),
|
|
||||||
};
|
|
||||||
|
|
||||||
diesel::insert_into(users::table)
|
|
||||||
.values(&new_user)
|
|
||||||
.execute(&mut conn)?;
|
|
||||||
|
|
||||||
println!("created user '{}' (id: {})", username, new_user.id);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn list_users(pool: &PgPool) -> Result<()> {
|
|
||||||
let mut conn = pool.get().context("failed to get database connection")?;
|
|
||||||
|
|
||||||
let users_list: Vec<User> = users::table.order(users::username.asc()).load(&mut conn)?;
|
|
||||||
if users_list.is_empty() {
|
|
||||||
println!("No users found.");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
for user in users_list {
|
|
||||||
let memberships: Vec<String> = user_memberships::table
|
|
||||||
.inner_join(tenants::table)
|
|
||||||
.filter(user_memberships::user_id.eq(user.id))
|
|
||||||
.select(tenants::name)
|
|
||||||
.order(tenants::name.asc())
|
|
||||||
.load(&mut conn)?;
|
|
||||||
|
|
||||||
if memberships.is_empty() {
|
|
||||||
println!("{} ({})", user.username, user.id);
|
|
||||||
} else {
|
|
||||||
println!(
|
|
||||||
"{} ({}) -> {}",
|
|
||||||
user.username,
|
|
||||||
user.id,
|
|
||||||
memberships.join(", ")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn delete_user(pool: &PgPool, username: &str) -> Result<()> {
|
|
||||||
let mut conn = pool.get().context("failed to get database connection")?;
|
|
||||||
|
|
||||||
let user: User = users::table
|
|
||||||
.filter(users::username.eq(username))
|
|
||||||
.first(&mut conn)
|
|
||||||
.optional()?
|
|
||||||
.ok_or_else(|| anyhow!("user '{}' not found", username))?;
|
|
||||||
|
|
||||||
let has_memberships: bool = select(exists(
|
|
||||||
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)?;
|
|
||||||
|
|
||||||
println!("deleted user '{}'", username);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn create_tenant(
|
|
||||||
pool: &PgPool,
|
|
||||||
name: &str,
|
|
||||||
storage_root_arg: Option<String>,
|
|
||||||
quickwit_index_arg: Option<String>,
|
|
||||||
) -> Result<()> {
|
|
||||||
let service = TenantService::new(pool.clone());
|
|
||||||
let tenant = service
|
|
||||||
.create_tenant(
|
|
||||||
name,
|
|
||||||
storage_root_arg.as_deref(),
|
|
||||||
quickwit_index_arg.as_deref(),
|
|
||||||
TenantStatus::Creating,
|
|
||||||
&[],
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.map_err(|err| anyhow!(format!("{err:?}")))?;
|
|
||||||
|
|
||||||
let storage_root = tenant.storage_root.as_deref().unwrap_or("<none>");
|
|
||||||
let quickwit_index = tenant.quickwit_index.as_deref().unwrap_or("<none>");
|
|
||||||
|
|
||||||
println!(
|
|
||||||
"created tenant '{}' with id {}, storage_root '{}', quickwit_index '{}', status '{}'",
|
|
||||||
tenant.name,
|
|
||||||
tenant.id,
|
|
||||||
storage_root,
|
|
||||||
quickwit_index,
|
|
||||||
tenant.status.as_str()
|
|
||||||
);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn create_magic_token(
|
|
||||||
pool: &PgPool,
|
|
||||||
username: &str,
|
|
||||||
ttl_minutes: i64,
|
|
||||||
max_uses: Option<i32>,
|
|
||||||
kind: MagicTokenKind,
|
|
||||||
) -> Result<()> {
|
|
||||||
let mut conn = pool.get().context("failed to get database connection")?;
|
|
||||||
|
|
||||||
let user: User = users::table
|
|
||||||
.filter(users::username.eq(username))
|
|
||||||
.first(&mut conn)
|
|
||||||
.with_context(|| format!("user '{}' not found", username))?;
|
|
||||||
|
|
||||||
let raw_token = generate_random_token();
|
|
||||||
let token_hash = hash_token(&raw_token);
|
|
||||||
let expires_at = Utc::now() + ChronoDuration::minutes(ttl_minutes);
|
|
||||||
|
|
||||||
let new_token = MagicToken {
|
|
||||||
id: Uuid::new_v4(),
|
|
||||||
user_id: user.id,
|
|
||||||
kind,
|
|
||||||
token_hash,
|
|
||||||
metadata: serde_json::json!({}),
|
|
||||||
expires_at: expires_at.naive_utc(),
|
|
||||||
max_uses,
|
|
||||||
used_count: 0,
|
|
||||||
created_at: Utc::now().naive_utc(),
|
|
||||||
created_by: None,
|
|
||||||
last_used_at: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
diesel::insert_into(magic_tokens::table)
|
|
||||||
.values(&new_token)
|
|
||||||
.execute(&mut conn)?;
|
|
||||||
|
|
||||||
println!(
|
|
||||||
"Magic token created for '{}' (kind: {}, expires_at: {}, max_uses: {})",
|
|
||||||
username,
|
|
||||||
kind.as_str(),
|
|
||||||
expires_at,
|
|
||||||
max_uses.map_or("∞".to_string(), |v| v.to_string())
|
|
||||||
);
|
|
||||||
println!("Token: {}", raw_token);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn generate_random_token() -> String {
|
|
||||||
let mut bytes = [0u8; 32];
|
|
||||||
OsRng
|
|
||||||
.try_fill_bytes(&mut bytes)
|
|
||||||
.expect("failed to read random bytes");
|
|
||||||
hex::encode(bytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn hash_token(token: &str) -> String {
|
|
||||||
let mut hasher = Sha256::new();
|
|
||||||
hasher.update(token.as_bytes());
|
|
||||||
hex::encode(hasher.finalize())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn delete_tenant(
|
|
||||||
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 tenant: Tenant = tenants::table
|
|
||||||
.find(tenant_id)
|
|
||||||
.first(&mut conn)
|
|
||||||
.optional()?
|
|
||||||
.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))?;
|
|
||||||
|
|
||||||
if tenant.name != expected_name {
|
|
||||||
bail!(
|
|
||||||
"tenant name mismatch: expected '{}', database has '{}'",
|
|
||||||
expected_name,
|
|
||||||
tenant.name
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
diesel::update(tenants::table.find(tenant.id))
|
|
||||||
.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(())
|
|
||||||
}
|
|
||||||
|
|
||||||
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<()> {
|
|
||||||
let mut conn = pool.get().context("failed to get database connection")?;
|
|
||||||
|
|
||||||
let user: User = users::table
|
|
||||||
.filter(users::username.eq(username))
|
|
||||||
.first(&mut conn)
|
|
||||||
.optional()?
|
|
||||||
.ok_or_else(|| anyhow!("user '{}' not found", username))?;
|
|
||||||
|
|
||||||
let tenant: Tenant = tenants::table
|
|
||||||
.find(tenant_id)
|
|
||||||
.first(&mut conn)
|
|
||||||
.optional()?
|
|
||||||
.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 {
|
|
||||||
id: Uuid::new_v4(),
|
|
||||||
user_id: user.id,
|
|
||||||
tenant_id: tenant.id,
|
|
||||||
capability_set_id: Some(owner_capability_set_id),
|
|
||||||
};
|
|
||||||
|
|
||||||
diesel::insert_into(user_memberships::table)
|
|
||||||
.values(&membership)
|
|
||||||
.on_conflict((user_memberships::user_id, user_memberships::tenant_id))
|
|
||||||
.do_nothing()
|
|
||||||
.execute(&mut conn)?;
|
|
||||||
|
|
||||||
println!("added user '{}' to tenant '{}'", username, tenant.name);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn remove_user_from_tenant(pool: &PgPool, username: &str, tenant_id: Uuid) -> Result<()> {
|
|
||||||
let mut conn = pool.get().context("failed to get database connection")?;
|
|
||||||
|
|
||||||
let user: User = users::table
|
|
||||||
.filter(users::username.eq(username))
|
|
||||||
.first(&mut conn)
|
|
||||||
.optional()?
|
|
||||||
.ok_or_else(|| anyhow!("user '{}' not found", username))?;
|
|
||||||
|
|
||||||
let tenant: Tenant = tenants::table
|
|
||||||
.find(tenant_id)
|
|
||||||
.first(&mut conn)
|
|
||||||
.optional()?
|
|
||||||
.ok_or_else(|| anyhow!("tenant '{}' not found", tenant_id))?;
|
|
||||||
|
|
||||||
let removed = diesel::delete(
|
|
||||||
user_memberships::table
|
|
||||||
.filter(user_memberships::user_id.eq(user.id))
|
|
||||||
.filter(user_memberships::tenant_id.eq(tenant.id)),
|
|
||||||
)
|
|
||||||
.execute(&mut conn)?;
|
|
||||||
|
|
||||||
if removed == 0 {
|
|
||||||
println!(
|
|
||||||
"user '{}' was not a member of tenant '{}'",
|
|
||||||
username, tenant.name
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
println!("removed user '{}' from tenant '{}'", username, tenant.name);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn reanalyze_documents(pool: &PgPool, tenant_id: Uuid) -> Result<()> {
|
|
||||||
let mut conn = pool.get().context("failed to get database connection")?;
|
|
||||||
|
|
||||||
let tenant: Tenant = tenants::table
|
|
||||||
.find(tenant_id)
|
|
||||||
.first(&mut conn)
|
|
||||||
.optional()?
|
|
||||||
.ok_or_else(|| anyhow!("tenant '{}' not found", tenant_id))?;
|
|
||||||
|
|
||||||
let targets: Vec<(Uuid, Uuid)> = documents::table
|
|
||||||
.filter(documents::tenant_id.eq(tenant.id))
|
|
||||||
.filter(documents::deleted_at.is_null())
|
|
||||||
.select((documents::id, documents::current_version_id))
|
|
||||||
.load(&mut conn)?;
|
|
||||||
|
|
||||||
if targets.is_empty() {
|
|
||||||
println!("tenant '{}' has no active documents", tenant.name);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut queued = 0usize;
|
|
||||||
for (document_id, version_id) in targets {
|
|
||||||
enqueue_job(
|
|
||||||
&mut conn,
|
|
||||||
tenant.id,
|
|
||||||
JOB_ANALYZE_DOCUMENT,
|
|
||||||
serde_json::json!({
|
|
||||||
"document_id": document_id,
|
|
||||||
"document_version_id": version_id,
|
|
||||||
"force": true,
|
|
||||||
}),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.map_err(|err| anyhow!("failed to enqueue analyze job: {}", err))?;
|
|
||||||
queued += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
println!(
|
|
||||||
"queued {} documents for re-analysis in tenant '{}'",
|
|
||||||
queued, tenant.name
|
|
||||||
);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn list_tenants(pool: &PgPool) -> Result<()> {
|
|
||||||
let mut conn = pool.get().context("failed to get database connection")?;
|
|
||||||
let tenants: Vec<Tenant> = tenants::table
|
|
||||||
.order(tenants::name.asc())
|
|
||||||
.load(&mut conn)
|
|
||||||
.context("failed to load tenants")?;
|
|
||||||
|
|
||||||
if tenants.is_empty() {
|
|
||||||
println!("No tenants found.");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
for tenant in tenants {
|
|
||||||
println!("{} {}", tenant.id, tenant.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn delete_assets_for_tenant(
|
|
||||||
config: &AppConfig,
|
|
||||||
pool: &PgPool,
|
|
||||||
tenant_id: Uuid,
|
|
||||||
asset_type: Option<&str>,
|
|
||||||
) -> Result<()> {
|
|
||||||
let bucket = s3::build_bucket(config)?;
|
|
||||||
let storage: Arc<dyn ObjectStorage> = Arc::new(S3Storage::new(bucket));
|
|
||||||
|
|
||||||
let mut conn = pool.get().context("failed to get database connection")?;
|
|
||||||
let tenant: Tenant = tenants::table
|
|
||||||
.find(tenant_id)
|
|
||||||
.first(&mut conn)
|
|
||||||
.optional()?
|
|
||||||
.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)
|
|
||||||
.with_context(|| format!("missing storage root for tenant {}", tenant.name))?;
|
|
||||||
|
|
||||||
let mut asset_query = document_assets::table
|
|
||||||
.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)
|
|
||||||
.with_context(|| format!("failed to load assets for tenant {}", tenant.name))?;
|
|
||||||
|
|
||||||
if assets.is_empty() {
|
|
||||||
match asset_type {
|
|
||||||
Some(asset_type) => {
|
|
||||||
println!("Tenant {}: no assets of type '{}'", tenant.name, asset_type)
|
|
||||||
}
|
|
||||||
None => println!("Tenant {}: no assets", tenant.name),
|
|
||||||
}
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
match asset_type {
|
|
||||||
Some(asset_type) => println!(
|
|
||||||
"Tenant {} ({}): deleting {} '{}' assets…",
|
|
||||||
tenant.name,
|
|
||||||
tenant.id,
|
|
||||||
assets.len(),
|
|
||||||
asset_type
|
|
||||||
),
|
|
||||||
None => println!(
|
|
||||||
"Tenant {} ({}): deleting {} assets…",
|
|
||||||
tenant.name,
|
|
||||||
tenant.id,
|
|
||||||
assets.len()
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
let asset_ids: Vec<Uuid> = assets.iter().map(|asset| asset.id).collect();
|
|
||||||
|
|
||||||
for asset in &assets {
|
|
||||||
if let Err(err) = tenant_storage.delete_object(&asset.s3_key).await {
|
|
||||||
eprintln!(
|
|
||||||
"Failed to delete object {} (tenant {}): {err}",
|
|
||||||
asset.s3_key, tenant.name
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
diesel::delete(
|
|
||||||
document_assets::table
|
|
||||||
.filter(document_assets::tenant_id.eq(tenant.id))
|
|
||||||
.filter(document_assets::id.eq_any(&asset_ids)),
|
|
||||||
)
|
|
||||||
.execute(&mut conn)
|
|
||||||
.with_context(|| format!("failed to remove asset records for tenant {}", tenant.name))?;
|
|
||||||
|
|
||||||
println!("Tenant {}: asset records deleted.", tenant.name);
|
|
||||||
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(
|
|
||||||
config: &AppConfig,
|
|
||||||
pool: &PgPool,
|
|
||||||
tenant_id: Uuid,
|
|
||||||
method: Method,
|
|
||||||
) -> Result<()> {
|
|
||||||
let endpoint = config
|
|
||||||
.quickwit_endpoint
|
|
||||||
.as_ref()
|
|
||||||
.ok_or_else(|| anyhow!("quickwit endpoint not configured"))?;
|
|
||||||
|
|
||||||
let mut conn = pool.get().context("failed to get database connection")?;
|
|
||||||
let tenant: Tenant = tenants::table
|
|
||||||
.find(tenant_id)
|
|
||||||
.first(&mut conn)
|
|
||||||
.optional()?
|
|
||||||
.ok_or_else(|| anyhow!("tenant '{}' not found", tenant_id))?;
|
|
||||||
|
|
||||||
let client = Client::new();
|
|
||||||
let index_id = format!("documents-{}", tenant.id);
|
|
||||||
let base_endpoint = endpoint.trim_end_matches('/');
|
|
||||||
|
|
||||||
match method {
|
|
||||||
Method::POST => {
|
|
||||||
ensure_quickwit_index(&client, base_endpoint, &index_id)
|
|
||||||
.await
|
|
||||||
.context("failed to ensure quickwit index")?;
|
|
||||||
|
|
||||||
diesel::update(tenants::table.filter(tenants::id.eq(tenant.id)))
|
|
||||||
.set(tenants::quickwit_index.eq(Some(index_id.clone())))
|
|
||||||
.execute(&mut conn)
|
|
||||||
.context("failed to update tenant quickwit_index")?;
|
|
||||||
|
|
||||||
println!(
|
|
||||||
"Tenant '{}' quickwit index set to '{}'.",
|
|
||||||
tenant.name, index_id
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Method::DELETE => {
|
|
||||||
let response = client
|
|
||||||
.delete(format!("{}/api/v1/indexes/{}", base_endpoint, index_id))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.context("failed to send delete index request")?;
|
|
||||||
|
|
||||||
match response.status() {
|
|
||||||
status if status.is_success() || status == StatusCode::NOT_FOUND => {
|
|
||||||
diesel::update(tenants::table.filter(tenants::id.eq(tenant.id)))
|
|
||||||
.set(tenants::quickwit_index.eq::<Option<String>>(None))
|
|
||||||
.execute(&mut conn)
|
|
||||||
.context("failed to clear tenant quickwit_index")?;
|
|
||||||
|
|
||||||
println!("Tenant '{}' quickwit index cleared.", tenant.name);
|
|
||||||
}
|
|
||||||
status => {
|
|
||||||
let body = response.text().await.unwrap_or_default();
|
|
||||||
bail!(
|
|
||||||
"quickwit delete index failed with status {}: {}",
|
|
||||||
status,
|
|
||||||
body
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => unreachable!(),
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
use papercrate::openapi::ApiDoc;
|
|
||||||
use utoipa::OpenApi;
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
let spec = ApiDoc::openapi();
|
|
||||||
let json = serde_json::to_string_pretty(&spec).expect("serialize openapi");
|
|
||||||
println!("{}", json);
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
use std::net::SocketAddr;
|
|
||||||
|
|
||||||
use tokio::net::TcpListener;
|
|
||||||
use tower::make::Shared;
|
|
||||||
|
|
||||||
use papercrate::{routes::webdav, utils::bootstrap::init_component};
|
|
||||||
|
|
||||||
#[tokio::main]
|
|
||||||
async fn main() -> anyhow::Result<()> {
|
|
||||||
let state = init_component("webdav", None).await?;
|
|
||||||
let webdav_host = state.config.webdav_host.clone();
|
|
||||||
let webdav_port = state.config.webdav_port;
|
|
||||||
tracing::info!(
|
|
||||||
component = "webdav",
|
|
||||||
webdav_host = %webdav_host,
|
|
||||||
webdav_port,
|
|
||||||
"starting webdav server"
|
|
||||||
);
|
|
||||||
|
|
||||||
let listen_addr: SocketAddr = format!("{}:{}", webdav_host, webdav_port).parse()?;
|
|
||||||
let router = webdav::create_router().with_state(state.as_ref().clone());
|
|
||||||
|
|
||||||
let listener = TcpListener::bind(listen_addr).await?;
|
|
||||||
tracing::info!("listening for WebDAV on {}", listen_addr);
|
|
||||||
|
|
||||||
axum::serve(listener, Shared::new(router)).await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,25 @@
|
|||||||
use std::time::Duration;
|
use std::{sync::Arc, time::Duration};
|
||||||
|
|
||||||
use tokio::signal;
|
use tokio::signal;
|
||||||
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
use papercrate::{default_handlers, utils::bootstrap::init_component, Worker};
|
use paperless_backend::{
|
||||||
|
auth::jwt::JwtService, config::AppConfig, db, default_handlers, s3::build_client,
|
||||||
|
state::AppState, storage::S3Storage, Worker,
|
||||||
|
};
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> anyhow::Result<()> {
|
async fn main() -> anyhow::Result<()> {
|
||||||
let state = init_component("worker", Some(1)).await?;
|
dotenv::dotenv().ok();
|
||||||
tracing::info!(component = "worker", "starting worker process");
|
init_tracing();
|
||||||
|
|
||||||
|
let config = AppConfig::from_env()?;
|
||||||
|
let pool = db::init_pool(&config.database_url)?;
|
||||||
|
let s3_client = build_client(&config).await?;
|
||||||
|
let storage = Arc::new(S3Storage::new(s3_client, config.s3_bucket.clone()));
|
||||||
|
let jwt = JwtService::from_config(&config)?;
|
||||||
|
|
||||||
|
let state = Arc::new(AppState::new(pool, config, storage, jwt));
|
||||||
let worker = Worker::new(state, default_handlers(), Duration::from_secs(2));
|
let worker = Worker::new(state, default_handlers(), Duration::from_secs(2));
|
||||||
|
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
@@ -19,3 +31,12 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn init_tracing() {
|
||||||
|
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(filter)
|
||||||
|
.with_target(false)
|
||||||
|
.compact()
|
||||||
|
.init();
|
||||||
|
}
|
||||||
|
|||||||
+36
-276
@@ -1,298 +1,58 @@
|
|||||||
|
use std::env;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use url::Url;
|
|
||||||
|
|
||||||
use serde::de::Deserializer;
|
#[derive(Clone, Debug)]
|
||||||
use serde::Deserialize;
|
|
||||||
use serde_aux::field_attributes::deserialize_bool_from_anything;
|
|
||||||
|
|
||||||
use crate::db::DEFAULT_MAX_POOL_SIZE;
|
|
||||||
|
|
||||||
#[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")]
|
|
||||||
pub database_max_pool_size: u32,
|
|
||||||
#[serde(default = "default_server_host")]
|
|
||||||
pub server_host: String,
|
pub server_host: String,
|
||||||
#[serde(default = "default_server_port")]
|
|
||||||
pub server_port: u16,
|
pub server_port: u16,
|
||||||
#[serde(default = "default_webdav_host")]
|
|
||||||
pub webdav_host: String,
|
|
||||||
#[serde(default = "default_webdav_port")]
|
|
||||||
pub webdav_port: u16,
|
|
||||||
pub jwt_secret: String,
|
pub jwt_secret: String,
|
||||||
#[serde(default = "default_jwt_issuer")]
|
|
||||||
pub jwt_issuer: String,
|
pub jwt_issuer: String,
|
||||||
#[serde(default = "default_jwt_audience")]
|
|
||||||
pub jwt_audience: String,
|
pub jwt_audience: String,
|
||||||
#[serde(default = "default_jwt_expiry_minutes")]
|
|
||||||
pub jwt_expiry_minutes: i64,
|
pub jwt_expiry_minutes: i64,
|
||||||
#[serde(default = "default_download_token_audience")]
|
|
||||||
pub download_token_audience: String,
|
|
||||||
#[serde(default = "default_download_token_expiry_minutes")]
|
|
||||||
pub download_token_expiry_minutes: i64,
|
|
||||||
#[serde(default = "default_refresh_token_expiry_days")]
|
|
||||||
pub refresh_token_expiry_days: i64,
|
|
||||||
#[serde(
|
|
||||||
default = "default_refresh_cookie_secure",
|
|
||||||
deserialize_with = "deserialize_bool_from_anything"
|
|
||||||
)]
|
|
||||||
pub refresh_cookie_secure: bool,
|
|
||||||
#[serde(default)]
|
|
||||||
pub refresh_cookie_domain: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub cors_allowed_origin: Option<String>,
|
|
||||||
#[serde(default, deserialize_with = "deserialize_bool_from_anything")]
|
|
||||||
pub proxy_downloads: bool,
|
|
||||||
#[serde(default)]
|
|
||||||
pub aws_endpoint_url: Option<String>,
|
pub aws_endpoint_url: Option<String>,
|
||||||
#[serde(default)]
|
|
||||||
pub aws_access_key_id: Option<String>,
|
pub aws_access_key_id: Option<String>,
|
||||||
#[serde(default)]
|
|
||||||
pub aws_secret_access_key: Option<String>,
|
pub aws_secret_access_key: Option<String>,
|
||||||
#[serde(default = "default_aws_region")]
|
|
||||||
pub aws_region: String,
|
pub aws_region: String,
|
||||||
pub s3_bucket: String,
|
pub s3_bucket: String,
|
||||||
#[serde(default)]
|
|
||||||
pub quickwit_endpoint: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub quickwit_index: Option<String>,
|
|
||||||
#[serde(default = "default_worker_max_document_bytes")]
|
|
||||||
pub worker_max_document_bytes: u64,
|
|
||||||
#[serde(default = "default_upload_body_limit_bytes")]
|
|
||||||
pub upload_body_limit_bytes: u64,
|
|
||||||
#[serde(default = "default_service_timezone")]
|
|
||||||
pub service_timezone: String,
|
|
||||||
#[serde(default = "default_issued_at_date_order")]
|
|
||||||
pub issued_at_date_order: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub issued_at_filename_date_order: Option<String>,
|
|
||||||
#[serde(default, deserialize_with = "deserialize_string_list")]
|
|
||||||
pub issued_at_date_parser_locales: Vec<String>,
|
|
||||||
#[serde(default, deserialize_with = "deserialize_string_list")]
|
|
||||||
pub issued_at_ignore_dates: Vec<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub webauthn_rp_id: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub webauthn_origin: Option<String>,
|
|
||||||
#[serde(default = "default_webauthn_rp_name")]
|
|
||||||
pub webauthn_rp_name: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppConfig {
|
impl AppConfig {
|
||||||
pub fn load_and_log(component: &str) -> Result<Self> {
|
|
||||||
dotenv::dotenv().ok();
|
|
||||||
let config = Self::from_env()?;
|
|
||||||
tracing::info!(
|
|
||||||
component,
|
|
||||||
database_url = %config.redacted_database_url(),
|
|
||||||
migrations_database_url = %config.redacted_migrations_database_url(),
|
|
||||||
pool_size = config.database_max_pool_size,
|
|
||||||
quickwit_enabled = config.quickwit_endpoint.is_some(),
|
|
||||||
passkeys_enabled = config.webauthn_origin.is_some(),
|
|
||||||
s3_bucket = %config.s3_bucket,
|
|
||||||
worker_max_document_bytes = config.worker_max_document_bytes,
|
|
||||||
upload_body_limit_bytes = config.upload_body_limit_bytes,
|
|
||||||
proxy_downloads = config.proxy_downloads,
|
|
||||||
"loaded backend configuration"
|
|
||||||
);
|
|
||||||
Ok(config)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn from_env() -> Result<Self> {
|
pub fn from_env() -> Result<Self> {
|
||||||
let config: AppConfig = envy::from_env()
|
let database_url = env::var("DATABASE_URL").context("DATABASE_URL must be set")?;
|
||||||
.context("failed to parse application configuration from environment")?;
|
let server_host = env::var("SERVER_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
|
||||||
Ok(config.normalize())
|
let server_port = env::var("SERVER_PORT")
|
||||||
}
|
.unwrap_or_else(|_| "3000".to_string())
|
||||||
|
.parse()
|
||||||
|
.context("SERVER_PORT must be a valid u16")?;
|
||||||
|
let jwt_secret = env::var("JWT_SECRET").context("JWT_SECRET must be set")?;
|
||||||
|
let jwt_issuer = env::var("JWT_ISSUER").unwrap_or_else(|_| "paperless-neo".to_string());
|
||||||
|
let jwt_audience =
|
||||||
|
env::var("JWT_AUDIENCE").unwrap_or_else(|_| "paperless-neo-clients".to_string());
|
||||||
|
let jwt_expiry_minutes = env::var("JWT_EXPIRY_MINUTES")
|
||||||
|
.unwrap_or_else(|_| "60".to_string())
|
||||||
|
.parse()
|
||||||
|
.context("JWT_EXPIRY_MINUTES must be an integer")?;
|
||||||
|
let aws_endpoint_url = env::var("AWS_ENDPOINT_URL").ok();
|
||||||
|
let aws_access_key_id = env::var("AWS_ACCESS_KEY_ID").ok();
|
||||||
|
let aws_secret_access_key = env::var("AWS_SECRET_ACCESS_KEY").ok();
|
||||||
|
let aws_region = env::var("AWS_REGION").unwrap_or_else(|_| "us-east-1".to_string());
|
||||||
|
let s3_bucket = env::var("S3_BUCKET").context("S3_BUCKET must be set")?;
|
||||||
|
|
||||||
pub fn redacted_database_url(&self) -> String {
|
Ok(Self {
|
||||||
redact_database_url(&self.database_url)
|
database_url,
|
||||||
}
|
server_host,
|
||||||
|
server_port,
|
||||||
pub fn redacted_migrations_database_url(&self) -> String {
|
jwt_secret,
|
||||||
redact_database_url(self.migrations_database_url())
|
jwt_issuer,
|
||||||
}
|
jwt_audience,
|
||||||
|
jwt_expiry_minutes,
|
||||||
pub fn migrations_database_url(&self) -> &str {
|
aws_endpoint_url,
|
||||||
if let Some(ref url) = self.migrations_database_url {
|
aws_access_key_id,
|
||||||
url
|
aws_secret_access_key,
|
||||||
} else {
|
aws_region,
|
||||||
&self.database_url
|
s3_bucket,
|
||||||
}
|
})
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AppConfig {
|
|
||||||
fn normalize(mut self) -> Self {
|
|
||||||
if self.webdav_host.is_empty() {
|
|
||||||
self.webdav_host = self.server_host.clone();
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.webauthn_rp_id.is_none() {
|
|
||||||
self.webauthn_rp_id = Some(self.server_host.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.webauthn_origin.is_none() {
|
|
||||||
let scheme = if self.server_host == "127.0.0.1" || self.server_host == "localhost" {
|
|
||||||
"http"
|
|
||||||
} else {
|
|
||||||
"https"
|
|
||||||
};
|
|
||||||
self.webauthn_origin = Some(format!(
|
|
||||||
"{scheme}://{}:{}",
|
|
||||||
self.server_host, self.server_port
|
|
||||||
));
|
|
||||||
}
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_database_max_pool_size() -> u32 {
|
|
||||||
DEFAULT_MAX_POOL_SIZE
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_server_host() -> String {
|
|
||||||
"127.0.0.1".to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_server_port() -> u16 {
|
|
||||||
3000
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_webdav_host() -> String {
|
|
||||||
String::new()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_webdav_port() -> u16 {
|
|
||||||
3001
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_jwt_issuer() -> String {
|
|
||||||
"papercrate".to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_jwt_audience() -> String {
|
|
||||||
"papercrate-clients".to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_jwt_expiry_minutes() -> i64 {
|
|
||||||
60
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_download_token_audience() -> String {
|
|
||||||
"papercrate-download".to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_download_token_expiry_minutes() -> i64 {
|
|
||||||
60
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_refresh_token_expiry_days() -> i64 {
|
|
||||||
30
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_refresh_cookie_secure() -> bool {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_aws_region() -> String {
|
|
||||||
"us-east-1".to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_worker_max_document_bytes() -> u64 {
|
|
||||||
200 * 1024 * 1024
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_upload_body_limit_bytes() -> u64 {
|
|
||||||
128 * 1024 * 1024
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_service_timezone() -> String {
|
|
||||||
"UTC".to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_issued_at_date_order() -> String {
|
|
||||||
"DMY".to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn deserialize_string_list<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
|
|
||||||
where
|
|
||||||
D: Deserializer<'de>,
|
|
||||||
{
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
#[serde(untagged)]
|
|
||||||
enum Helper {
|
|
||||||
List(Vec<String>),
|
|
||||||
Single(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
let helper = Option::<Helper>::deserialize(deserializer)?;
|
|
||||||
let mut values = Vec::new();
|
|
||||||
|
|
||||||
if let Some(helper) = helper {
|
|
||||||
match helper {
|
|
||||||
Helper::List(list) => {
|
|
||||||
for entry in list {
|
|
||||||
let trimmed = entry.trim();
|
|
||||||
if !trimmed.is_empty() {
|
|
||||||
values.push(trimmed.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Helper::Single(value) => {
|
|
||||||
for part in value.split(',') {
|
|
||||||
let trimmed = part.trim();
|
|
||||||
if !trimmed.is_empty() {
|
|
||||||
values.push(trimmed.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(values)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_webauthn_rp_name() -> String {
|
|
||||||
"Papercrate".to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn redact_database_url(raw: &str) -> String {
|
|
||||||
match Url::parse(raw) {
|
|
||||||
Ok(mut parsed) => {
|
|
||||||
if parsed.password().is_some() {
|
|
||||||
let _ = parsed.set_password(Some("*****"));
|
|
||||||
parsed.to_string()
|
|
||||||
} else {
|
|
||||||
raw.to_string()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(_) => "***".to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::redact_database_url;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn redacts_password_in_database_url() {
|
|
||||||
let redacted = redact_database_url("postgres://user:secret@localhost/db");
|
|
||||||
assert!(redacted.contains("postgres://user:*****@"));
|
|
||||||
assert!(!redacted.contains("secret"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn handles_url_without_password() {
|
|
||||||
let redacted = redact_database_url("postgres://localhost/db");
|
|
||||||
assert_eq!(redacted, "postgres://localhost/db");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn falls_back_when_parse_fails() {
|
|
||||||
let redacted = redact_database_url("not a url");
|
|
||||||
assert_eq!(redacted, "***");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-38
@@ -1,51 +1,15 @@
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use diesel::pg::PgConnection;
|
use diesel::pg::PgConnection;
|
||||||
use diesel::r2d2::{ConnectionManager, CustomizeConnection, Pool};
|
use diesel::r2d2::{ConnectionManager, Pool};
|
||||||
use diesel::RunQueryDsl;
|
|
||||||
|
|
||||||
pub type PgPool = Pool<ConnectionManager<PgConnection>>;
|
pub type PgPool = Pool<ConnectionManager<PgConnection>>;
|
||||||
|
|
||||||
pub const DEFAULT_MAX_POOL_SIZE: u32 = 2;
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct SchemaCustomizer;
|
|
||||||
|
|
||||||
impl CustomizeConnection<PgConnection, diesel::r2d2::Error> for SchemaCustomizer {
|
|
||||||
fn on_acquire(&self, conn: &mut PgConnection) -> Result<(), diesel::r2d2::Error> {
|
|
||||||
diesel::sql_query(
|
|
||||||
"SELECT set_config('search_path', (
|
|
||||||
SELECT string_agg(schema_name, ', ')
|
|
||||||
FROM (
|
|
||||||
SELECT 'tenant' AS schema_name WHERE EXISTS (
|
|
||||||
SELECT 1 FROM pg_namespace WHERE nspname = 'tenant'
|
|
||||||
)
|
|
||||||
UNION ALL
|
|
||||||
SELECT 'shared' AS schema_name WHERE EXISTS (
|
|
||||||
SELECT 1 FROM pg_namespace WHERE nspname = 'shared'
|
|
||||||
)
|
|
||||||
UNION ALL
|
|
||||||
SELECT 'public' AS schema_name
|
|
||||||
) AS schemas
|
|
||||||
), false)",
|
|
||||||
)
|
|
||||||
.execute(conn)
|
|
||||||
.map(|_| ())
|
|
||||||
.map_err(diesel::r2d2::Error::QueryError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn init_pool(database_url: &str) -> anyhow::Result<PgPool> {
|
pub fn init_pool(database_url: &str) -> anyhow::Result<PgPool> {
|
||||||
init_pool_with_size(database_url, DEFAULT_MAX_POOL_SIZE)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn init_pool_with_size(database_url: &str, max_size: u32) -> anyhow::Result<PgPool> {
|
|
||||||
let manager = ConnectionManager::<PgConnection>::new(database_url);
|
let manager = ConnectionManager::<PgConnection>::new(database_url);
|
||||||
let pool_size = max_size.max(1);
|
|
||||||
let pool = Pool::builder()
|
let pool = Pool::builder()
|
||||||
.max_size(pool_size)
|
.max_size(16)
|
||||||
.connection_timeout(Duration::from_secs(10))
|
.connection_timeout(Duration::from_secs(10))
|
||||||
.connection_customizer(Box::new(SchemaCustomizer))
|
|
||||||
.build(manager)?;
|
.build(manager)?;
|
||||||
Ok(pool)
|
Ok(pool)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,256 +0,0 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
use std::path::Path as FsPath;
|
|
||||||
|
|
||||||
use chrono::{Duration as ChronoDuration, Utc};
|
|
||||||
use diesel::prelude::*;
|
|
||||||
use serde::Serialize;
|
|
||||||
use serde_json::Value;
|
|
||||||
use utoipa::ToSchema;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::error::{AppError, AppResult};
|
|
||||||
use crate::models::{Document, DocumentAsset, DocumentVersion};
|
|
||||||
use crate::schema::{document_assets, document_versions};
|
|
||||||
use crate::state::{AppState, PgPooledConnection};
|
|
||||||
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)]
|
|
||||||
pub struct DocumentAssetResponse {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub asset_type: String,
|
|
||||||
pub mime_type: String,
|
|
||||||
#[schema(value_type = Object)]
|
|
||||||
pub metadata: Value,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
#[schema(nullable)]
|
|
||||||
pub download: Option<DownloadLink>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
|
||||||
pub struct DocumentAssetDetailResponse {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub asset_type: String,
|
|
||||||
pub mime_type: String,
|
|
||||||
#[schema(value_type = Object)]
|
|
||||||
pub metadata: Value,
|
|
||||||
pub created_at: String,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
#[schema(nullable)]
|
|
||||||
pub download: Option<DownloadLink>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Clone, ToSchema)]
|
|
||||||
pub struct DocumentVersionResponse {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub version_number: i32,
|
|
||||||
pub size_bytes: i64,
|
|
||||||
pub checksum: String,
|
|
||||||
pub created_at: String,
|
|
||||||
#[schema(value_type = Object)]
|
|
||||||
pub metadata: Value,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Clone, ToSchema)]
|
|
||||||
pub struct DocumentVersionDetailResponse {
|
|
||||||
#[serde(flatten)]
|
|
||||||
pub version: DocumentVersionResponse,
|
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
||||||
pub assets: Vec<DocumentAssetResponse>,
|
|
||||||
pub download: DownloadLink,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn build_download_link(
|
|
||||||
state: &AppState,
|
|
||||||
document: &Document,
|
|
||||||
version_id: Uuid,
|
|
||||||
user_id: Uuid,
|
|
||||||
) -> AppResult<DownloadLink> {
|
|
||||||
state
|
|
||||||
.jwt
|
|
||||||
.generate_download_token(document.id, version_id, user_id, document.tenant_id)
|
|
||||||
.map_err(|err| {
|
|
||||||
tracing::error!(error = ?err, "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 {
|
|
||||||
DocumentVersionResponse {
|
|
||||||
id: version.id,
|
|
||||||
version_number: version.version_number,
|
|
||||||
size_bytes: version.size_bytes,
|
|
||||||
checksum: version.checksum,
|
|
||||||
created_at: to_iso(version.created_at),
|
|
||||||
metadata: version.metadata,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn to_asset_summary(asset: DocumentAsset) -> DocumentAssetResponse {
|
|
||||||
DocumentAssetResponse {
|
|
||||||
id: asset.id,
|
|
||||||
asset_type: asset.asset_type,
|
|
||||||
mime_type: asset.mime_type,
|
|
||||||
metadata: asset.metadata,
|
|
||||||
download: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn to_asset_detail_response(
|
|
||||||
asset: DocumentAsset,
|
|
||||||
download: Option<DownloadLink>,
|
|
||||||
) -> DocumentAssetDetailResponse {
|
|
||||||
DocumentAssetDetailResponse {
|
|
||||||
id: asset.id,
|
|
||||||
asset_type: asset.asset_type,
|
|
||||||
mime_type: asset.mime_type,
|
|
||||||
metadata: asset.metadata,
|
|
||||||
created_at: to_iso(asset.created_at),
|
|
||||||
download,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn asset_disposition(asset: &DocumentAsset) -> Option<String> {
|
|
||||||
let filename = asset.asset_type.clone();
|
|
||||||
inline_content_disposition(&filename)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn delete_asset(
|
|
||||||
conn: &mut PgPooledConnection,
|
|
||||||
tenant_id: Uuid,
|
|
||||||
asset_id: Uuid,
|
|
||||||
) -> AppResult<()> {
|
|
||||||
diesel::delete(
|
|
||||||
document_assets::table
|
|
||||||
.filter(document_assets::id.eq(asset_id))
|
|
||||||
.filter(document_assets::tenant_id.eq(tenant_id)),
|
|
||||||
)
|
|
||||||
.execute(conn)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn load_asset_responses_with_conn(
|
|
||||||
conn: &mut PgPooledConnection,
|
|
||||||
tenant_id: Uuid,
|
|
||||||
version_id: Uuid,
|
|
||||||
) -> AppResult<Vec<DocumentAssetResponse>> {
|
|
||||||
let assets: Vec<DocumentAsset> = document_assets::table
|
|
||||||
.filter(document_assets::document_version_id.eq(version_id))
|
|
||||||
.filter(document_assets::tenant_id.eq(tenant_id))
|
|
||||||
.order(document_assets::created_at.asc())
|
|
||||||
.load(conn)?;
|
|
||||||
|
|
||||||
Ok(assets.into_iter().map(to_asset_summary).collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn load_primary_assets(
|
|
||||||
conn: &mut PgPooledConnection,
|
|
||||||
documents: &[Document],
|
|
||||||
) -> AppResult<HashMap<Uuid, (DocumentVersionResponse, Vec<DocumentAssetResponse>)>> {
|
|
||||||
if documents.is_empty() {
|
|
||||||
return Ok(HashMap::new());
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut doc_to_version: HashMap<Uuid, Uuid> = HashMap::with_capacity(documents.len());
|
|
||||||
let mut version_ids: Vec<Uuid> = Vec::with_capacity(documents.len());
|
|
||||||
for doc in documents {
|
|
||||||
doc_to_version.insert(doc.id, doc.current_version_id);
|
|
||||||
version_ids.push(doc.current_version_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
version_ids.sort();
|
|
||||||
version_ids.dedup();
|
|
||||||
|
|
||||||
let versions: Vec<DocumentVersion> = document_versions::table
|
|
||||||
.filter(document_versions::id.eq_any(&version_ids))
|
|
||||||
.load(conn)?;
|
|
||||||
|
|
||||||
let mut version_map: HashMap<Uuid, DocumentVersion> = HashMap::new();
|
|
||||||
for version in versions {
|
|
||||||
version_map.insert(version.id, version);
|
|
||||||
}
|
|
||||||
|
|
||||||
let assets: Vec<DocumentAsset> = document_assets::table
|
|
||||||
.filter(document_assets::document_version_id.eq_any(&version_ids))
|
|
||||||
.order((
|
|
||||||
document_assets::document_version_id.asc(),
|
|
||||||
document_assets::created_at.asc(),
|
|
||||||
))
|
|
||||||
.load(conn)?;
|
|
||||||
|
|
||||||
let mut assets_by_version: HashMap<Uuid, Vec<DocumentAssetResponse>> = HashMap::new();
|
|
||||||
for asset in assets {
|
|
||||||
let version_id = asset.document_version_id;
|
|
||||||
let response = to_asset_summary(asset);
|
|
||||||
assets_by_version
|
|
||||||
.entry(version_id)
|
|
||||||
.or_default()
|
|
||||||
.push(response);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut result: HashMap<Uuid, (DocumentVersionResponse, Vec<DocumentAssetResponse>)> =
|
|
||||||
HashMap::with_capacity(doc_to_version.len());
|
|
||||||
for (doc_id, version_id) in doc_to_version {
|
|
||||||
if let Some(version) = version_map.remove(&version_id) {
|
|
||||||
let assets = assets_by_version.remove(&version_id).unwrap_or_default();
|
|
||||||
result.insert(doc_id, (to_version_response(version), assets));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn derive_document_title(original: &str) -> String {
|
|
||||||
let trimmed = original.trim();
|
|
||||||
if trimmed.is_empty() {
|
|
||||||
return "Document".to_string();
|
|
||||||
}
|
|
||||||
|
|
||||||
let stem = FsPath::new(trimmed)
|
|
||||||
.file_stem()
|
|
||||||
.and_then(|s| s.to_str())
|
|
||||||
.map(|s| s.trim())
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.map(|s| s.to_string());
|
|
||||||
|
|
||||||
stem.unwrap_or_else(|| trimmed.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn filename_with_retained_extension(title: &str, current_filename: &str) -> String {
|
|
||||||
let extension = FsPath::new(current_filename)
|
|
||||||
.extension()
|
|
||||||
.and_then(|ext| ext.to_str());
|
|
||||||
|
|
||||||
if let Some(ext) = extension {
|
|
||||||
if title
|
|
||||||
.rsplit_once('.')
|
|
||||||
.map(|(_, existing_ext)| existing_ext.eq_ignore_ascii_case(ext))
|
|
||||||
.unwrap_or(false)
|
|
||||||
{
|
|
||||||
title.to_string()
|
|
||||||
} else {
|
|
||||||
format!("{title}.{ext}")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
title.to_string()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use chrono::Utc;
|
|
||||||
use diesel::prelude::*;
|
|
||||||
use serde::Serialize;
|
|
||||||
use serde_json::Value;
|
|
||||||
use utoipa::ToSchema;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::error::{AppError, AppResult};
|
|
||||||
use crate::models::{Correspondent, DocumentCorrespondent, NewDocumentCorrespondent};
|
|
||||||
use crate::schema::{correspondents, document_correspondents, documents};
|
|
||||||
use crate::utils::time::to_iso;
|
|
||||||
|
|
||||||
#[derive(Serialize, Clone, ToSchema)]
|
|
||||||
pub struct DocumentCorrespondentResponse {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub name: String,
|
|
||||||
#[schema(value_type = Object)]
|
|
||||||
pub metadata: Value,
|
|
||||||
pub assigned_at: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn normalize_correspondent_ids(ids: &[Uuid]) -> AppResult<Vec<Uuid>> {
|
|
||||||
let mut unique: Vec<Uuid> = ids.iter().copied().collect();
|
|
||||||
unique.sort_unstable();
|
|
||||||
unique.dedup();
|
|
||||||
|
|
||||||
if unique.is_empty() {
|
|
||||||
return Err(AppError::bad_request(
|
|
||||||
"assignments must contain at least one correspondent",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(unique)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn insert_document_correspondents(
|
|
||||||
conn: &mut PgConnection,
|
|
||||||
tenant_id: Uuid,
|
|
||||||
document_id: Uuid,
|
|
||||||
user_id: Uuid,
|
|
||||||
correspondent_ids: &[Uuid],
|
|
||||||
) -> AppResult<usize> {
|
|
||||||
let ids = normalize_correspondent_ids(correspondent_ids)?;
|
|
||||||
|
|
||||||
let existing: Vec<Uuid> = correspondents::table
|
|
||||||
.filter(correspondents::id.eq_any(&ids))
|
|
||||||
.filter(correspondents::tenant_id.eq(tenant_id))
|
|
||||||
.select(correspondents::id)
|
|
||||||
.load(conn)?;
|
|
||||||
|
|
||||||
if existing.len() != ids.len() {
|
|
||||||
return Err(AppError::bad_request(
|
|
||||||
"one or more correspondents do not exist",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let new_rows: Vec<NewDocumentCorrespondent> = ids
|
|
||||||
.into_iter()
|
|
||||||
.map(|correspondent_id| NewDocumentCorrespondent {
|
|
||||||
document_id,
|
|
||||||
correspondent_id,
|
|
||||||
assigned_by: Some(user_id),
|
|
||||||
tenant_id,
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
if new_rows.is_empty() {
|
|
||||||
return Ok(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
let inserted = diesel::insert_into(document_correspondents::table)
|
|
||||||
.values(&new_rows)
|
|
||||||
.on_conflict_do_nothing()
|
|
||||||
.execute(conn)?;
|
|
||||||
|
|
||||||
if 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(inserted)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn load_correspondents_for_documents(
|
|
||||||
conn: &mut PgConnection,
|
|
||||||
document_ids: &[Uuid],
|
|
||||||
) -> AppResult<HashMap<Uuid, Vec<DocumentCorrespondentResponse>>> {
|
|
||||||
if document_ids.is_empty() {
|
|
||||||
return Ok(HashMap::new());
|
|
||||||
}
|
|
||||||
|
|
||||||
let rows: Vec<(DocumentCorrespondent, Correspondent)> = document_correspondents::table
|
|
||||||
.inner_join(correspondents::table)
|
|
||||||
.filter(document_correspondents::document_id.eq_any(document_ids))
|
|
||||||
.order((
|
|
||||||
document_correspondents::document_id.asc(),
|
|
||||||
document_correspondents::assigned_at.asc(),
|
|
||||||
))
|
|
||||||
.load(conn)?;
|
|
||||||
|
|
||||||
let mut map: HashMap<Uuid, Vec<DocumentCorrespondentResponse>> = HashMap::new();
|
|
||||||
for (assignment, correspondent) in rows {
|
|
||||||
map.entry(assignment.document_id)
|
|
||||||
.or_default()
|
|
||||||
.push(DocumentCorrespondentResponse {
|
|
||||||
id: correspondent.id,
|
|
||||||
name: correspondent.name,
|
|
||||||
metadata: correspondent.metadata,
|
|
||||||
assigned_at: to_iso(assignment.assigned_at),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(map)
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
use diesel::dsl::exists;
|
|
||||||
use diesel::prelude::*;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::error::AppResult;
|
|
||||||
use crate::schema::folders;
|
|
||||||
use crate::utils::validation::ensure_exists;
|
|
||||||
|
|
||||||
pub fn ensure_folder_exists_on_conn(
|
|
||||||
conn: &mut PgConnection,
|
|
||||||
tenant_id: Uuid,
|
|
||||||
folder_id: Uuid,
|
|
||||||
) -> AppResult<()> {
|
|
||||||
let exists: bool = diesel::select(exists(
|
|
||||||
folders::table
|
|
||||||
.filter(folders::id.eq(folder_id))
|
|
||||||
.filter(folders::tenant_id.eq(tenant_id)),
|
|
||||||
))
|
|
||||||
.get_result(conn)?;
|
|
||||||
ensure_exists(exists, "folder")
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
use serde_json::{map::Entry, Map, Value};
|
|
||||||
|
|
||||||
use crate::error::{AppError, AppResult};
|
|
||||||
|
|
||||||
pub fn merge_document_metadata(existing: Value, updates: Value) -> AppResult<Value> {
|
|
||||||
let mut base = match existing {
|
|
||||||
Value::Object(map) => map,
|
|
||||||
Value::Null => Map::new(),
|
|
||||||
_ => {
|
|
||||||
return Err(AppError::bad_request(
|
|
||||||
"existing metadata is not an object; set replace=true to overwrite",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let incoming = match updates {
|
|
||||||
Value::Object(map) => map,
|
|
||||||
_ => {
|
|
||||||
return Err(AppError::bad_request(
|
|
||||||
"metadata value must be a JSON object when replace is false",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
merge_metadata_maps(&mut base, incoming);
|
|
||||||
Ok(Value::Object(base))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn merge_metadata_maps(target: &mut Map<String, Value>, updates: Map<String, Value>) {
|
|
||||||
for (key, value) in updates {
|
|
||||||
match target.entry(key) {
|
|
||||||
Entry::Occupied(mut entry) => {
|
|
||||||
let existing = entry.get_mut();
|
|
||||||
match value {
|
|
||||||
Value::Object(update_map) => {
|
|
||||||
if let Value::Object(existing_map) = existing {
|
|
||||||
merge_metadata_maps(existing_map, update_map);
|
|
||||||
} else {
|
|
||||||
*existing = Value::Object(update_map);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
other => {
|
|
||||||
*existing = other;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Entry::Vacant(entry) => {
|
|
||||||
entry.insert(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
pub mod asset;
|
|
||||||
pub mod correspondents;
|
|
||||||
pub mod folders;
|
|
||||||
pub mod metadata;
|
|
||||||
pub mod ordering;
|
|
||||||
pub mod relations;
|
|
||||||
pub mod search;
|
|
||||||
pub mod tags;
|
|
||||||
|
|
||||||
pub use ordering::{DocumentSortField, SortDirection};
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use utoipa::ToSchema;
|
|
||||||
|
|
||||||
pub const UNICODE_COLLATION_NAME: &str = "unicode_ci";
|
|
||||||
pub const UNICODE_COLLATION_LOCALE: &str = "und-u-ks-level2";
|
|
||||||
|
|
||||||
const TITLE_ASC: &str = "title COLLATE \"unicode_ci\" ASC";
|
|
||||||
const TITLE_DESC: &str = "title COLLATE \"unicode_ci\" DESC";
|
|
||||||
const ISSUED_AT_ASC: &str = "issued_at ASC NULLS LAST";
|
|
||||||
const ISSUED_AT_DESC: &str = "issued_at DESC NULLS LAST";
|
|
||||||
const CREATED_AT_ASC: &str = "created_at ASC";
|
|
||||||
const CREATED_AT_DESC: &str = "created_at DESC";
|
|
||||||
const UPDATED_AT_ASC: &str = "updated_at ASC";
|
|
||||||
const UPDATED_AT_DESC: &str = "updated_at DESC";
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize, ToSchema)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum DocumentSortField {
|
|
||||||
Title,
|
|
||||||
IssuedAt,
|
|
||||||
CreatedAt,
|
|
||||||
UpdatedAt,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for DocumentSortField {
|
|
||||||
fn default() -> Self {
|
|
||||||
DocumentSortField::Title
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize, ToSchema)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum SortDirection {
|
|
||||||
Asc,
|
|
||||||
Desc,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for SortDirection {
|
|
||||||
fn default() -> Self {
|
|
||||||
SortDirection::Asc
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn ordering_clauses(
|
|
||||||
field: DocumentSortField,
|
|
||||||
direction: SortDirection,
|
|
||||||
) -> (&'static str, Option<&'static str>) {
|
|
||||||
match (field, direction) {
|
|
||||||
(DocumentSortField::Title, SortDirection::Asc) => (TITLE_ASC, None),
|
|
||||||
(DocumentSortField::Title, SortDirection::Desc) => (TITLE_DESC, None),
|
|
||||||
(DocumentSortField::IssuedAt, SortDirection::Asc) => (ISSUED_AT_ASC, Some(TITLE_ASC)),
|
|
||||||
(DocumentSortField::IssuedAt, SortDirection::Desc) => (ISSUED_AT_DESC, Some(TITLE_ASC)),
|
|
||||||
(DocumentSortField::CreatedAt, SortDirection::Asc) => (CREATED_AT_ASC, Some(TITLE_ASC)),
|
|
||||||
(DocumentSortField::CreatedAt, SortDirection::Desc) => (CREATED_AT_DESC, Some(TITLE_ASC)),
|
|
||||||
(DocumentSortField::UpdatedAt, SortDirection::Asc) => (UPDATED_AT_ASC, Some(TITLE_ASC)),
|
|
||||||
(DocumentSortField::UpdatedAt, SortDirection::Desc) => (UPDATED_AT_DESC, Some(TITLE_ASC)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::documents::correspondents::{
|
|
||||||
load_correspondents_for_documents, DocumentCorrespondentResponse,
|
|
||||||
};
|
|
||||||
use crate::documents::tags::load_tags_for_documents;
|
|
||||||
use crate::error::AppResult;
|
|
||||||
use crate::models::Tag;
|
|
||||||
use crate::state::PgPooledConnection;
|
|
||||||
|
|
||||||
/// Loads tags and correspondents for the provided documents in a single pass.
|
|
||||||
pub fn load_tags_and_correspondents(
|
|
||||||
conn: &mut PgPooledConnection,
|
|
||||||
document_ids: &[Uuid],
|
|
||||||
) -> AppResult<HashMap<Uuid, (Vec<Tag>, Vec<DocumentCorrespondentResponse>)>> {
|
|
||||||
if document_ids.is_empty() {
|
|
||||||
return Ok(HashMap::new());
|
|
||||||
}
|
|
||||||
|
|
||||||
let tags_map = load_tags_for_documents(conn, document_ids)?;
|
|
||||||
let mut correspondents_map = load_correspondents_for_documents(conn, document_ids)?;
|
|
||||||
|
|
||||||
let mut result = HashMap::with_capacity(document_ids.len());
|
|
||||||
for id in document_ids {
|
|
||||||
let tags = tags_map.get(id).cloned().unwrap_or_default();
|
|
||||||
let correspondents = correspondents_map.remove(id).unwrap_or_default();
|
|
||||||
result.insert(*id, (tags, correspondents));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(result)
|
|
||||||
}
|
|
||||||
@@ -1,304 +0,0 @@
|
|||||||
use std::collections::HashSet;
|
|
||||||
|
|
||||||
use anyhow::{anyhow, bail, Result};
|
|
||||||
use reqwest::{Client, StatusCode};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use serde_json::{json, Value};
|
|
||||||
use tracing::{debug, error};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::models::{Document, DocumentVersion};
|
|
||||||
|
|
||||||
pub const QUICKWIT_MAX_HITS: usize = 200;
|
|
||||||
|
|
||||||
pub fn build_quickwit_query(input: &str) -> Option<String> {
|
|
||||||
let tokens: Vec<String> = input
|
|
||||||
.split_whitespace()
|
|
||||||
.filter(|token| !token.is_empty())
|
|
||||||
.map(|token| {
|
|
||||||
let normalized = token.to_lowercase();
|
|
||||||
escape_quickwit_token(&normalized)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
if tokens.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let parts: Vec<String> = tokens
|
|
||||||
.into_iter()
|
|
||||||
.map(|token| format!("(title:{token} OR text:{token})"))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Some(parts.join(" AND "))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn escape_quickwit_token(token: &str) -> String {
|
|
||||||
let mut escaped = String::with_capacity(token.len());
|
|
||||||
for ch in token.chars() {
|
|
||||||
match ch {
|
|
||||||
'+' | '-' | '&' | '|' | '!' | '(' | ')' | '{' | '}' | '[' | ']' | '^' | '"' | '~'
|
|
||||||
| '*' | '?' | ':' | '\\' | '/' => {
|
|
||||||
escaped.push('\\');
|
|
||||||
escaped.push(ch);
|
|
||||||
}
|
|
||||||
_ => escaped.push(ch),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
escaped
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn quickwit_search(
|
|
||||||
endpoint: &str,
|
|
||||||
index: &str,
|
|
||||||
tenant_id: Uuid,
|
|
||||||
query: &str,
|
|
||||||
) -> Result<Vec<Uuid>> {
|
|
||||||
let tenant_clause = format!("tenant_id:{}", tenant_id);
|
|
||||||
let quickwit_query = match build_quickwit_query(query) {
|
|
||||||
Some(q) => {
|
|
||||||
debug!(%query, quickwit_query = %q, "built quickwit search query");
|
|
||||||
format!("{} AND ({})", tenant_clause, q)
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
debug!(%query, "quickwit search skipped because query produced no tokens");
|
|
||||||
return Ok(vec![]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let client = Client::new();
|
|
||||||
let url = format!("{}/api/v1/{}/search", endpoint.trim_end_matches('/'), index);
|
|
||||||
|
|
||||||
let payload = json!({
|
|
||||||
"query": quickwit_query,
|
|
||||||
"max_hits": QUICKWIT_MAX_HITS,
|
|
||||||
});
|
|
||||||
|
|
||||||
debug!(%url, payload = %payload, "sending quickwit search request");
|
|
||||||
let response = client.post(url).json(&payload).send().await?;
|
|
||||||
if !response.status().is_success() {
|
|
||||||
let status = response.status();
|
|
||||||
let body = response.text().await.unwrap_or_default();
|
|
||||||
error!(%status, body = %body, "quickwit search request failed");
|
|
||||||
return Err(anyhow!(
|
|
||||||
"quickwit search failed with status {status}: {body}"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let data: QuickwitSearchResponse = response.json().await?;
|
|
||||||
debug!("quickwit search response parsed successfully");
|
|
||||||
let QuickwitSearchResponse { hits } = data;
|
|
||||||
|
|
||||||
let total_hits = hits.len();
|
|
||||||
let mut seen = HashSet::new();
|
|
||||||
let mut doc_ids = Vec::with_capacity(total_hits);
|
|
||||||
|
|
||||||
for hit in hits {
|
|
||||||
if let Some(doc_id) = extract_document_id(&hit) {
|
|
||||||
if seen.insert(doc_id) {
|
|
||||||
doc_ids.push(doc_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
debug!(
|
|
||||||
total_hits = total_hits,
|
|
||||||
unique_ids = doc_ids.len(),
|
|
||||||
"quickwit search completed"
|
|
||||||
);
|
|
||||||
Ok(doc_ids)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn quickwit_index_template(index_id: &str) -> Value {
|
|
||||||
json!({
|
|
||||||
"version": "0.8",
|
|
||||||
"index_id": index_id,
|
|
||||||
"doc_mapping": {
|
|
||||||
"tokenizers": [
|
|
||||||
{
|
|
||||||
"name": "substring",
|
|
||||||
"type": "ngram",
|
|
||||||
"min_gram": 2,
|
|
||||||
"max_gram": 20,
|
|
||||||
"prefix_only": false
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"field_mappings": [
|
|
||||||
{ "name": "tenant_id", "type": "text", "stored": true },
|
|
||||||
{ "name": "document_id", "type": "text", "stored": true },
|
|
||||||
{ "name": "version_id", "type": "text", "stored": true },
|
|
||||||
{ "name": "title", "type": "text", "tokenizer": "substring", "stored": true },
|
|
||||||
{ "name": "text", "type": "text", "tokenizer": "substring", "record": "position" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"search_settings": {
|
|
||||||
"default_search_fields": ["title", "text"]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn ensure_quickwit_index(client: &Client, endpoint: &str, index_id: &str) -> Result<()> {
|
|
||||||
let base = endpoint.trim_end_matches('/');
|
|
||||||
let create_url = format!("{}/api/v1/indexes", base);
|
|
||||||
let payload = quickwit_index_template(index_id);
|
|
||||||
|
|
||||||
let response = client.post(&create_url).json(&payload).send().await?;
|
|
||||||
match response.status() {
|
|
||||||
status if status.is_success() => Ok(()),
|
|
||||||
StatusCode::CONFLICT => {
|
|
||||||
let lookup_url = format!("{}/api/v1/indexes/{}", base, index_id);
|
|
||||||
let lookup = client.get(&lookup_url).send().await?;
|
|
||||||
if lookup.status().is_success() {
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
let status = lookup.status();
|
|
||||||
let body = lookup.text().await.unwrap_or_default();
|
|
||||||
bail!("quickwit index lookup failed with status {status}: {body}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
status => {
|
|
||||||
let body = response.text().await.unwrap_or_default();
|
|
||||||
bail!("quickwit create index failed with status {status}: {body}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn delete_quickwit_index(client: &Client, endpoint: &str, index_id: &str) -> Result<()> {
|
|
||||||
let base = endpoint.trim_end_matches('/');
|
|
||||||
let url = format!("{}/api/v1/indexes/{}", base, index_id);
|
|
||||||
let response = client.delete(&url).send().await?;
|
|
||||||
match response.status() {
|
|
||||||
status if status.is_success() => Ok(()),
|
|
||||||
StatusCode::NOT_FOUND => Ok(()),
|
|
||||||
status => {
|
|
||||||
let body = response.text().await.unwrap_or_default();
|
|
||||||
bail!("quickwit delete index failed with status {status}: {body}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn extract_document_id(hit: &Value) -> Option<Uuid> {
|
|
||||||
for key in ["_source", "source", "fields", "stored_fields"] {
|
|
||||||
if let Some(value) = hit.get(key) {
|
|
||||||
if let Some(uuid) = extract_uuid_from_value(value) {
|
|
||||||
return Some(uuid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(value) = hit.get("document_id") {
|
|
||||||
if let Some(uuid) = extract_uuid_from_value(value) {
|
|
||||||
return Some(uuid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn extract_uuid_from_value(value: &Value) -> Option<Uuid> {
|
|
||||||
if let Some(obj) = value.as_object() {
|
|
||||||
if let Some(inner) = obj.get("document_id") {
|
|
||||||
return parse_uuid_value(inner);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(arr) = value.as_array() {
|
|
||||||
for item in arr {
|
|
||||||
if let Some(uuid) = extract_uuid_from_value(item) {
|
|
||||||
return Some(uuid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
parse_uuid_value(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn parse_uuid_value(value: &Value) -> Option<Uuid> {
|
|
||||||
if let Some(s) = value.as_str() {
|
|
||||||
return Uuid::parse_str(s).ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(arr) = value.as_array() {
|
|
||||||
for item in arr {
|
|
||||||
if let Some(uuid) = parse_uuid_value(item) {
|
|
||||||
return Some(uuid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct QuickwitSearchResponse {
|
|
||||||
#[serde(default)]
|
|
||||||
hits: Vec<Value>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
pub struct QuickwitIngestRecord {
|
|
||||||
pub document_id: Uuid,
|
|
||||||
pub version_id: Uuid,
|
|
||||||
pub tenant_id: Uuid,
|
|
||||||
pub title: String,
|
|
||||||
pub text: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn build_quickwit_ingest_record(
|
|
||||||
document: &Document,
|
|
||||||
version: &DocumentVersion,
|
|
||||||
tenant_id: Uuid,
|
|
||||||
text: &str,
|
|
||||||
) -> QuickwitIngestRecord {
|
|
||||||
QuickwitIngestRecord {
|
|
||||||
document_id: document.id,
|
|
||||||
version_id: version.id,
|
|
||||||
tenant_id,
|
|
||||||
title: document.title.to_lowercase(),
|
|
||||||
text: text.to_lowercase(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn quickwit_ingest(
|
|
||||||
client: &Client,
|
|
||||||
endpoint: &str,
|
|
||||||
index: &str,
|
|
||||||
records: &[QuickwitIngestRecord],
|
|
||||||
) -> Result<()> {
|
|
||||||
if records.is_empty() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let url = format!(
|
|
||||||
"{}/api/v1/{}/ingest?commit=auto",
|
|
||||||
endpoint.trim_end_matches('/'),
|
|
||||||
index
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut body = String::new();
|
|
||||||
for record in records {
|
|
||||||
let line = serde_json::to_string(record)?;
|
|
||||||
body.push_str(&line);
|
|
||||||
body.push('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
debug!(%url, lines = records.len(), "sending quickwit ingest request");
|
|
||||||
let response = client
|
|
||||||
.post(url)
|
|
||||||
.header("content-type", "application/x-ndjson")
|
|
||||||
.body(body)
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if !response.status().is_success() {
|
|
||||||
let status = response.status();
|
|
||||||
let body = response.text().await.unwrap_or_default();
|
|
||||||
error!(%status, %body, "quickwit ingest request failed");
|
|
||||||
return Err(anyhow!(
|
|
||||||
"quickwit ingest failed with status {status}: {body}"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
debug!("quickwit ingest request succeeded");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use diesel::prelude::*;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::error::{AppError, AppResult};
|
|
||||||
use crate::models::{Document, NewDocumentTag, Tag};
|
|
||||||
use crate::schema::{document_tags, tags};
|
|
||||||
|
|
||||||
pub fn assign_tags(
|
|
||||||
conn: &mut PgConnection,
|
|
||||||
tenant_id: Uuid,
|
|
||||||
document: &Document,
|
|
||||||
raw_tag_ids: &[Uuid],
|
|
||||||
assigned_by: Option<Uuid>,
|
|
||||||
) -> AppResult<usize> {
|
|
||||||
if raw_tag_ids.is_empty() {
|
|
||||||
return Ok(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut tag_ids: Vec<Uuid> = raw_tag_ids.iter().copied().collect();
|
|
||||||
tag_ids.sort_unstable();
|
|
||||||
tag_ids.dedup();
|
|
||||||
|
|
||||||
if tag_ids.is_empty() {
|
|
||||||
return Ok(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
let existing: Vec<Uuid> = tags::table
|
|
||||||
.filter(tags::id.eq_any(&tag_ids))
|
|
||||||
.filter(tags::tenant_id.eq(tenant_id))
|
|
||||||
.select(tags::id)
|
|
||||||
.load(conn)?;
|
|
||||||
|
|
||||||
if existing.len() != tag_ids.len() {
|
|
||||||
return Err(AppError::bad_request("one or more tags do not exist"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let new_tags: Vec<NewDocumentTag> = tag_ids
|
|
||||||
.into_iter()
|
|
||||||
.map(|tag_id| NewDocumentTag {
|
|
||||||
document_id: document.id,
|
|
||||||
tag_id,
|
|
||||||
assigned_by,
|
|
||||||
tenant_id,
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
if new_tags.is_empty() {
|
|
||||||
return Ok(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
let inserted = diesel::insert_into(document_tags::table)
|
|
||||||
.values(&new_tags)
|
|
||||||
.on_conflict_do_nothing()
|
|
||||||
.execute(conn)?;
|
|
||||||
|
|
||||||
Ok(inserted)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn load_tags_for_documents(
|
|
||||||
conn: &mut PgConnection,
|
|
||||||
document_ids: &[Uuid],
|
|
||||||
) -> AppResult<HashMap<Uuid, Vec<Tag>>> {
|
|
||||||
if document_ids.is_empty() {
|
|
||||||
return Ok(HashMap::new());
|
|
||||||
}
|
|
||||||
|
|
||||||
let rows: Vec<(Uuid, Tag)> = document_tags::table
|
|
||||||
.inner_join(tags::table)
|
|
||||||
.filter(document_tags::document_id.eq_any(document_ids))
|
|
||||||
.select((document_tags::document_id, tags::all_columns))
|
|
||||||
.load(conn)?;
|
|
||||||
|
|
||||||
let mut map: HashMap<Uuid, Vec<Tag>> = HashMap::new();
|
|
||||||
for (doc_id, tag) in rows {
|
|
||||||
map.entry(doc_id).or_default().push(tag);
|
|
||||||
}
|
|
||||||
Ok(map)
|
|
||||||
}
|
|
||||||
+5
-45
@@ -4,9 +4,7 @@ use axum::{
|
|||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use serde_json::Value;
|
use std::fmt::Display;
|
||||||
use std::fmt::{self, Display};
|
|
||||||
use utoipa::ToSchema;
|
|
||||||
|
|
||||||
pub type AppResult<T> = Result<T, AppError>;
|
pub type AppResult<T> = Result<T, AppError>;
|
||||||
|
|
||||||
@@ -14,8 +12,6 @@ pub type AppResult<T> = Result<T, AppError>;
|
|||||||
pub struct AppError {
|
pub struct AppError {
|
||||||
status: StatusCode,
|
status: StatusCode,
|
||||||
message: String,
|
message: String,
|
||||||
code: Option<String>,
|
|
||||||
details: Option<Value>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppError {
|
impl AppError {
|
||||||
@@ -23,8 +19,6 @@ impl AppError {
|
|||||||
Self {
|
Self {
|
||||||
status,
|
status,
|
||||||
message: message.into(),
|
message: message.into(),
|
||||||
code: None,
|
|
||||||
details: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,18 +26,10 @@ impl AppError {
|
|||||||
Self::new(StatusCode::BAD_REQUEST, message)
|
Self::new(StatusCode::BAD_REQUEST, message)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn conflict(message: impl Into<String>) -> Self {
|
|
||||||
Self::new(StatusCode::CONFLICT, message)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn unauthorized() -> Self {
|
pub fn unauthorized() -> Self {
|
||||||
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")
|
||||||
}
|
}
|
||||||
@@ -51,54 +37,28 @@ impl AppError {
|
|||||||
pub fn internal<E: Display>(error: E) -> Self {
|
pub fn internal<E: Display>(error: E) -> Self {
|
||||||
Self::new(StatusCode::INTERNAL_SERVER_ERROR, error.to_string())
|
Self::new(StatusCode::INTERNAL_SERVER_ERROR, error.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn with_code(mut self, code: impl Into<String>) -> Self {
|
|
||||||
self.code = Some(code.into());
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_details(mut self, details: Value) -> Self {
|
|
||||||
self.details = Some(details);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for AppError {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
write!(f, "{}: {}", self.status, self.message)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IntoResponse for AppError {
|
impl IntoResponse for AppError {
|
||||||
fn into_response(self) -> Response {
|
fn into_response(self) -> Response {
|
||||||
let status = self.status;
|
let status = self.status;
|
||||||
let body = Json(ApiErrorResponse {
|
let body = Json(ErrorResponse {
|
||||||
error: self.message,
|
error: self.message,
|
||||||
code: self.code,
|
|
||||||
details: self.details,
|
|
||||||
});
|
});
|
||||||
(status, body).into_response()
|
(status, body).into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize)]
|
||||||
pub struct ApiErrorResponse {
|
struct ErrorResponse {
|
||||||
error: String,
|
error: String,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
code: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
details: Option<Value>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<diesel::result::Error> for AppError {
|
impl From<diesel::result::Error> for AppError {
|
||||||
fn from(value: diesel::result::Error) -> Self {
|
fn from(value: diesel::result::Error) -> Self {
|
||||||
match value {
|
match value {
|
||||||
diesel::result::Error::NotFound => AppError::not_found(),
|
diesel::result::Error::NotFound => AppError::not_found(),
|
||||||
other => {
|
_ => AppError::internal(value),
|
||||||
let message = format!("database operation failed: {other}");
|
|
||||||
tracing::error!(error = ?other, message);
|
|
||||||
AppError::internal(message)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
pub mod responders;
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
use axum::{
|
|
||||||
http::StatusCode,
|
|
||||||
response::{IntoResponse, Response},
|
|
||||||
Json,
|
|
||||||
};
|
|
||||||
use serde::Serialize;
|
|
||||||
|
|
||||||
use crate::error::{AppError, AppResult};
|
|
||||||
|
|
||||||
/// Helper trait to convert error-centric results into the application's error type.
|
|
||||||
pub trait IntoAppResult<T> {
|
|
||||||
fn into_app_result(self) -> AppResult<T>;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T, E> IntoAppResult<T> for Result<T, E>
|
|
||||||
where
|
|
||||||
AppError: From<E>,
|
|
||||||
{
|
|
||||||
fn into_app_result(self) -> AppResult<T> {
|
|
||||||
self.map_err(AppError::from)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extension helpers for optional values to map them into `AppResult`.
|
|
||||||
pub trait OptionAppResultExt<T> {
|
|
||||||
fn or_not_found(self) -> AppResult<T>;
|
|
||||||
fn or_bad_request(self, message: impl Into<String>) -> AppResult<T>;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T> OptionAppResultExt<T> for Option<T> {
|
|
||||||
fn or_not_found(self) -> AppResult<T> {
|
|
||||||
self.ok_or_else(AppError::not_found)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn or_bad_request(self, message: impl Into<String>) -> AppResult<T> {
|
|
||||||
self.ok_or_else(|| AppError::bad_request(message))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Provides helpers for statements returning number of affected rows.
|
|
||||||
pub trait RowsAffectedExt: Sized {
|
|
||||||
fn or_error(self, error: AppError) -> AppResult<usize>;
|
|
||||||
fn or_not_found(self) -> AppResult<usize> {
|
|
||||||
self.or_error(AppError::not_found())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RowsAffectedExt for usize {
|
|
||||||
fn or_error(self, error: AppError) -> AppResult<usize> {
|
|
||||||
if self == 0 {
|
|
||||||
Err(error)
|
|
||||||
} else {
|
|
||||||
Ok(self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Wrapper providing a consistent JSON response with a status code.
|
|
||||||
pub struct JsonResponse<T> {
|
|
||||||
status: StatusCode,
|
|
||||||
payload: T,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T> JsonResponse<T> {
|
|
||||||
pub fn new(status: StatusCode, payload: T) -> Self {
|
|
||||||
Self { status, payload }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn ok(payload: T) -> Self {
|
|
||||||
Self::new(StatusCode::OK, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn created(payload: T) -> Self {
|
|
||||||
Self::new(StatusCode::CREATED, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn accepted(payload: T) -> Self {
|
|
||||||
Self::new(StatusCode::ACCEPTED, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn into_inner(self) -> T {
|
|
||||||
self.payload
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn as_inner(&self) -> &T {
|
|
||||||
&self.payload
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T> From<T> for JsonResponse<T> {
|
|
||||||
fn from(value: T) -> Self {
|
|
||||||
Self::ok(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T> IntoResponse for JsonResponse<T>
|
|
||||||
where
|
|
||||||
T: Serialize,
|
|
||||||
{
|
|
||||||
fn into_response(self) -> Response {
|
|
||||||
(self.status, Json(self.payload)).into_response()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Helper for returning empty responses with a status code.
|
|
||||||
pub fn empty(status: StatusCode) -> AppResult<StatusCode> {
|
|
||||||
Ok(status)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Helper for returning `204 No Content`.
|
|
||||||
pub fn no_content() -> AppResult<StatusCode> {
|
|
||||||
empty(StatusCode::NO_CONTENT)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Helper for returning JSON payloads with `200 OK`.
|
|
||||||
pub fn ok_json<T>(value: T) -> AppResult<JsonResponse<T>>
|
|
||||||
where
|
|
||||||
T: Serialize,
|
|
||||||
{
|
|
||||||
Ok(JsonResponse::ok(value))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Helper for returning JSON payloads with `201 Created`.
|
|
||||||
pub fn created_json<T>(value: T) -> AppResult<JsonResponse<T>>
|
|
||||||
where
|
|
||||||
T: Serialize,
|
|
||||||
{
|
|
||||||
Ok(JsonResponse::created(value))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Helper for returning JSON payloads with `202 Accepted`.
|
|
||||||
pub fn accepted_json<T>(value: T) -> AppResult<JsonResponse<T>>
|
|
||||||
where
|
|
||||||
T: Serialize,
|
|
||||||
{
|
|
||||||
Ok(JsonResponse::accepted(value))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Standard wrapper for paginated responses.
|
|
||||||
#[derive(Serialize)]
|
|
||||||
pub struct PaginatedResponse<T, M>
|
|
||||||
where
|
|
||||||
T: Serialize,
|
|
||||||
M: Serialize,
|
|
||||||
{
|
|
||||||
pub data: T,
|
|
||||||
pub meta: M,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn paginated_json<T, M>(data: T, meta: M) -> AppResult<JsonResponse<PaginatedResponse<T, M>>>
|
|
||||||
where
|
|
||||||
T: Serialize,
|
|
||||||
M: Serialize,
|
|
||||||
{
|
|
||||||
let payload = PaginatedResponse { data, meta };
|
|
||||||
Ok(JsonResponse::ok(payload))
|
|
||||||
}
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
use std::collections::HashSet;
|
|
||||||
|
|
||||||
use chrono::{DateTime, Datelike, NaiveDate, TimeZone, Utc};
|
|
||||||
use chrono_tz::Tz;
|
|
||||||
use once_cell::sync::Lazy;
|
|
||||||
use tracing::warn;
|
|
||||||
|
|
||||||
use crate::config::AppConfig;
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug)]
|
|
||||||
pub enum DateOrder {
|
|
||||||
Dmy,
|
|
||||||
Mdy,
|
|
||||||
Ymd,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DateOrder {
|
|
||||||
pub fn parse(value: &str) -> Self {
|
|
||||||
Self::try_parse(value).unwrap_or(DateOrder::Dmy)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn try_parse(value: &str) -> Option<Self> {
|
|
||||||
match value.trim().to_ascii_uppercase().as_str() {
|
|
||||||
"YMD" => Some(DateOrder::Ymd),
|
|
||||||
"MDY" => Some(DateOrder::Mdy),
|
|
||||||
"DMY" => Some(DateOrder::Dmy),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static MIN_ISSUED_AT_DATE: Lazy<NaiveDate> =
|
|
||||||
Lazy::new(|| NaiveDate::from_ymd_opt(1901, 1, 1).expect("valid minimum issued_at date"));
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct IssuedAtSettings {
|
|
||||||
pub timezone: Tz,
|
|
||||||
pub date_order: DateOrder,
|
|
||||||
pub filename_date_order: Option<DateOrder>,
|
|
||||||
pub locales: HashSet<String>,
|
|
||||||
pub ignore_dates: HashSet<NaiveDate>,
|
|
||||||
pub min_date: NaiveDate,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl IssuedAtSettings {
|
|
||||||
pub fn from_config(config: &AppConfig) -> Self {
|
|
||||||
let timezone = config.service_timezone.parse::<Tz>().unwrap_or_else(|_| {
|
|
||||||
warn!(
|
|
||||||
timezone = %config.service_timezone,
|
|
||||||
"invalid service timezone configured; falling back to UTC"
|
|
||||||
);
|
|
||||||
chrono_tz::UTC
|
|
||||||
});
|
|
||||||
|
|
||||||
let date_order = DateOrder::parse(&config.issued_at_date_order);
|
|
||||||
let filename_date_order =
|
|
||||||
config
|
|
||||||
.issued_at_filename_date_order
|
|
||||||
.as_deref()
|
|
||||||
.and_then(|value| {
|
|
||||||
DateOrder::try_parse(value).or_else(|| {
|
|
||||||
warn!(value, "invalid issued_at filename date order; ignoring");
|
|
||||||
None
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
let locales = config
|
|
||||||
.issued_at_date_parser_locales
|
|
||||||
.iter()
|
|
||||||
.filter_map(|value| {
|
|
||||||
let trimmed = value.trim();
|
|
||||||
if trimmed.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(trimmed.to_ascii_lowercase())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect::<HashSet<_>>();
|
|
||||||
|
|
||||||
// Ignore dates are evaluated after normalizing candidate timestamps to
|
|
||||||
// the configured service timezone, so administrators should provide
|
|
||||||
// local calendar dates rather than UTC midnights.
|
|
||||||
let ignore_dates = config
|
|
||||||
.issued_at_ignore_dates
|
|
||||||
.iter()
|
|
||||||
.filter_map(|value| {
|
|
||||||
let trimmed = value.trim();
|
|
||||||
if trimmed.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
match NaiveDate::parse_from_str(trimmed, "%Y-%m-%d") {
|
|
||||||
Ok(date) => Some(date),
|
|
||||||
Err(err) => {
|
|
||||||
warn!(value = trimmed, error = %err, "invalid issued_at ignore date");
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Self {
|
|
||||||
timezone,
|
|
||||||
date_order,
|
|
||||||
filename_date_order,
|
|
||||||
locales,
|
|
||||||
ignore_dates,
|
|
||||||
min_date: *MIN_ISSUED_AT_DATE,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the immutable (lowercase) locale allowlist supplied via config.
|
|
||||||
pub fn locales(&self) -> &HashSet<String> {
|
|
||||||
&self.locales
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the immutable set of local-calendar dates that should be ignored.
|
|
||||||
pub fn ignore_dates(&self) -> &HashSet<NaiveDate> {
|
|
||||||
&self.ignore_dates
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the configured service timezone (copy type).
|
|
||||||
pub fn timezone(&self) -> Tz {
|
|
||||||
self.timezone
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn date_order(&self) -> DateOrder {
|
|
||||||
self.date_order
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn filename_date_order(&self) -> Option<DateOrder> {
|
|
||||||
self.filename_date_order
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn normalize_naive(
|
|
||||||
&self,
|
|
||||||
date: NaiveDate,
|
|
||||||
now_utc: chrono::DateTime<Utc>,
|
|
||||||
) -> Option<DateTime<Utc>> {
|
|
||||||
if !self.is_valid_with_now(date, now_utc) {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
self.timezone
|
|
||||||
.with_ymd_and_hms(date.year(), date.month(), date.day(), 0, 0, 0)
|
|
||||||
.earliest()
|
|
||||||
.map(|dt| dt.with_timezone(&Utc))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn normalize_datetime(
|
|
||||||
&self,
|
|
||||||
dt: chrono::DateTime<Utc>,
|
|
||||||
now_utc: chrono::DateTime<Utc>,
|
|
||||||
) -> Option<DateTime<Utc>> {
|
|
||||||
let local_date = dt.with_timezone(&self.timezone).date_naive();
|
|
||||||
self.normalize_naive(local_date, now_utc)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_valid_with_now(&self, date: NaiveDate, now_utc: chrono::DateTime<Utc>) -> bool {
|
|
||||||
if date < self.min_date {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
let now_local = now_utc.with_timezone(&self.timezone).date_naive();
|
|
||||||
if date > now_local {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
!self.ignore_dates.contains(&date)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -17,10 +17,6 @@ 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_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 {
|
||||||
@@ -32,7 +28,6 @@ pub type JobQueueResult<T> = Result<T, JobQueueError>;
|
|||||||
|
|
||||||
pub fn enqueue_job(
|
pub fn enqueue_job(
|
||||||
conn: &mut PgConnection,
|
conn: &mut PgConnection,
|
||||||
tenant_id: Uuid,
|
|
||||||
job_type: &str,
|
job_type: &str,
|
||||||
payload: Value,
|
payload: Value,
|
||||||
run_after: Option<NaiveDateTime>,
|
run_after: Option<NaiveDateTime>,
|
||||||
@@ -43,7 +38,6 @@ pub fn enqueue_job(
|
|||||||
payload,
|
payload,
|
||||||
status: STATUS_QUEUED.to_string(),
|
status: STATUS_QUEUED.to_string(),
|
||||||
run_after: run_after.unwrap_or_else(|| Utc::now().naive_utc()),
|
run_after: run_after.unwrap_or_else(|| Utc::now().naive_utc()),
|
||||||
tenant_id,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
diesel::insert_into(jobs::table)
|
diesel::insert_into(jobs::table)
|
||||||
|
|||||||
@@ -1,22 +1,13 @@
|
|||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod db;
|
pub mod db;
|
||||||
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 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 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;
|
|
||||||
|
|||||||
+31
-12
@@ -1,28 +1,47 @@
|
|||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use tower::make::Shared;
|
use tower::make::Shared;
|
||||||
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
use papercrate::{routes, utils::bootstrap::init_component};
|
use paperless_backend::auth::jwt::JwtService;
|
||||||
|
use paperless_backend::config::AppConfig;
|
||||||
|
use paperless_backend::db;
|
||||||
|
use paperless_backend::routes;
|
||||||
|
use paperless_backend::s3::build_client;
|
||||||
|
use paperless_backend::state::AppState;
|
||||||
|
use paperless_backend::storage::S3Storage;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> anyhow::Result<()> {
|
async fn main() -> anyhow::Result<()> {
|
||||||
let state = init_component("api", None).await?;
|
dotenv::dotenv().ok();
|
||||||
let server_host = state.config.server_host.clone();
|
init_tracing();
|
||||||
let server_port = state.config.server_port;
|
|
||||||
tracing::info!(
|
|
||||||
component = "api",
|
|
||||||
server_host = %server_host,
|
|
||||||
server_port,
|
|
||||||
"starting api server"
|
|
||||||
);
|
|
||||||
|
|
||||||
let router = routes::create_router(state.as_ref().clone());
|
let config = AppConfig::from_env()?;
|
||||||
|
let pool = db::init_pool(&config.database_url)?;
|
||||||
|
let s3_client = build_client(&config).await?;
|
||||||
|
let storage = Arc::new(S3Storage::new(s3_client, config.s3_bucket.clone()));
|
||||||
|
let jwt = JwtService::from_config(&config)?;
|
||||||
|
|
||||||
let addr: SocketAddr = format!("{}:{}", server_host, server_port).parse()?;
|
let state = AppState::new(pool, config, storage, jwt);
|
||||||
|
|
||||||
|
let router = routes::create_router(state.clone());
|
||||||
|
|
||||||
|
let addr: SocketAddr =
|
||||||
|
format!("{}:{}", state.config.server_host, state.config.server_port).parse()?;
|
||||||
let listener = TcpListener::bind(addr).await?;
|
let listener = TcpListener::bind(addr).await?;
|
||||||
tracing::info!("listening on {}", addr);
|
tracing::info!("listening on {}", addr);
|
||||||
|
|
||||||
axum::serve(listener, Shared::new(router)).await?;
|
axum::serve(listener, Shared::new(router)).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn init_tracing() {
|
||||||
|
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(filter)
|
||||||
|
.with_target(false)
|
||||||
|
.compact()
|
||||||
|
.init();
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
use diesel_migrations::{embed_migrations, EmbeddedMigrations};
|
|
||||||
|
|
||||||
pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
|
|
||||||
+20
-602
@@ -1,378 +1,16 @@
|
|||||||
use chrono::NaiveDateTime;
|
use chrono::NaiveDateTime;
|
||||||
use diesel::deserialize::FromSql;
|
|
||||||
use diesel::pg::{Pg, PgValue};
|
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
use diesel::serialize::{IsNull, Output, ToSql};
|
|
||||||
use diesel::{deserialize, serialize, AsExpression, FromSqlRow};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use serde_json::Value;
|
|
||||||
use std::fmt;
|
|
||||||
use std::io::Write;
|
|
||||||
use std::str;
|
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use utoipa::ToSchema;
|
|
||||||
|
|
||||||
use crate::schema::sql_types::{
|
|
||||||
ApiCapability as ApiCapabilitySql, MagicTokenKind as MagicTokenKindSql,
|
|
||||||
TenantStatus as TenantStatusSql,
|
|
||||||
};
|
|
||||||
use crate::schema::*;
|
use crate::schema::*;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
|
||||||
#[diesel(table_name = user_memberships)]
|
|
||||||
#[diesel(belongs_to(User, foreign_key = user_id))]
|
|
||||||
#[diesel(belongs_to(Tenant, foreign_key = tenant_id))]
|
|
||||||
pub struct UserMembership {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub user_id: Uuid,
|
|
||||||
pub tenant_id: Uuid,
|
|
||||||
pub created_at: NaiveDateTime,
|
|
||||||
pub updated_at: NaiveDateTime,
|
|
||||||
pub capability_set_id: Option<Uuid>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Insertable)]
|
|
||||||
#[diesel(table_name = user_memberships)]
|
|
||||||
pub struct NewUserMembership {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub user_id: Uuid,
|
|
||||||
pub tenant_id: Uuid,
|
|
||||||
pub capability_set_id: Option<Uuid>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, AsExpression, FromSqlRow)]
|
|
||||||
#[diesel(sql_type = TenantStatusSql)]
|
|
||||||
pub enum TenantStatus {
|
|
||||||
Creating,
|
|
||||||
Active,
|
|
||||||
Suspended,
|
|
||||||
Deleting,
|
|
||||||
Error,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, AsExpression, FromSqlRow)]
|
|
||||||
#[diesel(sql_type = MagicTokenKindSql)]
|
|
||||||
pub enum MagicTokenKind {
|
|
||||||
EmailLogin,
|
|
||||||
DemoLogin,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(
|
|
||||||
Debug,
|
|
||||||
Clone,
|
|
||||||
Copy,
|
|
||||||
PartialEq,
|
|
||||||
Eq,
|
|
||||||
Hash,
|
|
||||||
AsExpression,
|
|
||||||
FromSqlRow,
|
|
||||||
Serialize,
|
|
||||||
Deserialize,
|
|
||||||
ToSchema,
|
|
||||||
)]
|
|
||||||
#[diesel(sql_type = ApiCapabilitySql)]
|
|
||||||
pub enum ApiCapability {
|
|
||||||
#[serde(rename = "documents:read")]
|
|
||||||
DocumentsRead,
|
|
||||||
#[serde(rename = "documents:edit")]
|
|
||||||
DocumentsEdit,
|
|
||||||
#[serde(rename = "documents:write")]
|
|
||||||
DocumentsWrite,
|
|
||||||
#[serde(rename = "documents:upload")]
|
|
||||||
DocumentsUpload,
|
|
||||||
#[serde(rename = "folders:read")]
|
|
||||||
FoldersRead,
|
|
||||||
#[serde(rename = "folders:edit")]
|
|
||||||
FoldersEdit,
|
|
||||||
#[serde(rename = "folders:write")]
|
|
||||||
FoldersWrite,
|
|
||||||
#[serde(rename = "tags:read")]
|
|
||||||
TagsRead,
|
|
||||||
#[serde(rename = "tags:edit")]
|
|
||||||
TagsEdit,
|
|
||||||
#[serde(rename = "tags:write")]
|
|
||||||
TagsWrite,
|
|
||||||
#[serde(rename = "correspondents:read")]
|
|
||||||
CorrespondentsRead,
|
|
||||||
#[serde(rename = "correspondents:edit")]
|
|
||||||
CorrespondentsEdit,
|
|
||||||
#[serde(rename = "correspondents:write")]
|
|
||||||
CorrespondentsWrite,
|
|
||||||
#[serde(rename = "profile:read")]
|
|
||||||
ProfileRead,
|
|
||||||
#[serde(rename = "profile:write")]
|
|
||||||
ProfileWrite,
|
|
||||||
#[serde(rename = "webdav:read")]
|
|
||||||
WebdavRead,
|
|
||||||
#[serde(rename = "webdav:write")]
|
|
||||||
WebdavWrite,
|
|
||||||
#[serde(rename = "capability_sets:read")]
|
|
||||||
CapabilitySetsRead,
|
|
||||||
#[serde(rename = "capability_sets:write")]
|
|
||||||
CapabilitySetsWrite,
|
|
||||||
#[serde(rename = "tenants:write")]
|
|
||||||
TenantsWrite,
|
|
||||||
#[serde(rename = "tenants:reset")]
|
|
||||||
TenantsReset,
|
|
||||||
#[serde(rename = "tenants:delete")]
|
|
||||||
TenantsDelete,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MagicTokenKind {
|
|
||||||
pub fn as_str(&self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
MagicTokenKind::EmailLogin => "email_login",
|
|
||||||
MagicTokenKind::DemoLogin => "demo_login",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn variants() -> &'static [&'static str] {
|
|
||||||
&["email_login", "demo_login"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ApiCapability {
|
|
||||||
pub fn as_str(&self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
ApiCapability::DocumentsRead => "documents:read",
|
|
||||||
ApiCapability::DocumentsEdit => "documents:edit",
|
|
||||||
ApiCapability::DocumentsWrite => "documents:write",
|
|
||||||
ApiCapability::DocumentsUpload => "documents:upload",
|
|
||||||
ApiCapability::FoldersRead => "folders:read",
|
|
||||||
ApiCapability::FoldersEdit => "folders:edit",
|
|
||||||
ApiCapability::FoldersWrite => "folders:write",
|
|
||||||
ApiCapability::TagsRead => "tags:read",
|
|
||||||
ApiCapability::TagsEdit => "tags:edit",
|
|
||||||
ApiCapability::TagsWrite => "tags:write",
|
|
||||||
ApiCapability::CorrespondentsRead => "correspondents:read",
|
|
||||||
ApiCapability::CorrespondentsEdit => "correspondents:edit",
|
|
||||||
ApiCapability::CorrespondentsWrite => "correspondents:write",
|
|
||||||
ApiCapability::ProfileRead => "profile:read",
|
|
||||||
ApiCapability::ProfileWrite => "profile:write",
|
|
||||||
ApiCapability::WebdavRead => "webdav:read",
|
|
||||||
ApiCapability::WebdavWrite => "webdav:write",
|
|
||||||
ApiCapability::CapabilitySetsRead => "capability_sets:read",
|
|
||||||
ApiCapability::CapabilitySetsWrite => "capability_sets:write",
|
|
||||||
ApiCapability::TenantsWrite => "tenants:write",
|
|
||||||
ApiCapability::TenantsReset => "tenants:reset",
|
|
||||||
ApiCapability::TenantsDelete => "tenants:delete",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn variants() -> &'static [&'static str] {
|
|
||||||
&[
|
|
||||||
"documents:read",
|
|
||||||
"documents:edit",
|
|
||||||
"documents:write",
|
|
||||||
"documents:upload",
|
|
||||||
"folders:read",
|
|
||||||
"folders:edit",
|
|
||||||
"folders:write",
|
|
||||||
"tags:read",
|
|
||||||
"tags:edit",
|
|
||||||
"tags:write",
|
|
||||||
"correspondents:read",
|
|
||||||
"correspondents:edit",
|
|
||||||
"correspondents:write",
|
|
||||||
"profile:read",
|
|
||||||
"profile:write",
|
|
||||||
"webdav:read",
|
|
||||||
"webdav:write",
|
|
||||||
"capability_sets:read",
|
|
||||||
"capability_sets:write",
|
|
||||||
"tenants:write",
|
|
||||||
"tenants:reset",
|
|
||||||
"tenants:delete",
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for MagicTokenKind {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
write!(f, "{}", self.as_str())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for ApiCapability {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
write!(f, "{}", self.as_str())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ToSql<MagicTokenKindSql, Pg> for MagicTokenKind {
|
|
||||||
fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result {
|
|
||||||
out.write_all(self.as_str().as_bytes())?;
|
|
||||||
Ok(IsNull::No)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ToSql<ApiCapabilitySql, Pg> for ApiCapability {
|
|
||||||
fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result {
|
|
||||||
out.write_all(self.as_str().as_bytes())?;
|
|
||||||
Ok(IsNull::No)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FromSql<MagicTokenKindSql, Pg> for MagicTokenKind {
|
|
||||||
fn from_sql(bytes: PgValue<'_>) -> deserialize::Result<Self> {
|
|
||||||
match std::str::from_utf8(bytes.as_bytes())? {
|
|
||||||
"email_login" => Ok(MagicTokenKind::EmailLogin),
|
|
||||||
"demo_login" => Ok(MagicTokenKind::DemoLogin),
|
|
||||||
other => Err(Box::new(std::io::Error::new(
|
|
||||||
std::io::ErrorKind::InvalidData,
|
|
||||||
format!("invalid magic_token_kind '{other}'"),
|
|
||||||
))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FromSql<ApiCapabilitySql, Pg> for ApiCapability {
|
|
||||||
fn from_sql(bytes: PgValue<'_>) -> deserialize::Result<Self> {
|
|
||||||
match std::str::from_utf8(bytes.as_bytes())? {
|
|
||||||
"documents:read" => Ok(ApiCapability::DocumentsRead),
|
|
||||||
"documents:edit" => Ok(ApiCapability::DocumentsEdit),
|
|
||||||
"documents:write" => Ok(ApiCapability::DocumentsWrite),
|
|
||||||
"documents:upload" => Ok(ApiCapability::DocumentsUpload),
|
|
||||||
"folders:read" => Ok(ApiCapability::FoldersRead),
|
|
||||||
"folders:edit" => Ok(ApiCapability::FoldersEdit),
|
|
||||||
"folders:write" => Ok(ApiCapability::FoldersWrite),
|
|
||||||
"tags:read" => Ok(ApiCapability::TagsRead),
|
|
||||||
"tags:edit" => Ok(ApiCapability::TagsEdit),
|
|
||||||
"tags:write" => Ok(ApiCapability::TagsWrite),
|
|
||||||
"correspondents:read" => Ok(ApiCapability::CorrespondentsRead),
|
|
||||||
"correspondents:edit" => Ok(ApiCapability::CorrespondentsEdit),
|
|
||||||
"correspondents:write" => Ok(ApiCapability::CorrespondentsWrite),
|
|
||||||
"profile:read" => Ok(ApiCapability::ProfileRead),
|
|
||||||
"profile:write" => Ok(ApiCapability::ProfileWrite),
|
|
||||||
"webdav:read" => Ok(ApiCapability::WebdavRead),
|
|
||||||
"webdav:write" => Ok(ApiCapability::WebdavWrite),
|
|
||||||
"capability_sets:read" => Ok(ApiCapability::CapabilitySetsRead),
|
|
||||||
"capability_sets:write" => Ok(ApiCapability::CapabilitySetsWrite),
|
|
||||||
"tenants:write" => Ok(ApiCapability::TenantsWrite),
|
|
||||||
"tenants:reset" => Ok(ApiCapability::TenantsReset),
|
|
||||||
"tenants:delete" => Ok(ApiCapability::TenantsDelete),
|
|
||||||
other => Err(Box::new(std::io::Error::new(
|
|
||||||
std::io::ErrorKind::InvalidData,
|
|
||||||
format!("invalid api_capability '{other}'"),
|
|
||||||
))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl str::FromStr for MagicTokenKind {
|
|
||||||
type Err = &'static str;
|
|
||||||
|
|
||||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
|
||||||
match value {
|
|
||||||
"email_login" => Ok(MagicTokenKind::EmailLogin),
|
|
||||||
"demo_login" => Ok(MagicTokenKind::DemoLogin),
|
|
||||||
_ => Err("unsupported magic token kind"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl str::FromStr for ApiCapability {
|
|
||||||
type Err = &'static str;
|
|
||||||
|
|
||||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
|
||||||
match value {
|
|
||||||
"documents:read" => Ok(ApiCapability::DocumentsRead),
|
|
||||||
"documents:edit" => Ok(ApiCapability::DocumentsEdit),
|
|
||||||
"documents:write" => Ok(ApiCapability::DocumentsWrite),
|
|
||||||
"documents:upload" => Ok(ApiCapability::DocumentsUpload),
|
|
||||||
"folders:read" => Ok(ApiCapability::FoldersRead),
|
|
||||||
"folders:edit" => Ok(ApiCapability::FoldersEdit),
|
|
||||||
"folders:write" => Ok(ApiCapability::FoldersWrite),
|
|
||||||
"tags:read" => Ok(ApiCapability::TagsRead),
|
|
||||||
"tags:edit" => Ok(ApiCapability::TagsEdit),
|
|
||||||
"tags:write" => Ok(ApiCapability::TagsWrite),
|
|
||||||
"correspondents:read" => Ok(ApiCapability::CorrespondentsRead),
|
|
||||||
"correspondents:edit" => Ok(ApiCapability::CorrespondentsEdit),
|
|
||||||
"correspondents:write" => Ok(ApiCapability::CorrespondentsWrite),
|
|
||||||
"profile:read" => Ok(ApiCapability::ProfileRead),
|
|
||||||
"profile:write" => Ok(ApiCapability::ProfileWrite),
|
|
||||||
"webdav:read" => Ok(ApiCapability::WebdavRead),
|
|
||||||
"webdav:write" => Ok(ApiCapability::WebdavWrite),
|
|
||||||
"capability_sets:read" => Ok(ApiCapability::CapabilitySetsRead),
|
|
||||||
"capability_sets:write" => Ok(ApiCapability::CapabilitySetsWrite),
|
|
||||||
"tenants:write" => Ok(ApiCapability::TenantsWrite),
|
|
||||||
"tenants:reset" => Ok(ApiCapability::TenantsReset),
|
|
||||||
"tenants:delete" => Ok(ApiCapability::TenantsDelete),
|
|
||||||
_ => Err("unsupported api capability"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TenantStatus {
|
|
||||||
pub fn as_str(&self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
TenantStatus::Creating => "creating",
|
|
||||||
TenantStatus::Active => "active",
|
|
||||||
TenantStatus::Suspended => "suspended",
|
|
||||||
TenantStatus::Deleting => "deleting",
|
|
||||||
TenantStatus::Error => "error",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn from_str(value: &str) -> Option<Self> {
|
|
||||||
match value {
|
|
||||||
"creating" => Some(TenantStatus::Creating),
|
|
||||||
"active" => Some(TenantStatus::Active),
|
|
||||||
"suspended" => Some(TenantStatus::Suspended),
|
|
||||||
"deleting" => Some(TenantStatus::Deleting),
|
|
||||||
"error" => Some(TenantStatus::Error),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for TenantStatus {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
write!(f, "{}", self.as_str())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ToSql<TenantStatusSql, Pg> for TenantStatus {
|
|
||||||
fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result {
|
|
||||||
out.write_all(self.as_str().as_bytes())?;
|
|
||||||
Ok(IsNull::No)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FromSql<TenantStatusSql, Pg> for TenantStatus {
|
|
||||||
fn from_sql(bytes: PgValue<'_>) -> deserialize::Result<Self> {
|
|
||||||
let value = str::from_utf8(bytes.as_bytes())
|
|
||||||
.map_err(|err| Box::<dyn std::error::Error + Send + Sync>::from(err))?;
|
|
||||||
TenantStatus::from_str(value).ok_or_else(|| {
|
|
||||||
Box::<dyn std::error::Error + Send + Sync>::from(std::io::Error::new(
|
|
||||||
std::io::ErrorKind::InvalidData,
|
|
||||||
format!("invalid tenant status '{value}'"),
|
|
||||||
))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Queryable, Identifiable)]
|
|
||||||
#[diesel(table_name = tenants)]
|
|
||||||
#[diesel(primary_key(id))]
|
|
||||||
pub struct Tenant {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub name: String,
|
|
||||||
pub storage_root: Option<String>,
|
|
||||||
pub quickwit_index: Option<String>,
|
|
||||||
pub config: Value,
|
|
||||||
pub created_at: NaiveDateTime,
|
|
||||||
pub updated_at: NaiveDateTime,
|
|
||||||
pub status: TenantStatus,
|
|
||||||
pub created_by: Option<Uuid>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Queryable, Identifiable)]
|
#[derive(Debug, Clone, Queryable, Identifiable)]
|
||||||
#[diesel(table_name = users)]
|
#[diesel(table_name = users)]
|
||||||
pub struct User {
|
pub struct User {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
|
pub password_hash: String,
|
||||||
|
pub role: String,
|
||||||
pub created_at: NaiveDateTime,
|
pub created_at: NaiveDateTime,
|
||||||
pub updated_at: NaiveDateTime,
|
pub updated_at: NaiveDateTime,
|
||||||
}
|
}
|
||||||
@@ -382,135 +20,8 @@ pub struct User {
|
|||||||
pub struct NewUser {
|
pub struct NewUser {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
}
|
pub password_hash: String,
|
||||||
|
pub role: String,
|
||||||
#[derive(Debug, Clone, Queryable, Identifiable, Associations, Selectable)]
|
|
||||||
#[diesel(table_name = user_passkeys)]
|
|
||||||
#[diesel(belongs_to(User))]
|
|
||||||
pub struct UserPasskey {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub user_id: Uuid,
|
|
||||||
pub credential_id: Vec<u8>,
|
|
||||||
pub public_key: Vec<u8>,
|
|
||||||
pub credential: serde_json::Value,
|
|
||||||
pub sign_count: i64,
|
|
||||||
pub transports: Vec<Option<String>>,
|
|
||||||
pub aaguid: Option<Uuid>,
|
|
||||||
pub nickname: Option<String>,
|
|
||||||
pub created_at: NaiveDateTime,
|
|
||||||
pub updated_at: NaiveDateTime,
|
|
||||||
pub last_used_at: Option<NaiveDateTime>,
|
|
||||||
pub revoked_at: Option<NaiveDateTime>,
|
|
||||||
pub revoked_by: Option<Uuid>,
|
|
||||||
pub revoked_reason: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Insertable)]
|
|
||||||
#[diesel(table_name = user_passkeys)]
|
|
||||||
pub struct NewUserPasskey {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub user_id: Uuid,
|
|
||||||
pub credential_id: Vec<u8>,
|
|
||||||
pub public_key: Vec<u8>,
|
|
||||||
pub credential: serde_json::Value,
|
|
||||||
pub sign_count: i64,
|
|
||||||
pub transports: Vec<Option<String>>,
|
|
||||||
pub aaguid: Option<Uuid>,
|
|
||||||
pub nickname: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
|
||||||
#[diesel(table_name = webauthn_challenges)]
|
|
||||||
#[diesel(belongs_to(User))]
|
|
||||||
pub struct WebauthnChallenge {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub user_id: Option<Uuid>,
|
|
||||||
pub purpose: String,
|
|
||||||
pub challenge: Vec<u8>,
|
|
||||||
pub state: Vec<u8>,
|
|
||||||
pub created_at: NaiveDateTime,
|
|
||||||
pub expires_at: NaiveDateTime,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Insertable)]
|
|
||||||
#[diesel(table_name = webauthn_challenges)]
|
|
||||||
pub struct NewWebauthnChallenge {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub user_id: Option<Uuid>,
|
|
||||||
pub purpose: String,
|
|
||||||
pub challenge: Vec<u8>,
|
|
||||||
pub state: Vec<u8>,
|
|
||||||
pub expires_at: NaiveDateTime,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
|
||||||
#[diesel(table_name = api_tokens)]
|
|
||||||
#[diesel(belongs_to(User))]
|
|
||||||
#[diesel(belongs_to(Tenant))]
|
|
||||||
pub struct ApiToken {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub user_id: Uuid,
|
|
||||||
pub tenant_id: Uuid,
|
|
||||||
pub token_prefix: String,
|
|
||||||
pub token_hash: String,
|
|
||||||
pub label: Option<String>,
|
|
||||||
pub created_at: NaiveDateTime,
|
|
||||||
pub last_used_at: Option<NaiveDateTime>,
|
|
||||||
pub expires_at: Option<NaiveDateTime>,
|
|
||||||
pub revoked_at: Option<NaiveDateTime>,
|
|
||||||
pub capability_set_id: Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
|
||||||
#[diesel(table_name = capability_sets)]
|
|
||||||
#[diesel(belongs_to(Tenant))]
|
|
||||||
pub struct CapabilitySet {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub tenant_id: Uuid,
|
|
||||||
pub slug: String,
|
|
||||||
pub cap_version: i32,
|
|
||||||
pub is_system: bool,
|
|
||||||
pub created_at: NaiveDateTime,
|
|
||||||
pub updated_at: NaiveDateTime,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Insertable)]
|
|
||||||
#[diesel(table_name = capability_sets)]
|
|
||||||
pub struct NewCapabilitySet {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub tenant_id: Uuid,
|
|
||||||
pub slug: String,
|
|
||||||
pub cap_version: i32,
|
|
||||||
pub is_system: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
|
||||||
#[diesel(table_name = capability_set_capabilities)]
|
|
||||||
#[diesel(primary_key(capability_set_id, capability))]
|
|
||||||
#[diesel(belongs_to(CapabilitySet, foreign_key = capability_set_id))]
|
|
||||||
pub struct CapabilitySetCapability {
|
|
||||||
pub capability_set_id: Uuid,
|
|
||||||
pub capability: ApiCapability,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Insertable)]
|
|
||||||
#[diesel(table_name = capability_set_capabilities)]
|
|
||||||
pub struct NewCapabilitySetCapability {
|
|
||||||
pub capability_set_id: Uuid,
|
|
||||||
pub capability: ApiCapability,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Insertable)]
|
|
||||||
#[diesel(table_name = api_tokens)]
|
|
||||||
pub struct NewApiToken {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub user_id: Uuid,
|
|
||||||
pub tenant_id: Uuid,
|
|
||||||
pub token_prefix: String,
|
|
||||||
pub token_hash: String,
|
|
||||||
pub label: Option<String>,
|
|
||||||
pub expires_at: Option<NaiveDateTime>,
|
|
||||||
pub capability_set_id: Uuid,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Queryable, Identifiable)]
|
#[derive(Debug, Clone, Queryable, Identifiable)]
|
||||||
@@ -519,9 +30,9 @@ pub struct Folder {
|
|||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub parent_id: Option<Uuid>,
|
pub parent_id: Option<Uuid>,
|
||||||
|
pub path_cache: Option<String>,
|
||||||
pub created_at: NaiveDateTime,
|
pub created_at: NaiveDateTime,
|
||||||
pub updated_at: NaiveDateTime,
|
pub updated_at: NaiveDateTime,
|
||||||
pub tenant_id: Uuid,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Insertable)]
|
#[derive(Debug, Insertable)]
|
||||||
@@ -530,7 +41,7 @@ pub struct NewFolder {
|
|||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub parent_id: Option<Uuid>,
|
pub parent_id: Option<Uuid>,
|
||||||
pub tenant_id: Uuid,
|
pub path_cache: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
||||||
@@ -540,16 +51,15 @@ pub struct Document {
|
|||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub filename: String,
|
pub filename: String,
|
||||||
pub original_name: String,
|
pub original_name: String,
|
||||||
pub mime_type: Option<String>,
|
pub content_type: Option<String>,
|
||||||
pub folder_id: Option<Uuid>,
|
pub folder_id: Option<Uuid>,
|
||||||
pub created_at: NaiveDateTime,
|
pub current_version: i32,
|
||||||
|
pub uploaded_at: NaiveDateTime,
|
||||||
pub updated_at: NaiveDateTime,
|
pub updated_at: NaiveDateTime,
|
||||||
pub deleted_at: Option<NaiveDateTime>,
|
pub deleted_at: Option<NaiveDateTime>,
|
||||||
pub metadata: serde_json::Value,
|
pub metadata: serde_json::Value,
|
||||||
pub issued_at: Option<NaiveDateTime>,
|
pub issued_at: Option<NaiveDateTime>,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub current_version_id: Uuid,
|
|
||||||
pub tenant_id: Uuid,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Insertable)]
|
#[derive(Debug, Insertable)]
|
||||||
@@ -558,29 +68,12 @@ pub struct NewDocument {
|
|||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub filename: String,
|
pub filename: String,
|
||||||
pub original_name: String,
|
pub original_name: String,
|
||||||
pub mime_type: Option<String>,
|
pub content_type: Option<String>,
|
||||||
pub folder_id: Option<Uuid>,
|
pub folder_id: Option<Uuid>,
|
||||||
pub current_version_id: Uuid,
|
pub current_version: i32,
|
||||||
pub metadata: serde_json::Value,
|
pub metadata: serde_json::Value,
|
||||||
pub issued_at: Option<NaiveDateTime>,
|
pub issued_at: Option<NaiveDateTime>,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub tenant_id: Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Queryable, Identifiable, Insertable)]
|
|
||||||
#[diesel(table_name = magic_tokens)]
|
|
||||||
pub struct MagicToken {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub user_id: Uuid,
|
|
||||||
pub kind: MagicTokenKind,
|
|
||||||
pub token_hash: String,
|
|
||||||
pub metadata: serde_json::Value,
|
|
||||||
pub expires_at: NaiveDateTime,
|
|
||||||
pub max_uses: Option<i32>,
|
|
||||||
pub used_count: i32,
|
|
||||||
pub created_at: NaiveDateTime,
|
|
||||||
pub created_by: Option<Uuid>,
|
|
||||||
pub last_used_at: Option<NaiveDateTime>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
||||||
@@ -594,8 +87,7 @@ pub struct DocumentVersion {
|
|||||||
pub size_bytes: i64,
|
pub size_bytes: i64,
|
||||||
pub checksum: String,
|
pub checksum: String,
|
||||||
pub created_at: NaiveDateTime,
|
pub created_at: NaiveDateTime,
|
||||||
pub metadata: serde_json::Value,
|
pub operations_summary: serde_json::Value,
|
||||||
pub tenant_id: Uuid,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Insertable)]
|
#[derive(Debug, Insertable)]
|
||||||
@@ -607,8 +99,7 @@ pub struct NewDocumentVersion {
|
|||||||
pub s3_key: String,
|
pub s3_key: String,
|
||||||
pub size_bytes: i64,
|
pub size_bytes: i64,
|
||||||
pub checksum: String,
|
pub checksum: String,
|
||||||
pub metadata: serde_json::Value,
|
pub operations_summary: serde_json::Value,
|
||||||
pub tenant_id: Uuid,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
||||||
@@ -618,11 +109,12 @@ pub struct DocumentAsset {
|
|||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub document_version_id: Uuid,
|
pub document_version_id: Uuid,
|
||||||
pub asset_type: String,
|
pub asset_type: String,
|
||||||
|
pub s3_key: String,
|
||||||
pub mime_type: String,
|
pub mime_type: String,
|
||||||
|
pub width: Option<i32>,
|
||||||
|
pub height: Option<i32>,
|
||||||
pub metadata: serde_json::Value,
|
pub metadata: serde_json::Value,
|
||||||
pub created_at: NaiveDateTime,
|
pub created_at: NaiveDateTime,
|
||||||
pub s3_key: String,
|
|
||||||
pub tenant_id: Uuid,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Insertable)]
|
#[derive(Debug, Insertable)]
|
||||||
@@ -631,10 +123,11 @@ pub struct NewDocumentAsset {
|
|||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub document_version_id: Uuid,
|
pub document_version_id: Uuid,
|
||||||
pub asset_type: String,
|
pub asset_type: String,
|
||||||
pub mime_type: String,
|
|
||||||
pub metadata: serde_json::Value,
|
|
||||||
pub s3_key: String,
|
pub s3_key: String,
|
||||||
pub tenant_id: Uuid,
|
pub mime_type: String,
|
||||||
|
pub width: Option<i32>,
|
||||||
|
pub height: Option<i32>,
|
||||||
|
pub metadata: serde_json::Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Queryable, Identifiable)]
|
#[derive(Debug, Clone, Queryable, Identifiable)]
|
||||||
@@ -649,8 +142,6 @@ pub struct Job {
|
|||||||
pub last_error: Option<String>,
|
pub last_error: Option<String>,
|
||||||
pub created_at: NaiveDateTime,
|
pub created_at: NaiveDateTime,
|
||||||
pub updated_at: NaiveDateTime,
|
pub updated_at: NaiveDateTime,
|
||||||
pub tenant_id: Option<Uuid>,
|
|
||||||
pub result: Option<serde_json::Value>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Insertable)]
|
#[derive(Debug, Insertable)]
|
||||||
@@ -661,7 +152,6 @@ pub struct NewJob {
|
|||||||
pub payload: serde_json::Value,
|
pub payload: serde_json::Value,
|
||||||
pub status: String,
|
pub status: String,
|
||||||
pub run_after: NaiveDateTime,
|
pub run_after: NaiveDateTime,
|
||||||
pub tenant_id: Uuid,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Queryable, Identifiable)]
|
#[derive(Debug, Clone, Queryable, Identifiable)]
|
||||||
@@ -671,7 +161,6 @@ pub struct Tag {
|
|||||||
pub label: String,
|
pub label: String,
|
||||||
pub color: Option<String>,
|
pub color: Option<String>,
|
||||||
pub created_at: NaiveDateTime,
|
pub created_at: NaiveDateTime,
|
||||||
pub tenant_id: Uuid,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Insertable)]
|
#[derive(Debug, Insertable)]
|
||||||
@@ -680,7 +169,6 @@ pub struct NewTag {
|
|||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub label: String,
|
pub label: String,
|
||||||
pub color: Option<String>,
|
pub color: Option<String>,
|
||||||
pub tenant_id: Uuid,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
@@ -694,7 +182,6 @@ pub struct DocumentTag {
|
|||||||
pub tag_id: Uuid,
|
pub tag_id: Uuid,
|
||||||
pub assigned_at: NaiveDateTime,
|
pub assigned_at: NaiveDateTime,
|
||||||
pub assigned_by: Option<Uuid>,
|
pub assigned_by: Option<Uuid>,
|
||||||
pub tenant_id: Uuid,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Insertable)]
|
#[derive(Debug, Insertable)]
|
||||||
@@ -703,73 +190,4 @@ pub struct NewDocumentTag {
|
|||||||
pub document_id: Uuid,
|
pub document_id: Uuid,
|
||||||
pub tag_id: Uuid,
|
pub tag_id: Uuid,
|
||||||
pub assigned_by: Option<Uuid>,
|
pub assigned_by: Option<Uuid>,
|
||||||
pub tenant_id: Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Queryable, Identifiable)]
|
|
||||||
#[diesel(table_name = correspondents)]
|
|
||||||
pub struct Correspondent {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub name: String,
|
|
||||||
pub metadata: serde_json::Value,
|
|
||||||
pub created_at: NaiveDateTime,
|
|
||||||
pub updated_at: NaiveDateTime,
|
|
||||||
pub tenant_id: Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Insertable)]
|
|
||||||
#[diesel(table_name = correspondents)]
|
|
||||||
pub struct NewCorrespondent {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub name: String,
|
|
||||||
pub metadata: serde_json::Value,
|
|
||||||
pub tenant_id: Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Queryable, Associations)]
|
|
||||||
#[diesel(table_name = document_correspondents)]
|
|
||||||
#[diesel(belongs_to(Document))]
|
|
||||||
#[diesel(belongs_to(Correspondent))]
|
|
||||||
#[diesel(primary_key(document_id, correspondent_id))]
|
|
||||||
pub struct DocumentCorrespondent {
|
|
||||||
pub document_id: Uuid,
|
|
||||||
pub correspondent_id: Uuid,
|
|
||||||
pub assigned_at: NaiveDateTime,
|
|
||||||
pub assigned_by: Option<Uuid>,
|
|
||||||
pub tenant_id: Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Insertable)]
|
|
||||||
#[diesel(table_name = document_correspondents)]
|
|
||||||
pub struct NewDocumentCorrespondent {
|
|
||||||
pub document_id: Uuid,
|
|
||||||
pub correspondent_id: Uuid,
|
|
||||||
pub assigned_by: Option<Uuid>,
|
|
||||||
pub tenant_id: Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
|
||||||
#[diesel(table_name = user_sessions)]
|
|
||||||
#[diesel(belongs_to(User))]
|
|
||||||
pub struct UserSession {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub user_id: Uuid,
|
|
||||||
pub token_hash: String,
|
|
||||||
pub issued_at: NaiveDateTime,
|
|
||||||
pub expires_at: NaiveDateTime,
|
|
||||||
pub revoked_at: Option<NaiveDateTime>,
|
|
||||||
pub created_at: NaiveDateTime,
|
|
||||||
pub updated_at: NaiveDateTime,
|
|
||||||
pub tenant_id: Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Insertable)]
|
|
||||||
#[diesel(table_name = user_sessions)]
|
|
||||||
pub struct NewUserSession {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub user_id: Uuid,
|
|
||||||
pub token_hash: String,
|
|
||||||
pub issued_at: NaiveDateTime,
|
|
||||||
pub expires_at: NaiveDateTime,
|
|
||||||
pub tenant_id: Uuid,
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,135 +0,0 @@
|
|||||||
use utoipa::openapi::{self, tag::TagBuilder, InfoBuilder};
|
|
||||||
use utoipa::OpenApi;
|
|
||||||
|
|
||||||
pub struct ApiDoc;
|
|
||||||
|
|
||||||
impl OpenApi for ApiDoc {
|
|
||||||
fn openapi() -> openapi::OpenApi {
|
|
||||||
let mut doc = crate::routes::health::HealthApiDoc::openapi();
|
|
||||||
doc.merge(crate::routes::auth::AuthApiDoc::openapi());
|
|
||||||
doc.merge(crate::routes::documents::DocumentsApiDoc::openapi());
|
|
||||||
doc.merge(crate::routes::folders::FoldersApiDoc::openapi());
|
|
||||||
doc.merge(crate::routes::tags::TagsApiDoc::openapi());
|
|
||||||
doc.merge(crate::routes::correspondents::CorrespondentsApiDoc::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()
|
|
||||||
.title("Papercrate API")
|
|
||||||
.version(env!("CARGO_PKG_VERSION"))
|
|
||||||
.build();
|
|
||||||
|
|
||||||
doc.tags = Some(vec![
|
|
||||||
TagBuilder::new()
|
|
||||||
.name("Health")
|
|
||||||
.description(Some("Service health"))
|
|
||||||
.build(),
|
|
||||||
TagBuilder::new()
|
|
||||||
.name("Auth")
|
|
||||||
.description(Some("Authentication"))
|
|
||||||
.build(),
|
|
||||||
TagBuilder::new()
|
|
||||||
.name("Documents")
|
|
||||||
.description(Some("Document management"))
|
|
||||||
.build(),
|
|
||||||
TagBuilder::new()
|
|
||||||
.name("Assets")
|
|
||||||
.description(Some("Document assets"))
|
|
||||||
.build(),
|
|
||||||
TagBuilder::new()
|
|
||||||
.name("Folders")
|
|
||||||
.description(Some("Folder management"))
|
|
||||||
.build(),
|
|
||||||
TagBuilder::new()
|
|
||||||
.name("Tags")
|
|
||||||
.description(Some("Tag catalog"))
|
|
||||||
.build(),
|
|
||||||
TagBuilder::new()
|
|
||||||
.name("Correspondents")
|
|
||||||
.description(Some("Correspondent catalog"))
|
|
||||||
.build(),
|
|
||||||
TagBuilder::new()
|
|
||||||
.name("Profile")
|
|
||||||
.description(Some("User profile and WebDAV tokens"))
|
|
||||||
.build(),
|
|
||||||
TagBuilder::new()
|
|
||||||
.name("Capability Sets")
|
|
||||||
.description(Some("Capability set management"))
|
|
||||||
.build(),
|
|
||||||
TagBuilder::new()
|
|
||||||
.name("Tenants")
|
|
||||||
.description(Some("Tenant catalog"))
|
|
||||||
.build(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
doc
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub mod schemas {
|
|
||||||
pub use crate::auth::passkeys::{
|
|
||||||
AuthenticationChallengeResponse, PasskeyLoginFinishPayload, PasskeyLoginStartPayload,
|
|
||||||
PasskeyRegistrationFinishPayload, PasskeySummary, RegistrationChallengeResponse,
|
|
||||||
};
|
|
||||||
pub use crate::auth::AuthenticatedUser;
|
|
||||||
pub use crate::documents::asset::{
|
|
||||||
DocumentAssetDetailResponse, DocumentAssetResponse, DocumentVersionDetailResponse,
|
|
||||||
DocumentVersionResponse,
|
|
||||||
};
|
|
||||||
pub use crate::documents::correspondents::DocumentCorrespondentResponse;
|
|
||||||
pub use crate::error::ApiErrorResponse;
|
|
||||||
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,
|
|
||||||
SignupFinishRequest, SignupStartRequest, SignupStartResponse, TenantListResponse,
|
|
||||||
TenantSelectionRequest, TenantSelectionResponse, TenantSnippet,
|
|
||||||
};
|
|
||||||
pub use crate::services::capability_sets::{
|
|
||||||
CapabilitySetResponse, CreateCapabilitySetRequest, UpdateCapabilitySetRequest,
|
|
||||||
};
|
|
||||||
pub use crate::services::correspondents::{
|
|
||||||
AssignCorrespondentsRequest, BulkCorrespondentAction, BulkCorrespondentResponse,
|
|
||||||
BulkCorrespondentsRequest, CorrespondentAssignmentInput,
|
|
||||||
};
|
|
||||||
pub use crate::services::documents::{
|
|
||||||
BulkMoveRequest, BulkMoveResponse, BulkReanalyzeResponse, BulkReanalyzeSelectionRequest,
|
|
||||||
DocumentCheckResponse, DocumentDetailResponse, DocumentListQuery, DocumentMetadataUpdate,
|
|
||||||
DocumentResponse, DocumentStatusFilter, TagResponse, UpdateDocumentRequest,
|
|
||||||
};
|
|
||||||
pub use crate::services::folders::{
|
|
||||||
CreateFolderRequest, EnsureFolderPathRequest, FolderContentsQuery, FolderInfo,
|
|
||||||
UpdateFolderRequest,
|
|
||||||
};
|
|
||||||
pub use crate::services::profile::{
|
|
||||||
ApiTokenCreatedResponse, ApiTokenResponse, CreateApiTokenRequest, RevokePasskeyQuery,
|
|
||||||
};
|
|
||||||
pub use crate::services::tags::{
|
|
||||||
AssignTagsRequest, BulkTagAction, BulkTagRequest, BulkTagResponse,
|
|
||||||
};
|
|
||||||
pub use crate::services::tenants::{
|
|
||||||
TenantUserListResponse, TenantUserSummary, UpdateTenantRequest, UpdateTenantUserRequest,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::ApiDoc;
|
|
||||||
use utoipa::OpenApi;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn openapi_serializes() {
|
|
||||||
let spec = ApiDoc::openapi();
|
|
||||||
let _ = serde_json::to_string(&spec).expect("serialize openapi");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+42
-241
@@ -1,260 +1,61 @@
|
|||||||
use axum::{
|
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
|
||||||
extract::State,
|
use diesel::prelude::*;
|
||||||
http::{HeaderMap, StatusCode},
|
use serde::{Deserialize, Serialize};
|
||||||
response::Response,
|
|
||||||
Json,
|
|
||||||
};
|
|
||||||
use axum_extra::{
|
|
||||||
headers::{authorization::Bearer, Authorization, Cookie},
|
|
||||||
typed_header::TypedHeader,
|
|
||||||
};
|
|
||||||
use utoipa::OpenApi;
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
auth::{
|
auth::{password, AuthenticatedUser},
|
||||||
passkeys::{
|
|
||||||
AuthenticationChallengeResponse, PasskeyLoginFinishPayload, PasskeyLoginStartPayload,
|
|
||||||
PasskeyRegistrationFinishPayload, PasskeySummary, RegistrationChallengeResponse,
|
|
||||||
},
|
|
||||||
AuthenticatedUser, TenantScopedConn,
|
|
||||||
},
|
|
||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
http::responders::JsonResponse,
|
models::User,
|
||||||
services::auth::{
|
schema::users::dsl,
|
||||||
ApiTokenExchangeRequest, AuthService, LoginRequest, LoginResponse, LoginResponseVariants,
|
|
||||||
SignupFinishRequest, SignupStartRequest, SignupStartResponse, TenantListResponse,
|
|
||||||
TenantSelectionRequest, TenantSelectionResponse, TenantSnippet, SESSION_COOKIE_NAME,
|
|
||||||
},
|
|
||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(OpenApi)]
|
#[derive(Deserialize)]
|
||||||
#[openapi(
|
pub struct LoginRequest {
|
||||||
paths(
|
pub username: String,
|
||||||
login,
|
pub password: String,
|
||||||
api_token_exchange,
|
}
|
||||||
signup_start,
|
|
||||||
signup_finish,
|
#[derive(Serialize)]
|
||||||
refresh,
|
pub struct LoginResponse {
|
||||||
logout,
|
pub access_token: String,
|
||||||
me,
|
pub token_type: String,
|
||||||
select_tenant,
|
pub expires_in: i64,
|
||||||
passkey_register_start,
|
}
|
||||||
passkey_register_finish,
|
|
||||||
passkey_login_start,
|
|
||||||
passkey_login_finish,
|
|
||||||
),
|
|
||||||
components(schemas(
|
|
||||||
LoginRequest,
|
|
||||||
ApiTokenExchangeRequest,
|
|
||||||
SignupStartRequest,
|
|
||||||
SignupStartResponse,
|
|
||||||
SignupFinishRequest,
|
|
||||||
LoginResponse,
|
|
||||||
LoginResponseVariants,
|
|
||||||
TenantSnippet,
|
|
||||||
TenantSelectionResponse,
|
|
||||||
TenantSelectionRequest,
|
|
||||||
TenantListResponse,
|
|
||||||
crate::auth::AuthenticatedUser,
|
|
||||||
crate::auth::passkeys::RegistrationChallengeResponse,
|
|
||||||
crate::auth::passkeys::AuthenticationChallengeResponse,
|
|
||||||
crate::auth::passkeys::PasskeySummary,
|
|
||||||
crate::auth::passkeys::PasskeyRegistrationFinishPayload,
|
|
||||||
crate::auth::passkeys::PasskeyLoginStartPayload,
|
|
||||||
crate::auth::passkeys::PasskeyLoginFinishPayload,
|
|
||||||
crate::models::ApiCapability,
|
|
||||||
))
|
|
||||||
)]
|
|
||||||
pub struct AuthApiDoc;
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/auth/login",
|
|
||||||
request_body = LoginRequest,
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Login succeeded", body = LoginResponseVariants),
|
|
||||||
(status = 401, description = "Invalid credentials")
|
|
||||||
),
|
|
||||||
tag = "Auth"
|
|
||||||
)]
|
|
||||||
pub async fn login(
|
pub async fn login(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(payload): Json<LoginRequest>,
|
Json(payload): Json<LoginRequest>,
|
||||||
) -> AppResult<Response> {
|
) -> AppResult<Json<LoginResponse>> {
|
||||||
AuthService::new(&state).login(payload)
|
let mut conn = state.db()?;
|
||||||
|
|
||||||
|
let user: User = dsl::users
|
||||||
|
.filter(dsl::username.eq(&payload.username))
|
||||||
|
.first(&mut conn)?;
|
||||||
|
|
||||||
|
let valid = password::verify_password(&payload.password, &user.password_hash)
|
||||||
|
.map_err(|_| AppError::unauthorized())?;
|
||||||
|
|
||||||
|
if !valid {
|
||||||
|
return Err(AppError::unauthorized());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
let token = state
|
||||||
post,
|
.jwt
|
||||||
path = "/api/auth/exchange-api-token",
|
.generate_token(user.id, &user.username, &user.role)
|
||||||
request_body = ApiTokenExchangeRequest,
|
.map_err(AppError::from)?;
|
||||||
responses((status = 200, description = "Access token issued", body = LoginResponse)),
|
|
||||||
tag = "Auth"
|
Ok(Json(LoginResponse {
|
||||||
)]
|
access_token: token,
|
||||||
pub async fn api_token_exchange(
|
token_type: "Bearer".to_string(),
|
||||||
State(state): State<AppState>,
|
expires_in: state.config.jwt_expiry_minutes * 60,
|
||||||
Json(payload): Json<ApiTokenExchangeRequest>,
|
}))
|
||||||
) -> AppResult<JsonResponse<LoginResponse>> {
|
|
||||||
AuthService::new(&state).exchange_api_token(payload)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
pub async fn logout(_user: AuthenticatedUser) -> impl IntoResponse {
|
||||||
post,
|
StatusCode::NO_CONTENT
|
||||||
path = "/api/auth/signup/start",
|
|
||||||
request_body = SignupStartRequest,
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Signup challenge created", body = SignupStartResponse),
|
|
||||||
(status = 400, description = "Invalid signup request"),
|
|
||||||
(status = 409, description = "Username already exists")
|
|
||||||
),
|
|
||||||
tag = "Auth"
|
|
||||||
)]
|
|
||||||
pub async fn signup_start(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Json(payload): Json<SignupStartRequest>,
|
|
||||||
) -> AppResult<JsonResponse<SignupStartResponse>> {
|
|
||||||
AuthService::new(&state).signup_start(payload)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/auth/signup/finish",
|
|
||||||
request_body = SignupFinishRequest,
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Signup completed", body = LoginResponseVariants),
|
|
||||||
(status = 400, description = "Invalid signup completion"),
|
|
||||||
(status = 409, description = "Username already exists")
|
|
||||||
),
|
|
||||||
tag = "Auth"
|
|
||||||
)]
|
|
||||||
pub async fn signup_finish(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Json(payload): Json<SignupFinishRequest>,
|
|
||||||
) -> AppResult<Response> {
|
|
||||||
AuthService::new(&state).signup_finish(payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/auth/refresh",
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Refreshed access token", body = LoginResponse),
|
|
||||||
(status = 401, description = "Missing or invalid refresh token")
|
|
||||||
),
|
|
||||||
tag = "Auth"
|
|
||||||
)]
|
|
||||||
pub async fn refresh(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
jar: Option<TypedHeader<Cookie>>,
|
|
||||||
) -> AppResult<Response> {
|
|
||||||
let cookies = jar.ok_or_else(AppError::unauthorized)?;
|
|
||||||
let refresh_value = cookies
|
|
||||||
.get(SESSION_COOKIE_NAME)
|
|
||||||
.ok_or_else(AppError::unauthorized)?;
|
|
||||||
|
|
||||||
AuthService::new(&state).refresh(refresh_value)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/auth/select-tenant",
|
|
||||||
request_body = TenantSelectionRequest,
|
|
||||||
responses((status = 200, description = "Tenant selected", body = LoginResponse)),
|
|
||||||
tag = "Auth"
|
|
||||||
)]
|
|
||||||
pub async fn select_tenant(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
TypedHeader(Authorization(bearer)): TypedHeader<Authorization<Bearer>>,
|
|
||||||
Json(payload): Json<TenantSelectionRequest>,
|
|
||||||
) -> AppResult<Response> {
|
|
||||||
AuthService::new(&state).select_tenant(bearer.token(), payload.tenant_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/auth/logout",
|
|
||||||
responses((status = 204, description = "Session revoked")),
|
|
||||||
tag = "Auth"
|
|
||||||
)]
|
|
||||||
pub async fn logout(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
TenantScopedConn { mut conn, user, .. }: TenantScopedConn,
|
|
||||||
jar: Option<TypedHeader<Cookie>>,
|
|
||||||
) -> AppResult<(HeaderMap, StatusCode)> {
|
|
||||||
let refresh_cookie = jar.as_ref().and_then(|cookies| {
|
|
||||||
cookies
|
|
||||||
.get(SESSION_COOKIE_NAME)
|
|
||||||
.map(|value| value.to_owned())
|
|
||||||
});
|
|
||||||
AuthService::new(&state).logout(&mut conn, &user, refresh_cookie.as_deref())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/api/auth/me",
|
|
||||||
responses((status = 200, description = "Current session", body = crate::auth::AuthenticatedUser)),
|
|
||||||
tag = "Auth"
|
|
||||||
)]
|
|
||||||
pub async fn me(user: AuthenticatedUser) -> Json<AuthenticatedUser> {
|
pub async fn me(user: AuthenticatedUser) -> Json<AuthenticatedUser> {
|
||||||
Json(user)
|
Json(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/auth/passkeys/register/start",
|
|
||||||
responses((status = 200, body = crate::auth::passkeys::RegistrationChallengeResponse)),
|
|
||||||
tag = "Auth"
|
|
||||||
)]
|
|
||||||
pub async fn passkey_register_start(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
user: AuthenticatedUser,
|
|
||||||
) -> AppResult<JsonResponse<RegistrationChallengeResponse>> {
|
|
||||||
AuthService::new(&state).passkey_register_start(user)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/auth/passkeys/register/finish",
|
|
||||||
request_body = crate::auth::passkeys::PasskeyRegistrationFinishPayload,
|
|
||||||
responses((status = 201, body = crate::auth::passkeys::PasskeySummary)),
|
|
||||||
tag = "Auth"
|
|
||||||
)]
|
|
||||||
pub async fn passkey_register_finish(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
user: AuthenticatedUser,
|
|
||||||
Json(payload): Json<PasskeyRegistrationFinishPayload>,
|
|
||||||
) -> AppResult<JsonResponse<PasskeySummary>> {
|
|
||||||
AuthService::new(&state).passkey_register_finish(user, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/auth/passkeys/login/start",
|
|
||||||
request_body = crate::auth::passkeys::PasskeyLoginStartPayload,
|
|
||||||
responses((status = 200, body = crate::auth::passkeys::AuthenticationChallengeResponse)),
|
|
||||||
tag = "Auth"
|
|
||||||
)]
|
|
||||||
pub async fn passkey_login_start(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Json(payload): Json<PasskeyLoginStartPayload>,
|
|
||||||
) -> AppResult<JsonResponse<AuthenticationChallengeResponse>> {
|
|
||||||
AuthService::new(&state).passkey_login_start(&payload.username)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/auth/passkeys/login/finish",
|
|
||||||
request_body = crate::auth::passkeys::PasskeyLoginFinishPayload,
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Passkey login successful", body = LoginResponseVariants),
|
|
||||||
(status = 401, description = "Authentication failed")
|
|
||||||
),
|
|
||||||
tag = "Auth"
|
|
||||||
)]
|
|
||||||
pub async fn passkey_login_finish(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Json(payload): Json<PasskeyLoginFinishPayload>,
|
|
||||||
) -> AppResult<Response> {
|
|
||||||
AuthService::new(&state).passkey_login_finish(payload)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,134 +0,0 @@
|
|||||||
use axum::{extract::Path, http::StatusCode, Json};
|
|
||||||
use utoipa::OpenApi;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
auth::TenantScopedConn,
|
|
||||||
error::AppResult,
|
|
||||||
http::responders::JsonResponse,
|
|
||||||
services::capability_sets::{
|
|
||||||
CapabilitySetResponse, CapabilitySetService, CreateCapabilitySetRequest,
|
|
||||||
UpdateCapabilitySetRequest,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/api/capability-sets",
|
|
||||||
responses((status = 200, body = [CapabilitySetResponse])),
|
|
||||||
tag = "Capability Sets"
|
|
||||||
)]
|
|
||||||
pub async fn list_capability_sets(
|
|
||||||
TenantScopedConn {
|
|
||||||
mut conn,
|
|
||||||
tenant_id,
|
|
||||||
..
|
|
||||||
}: TenantScopedConn,
|
|
||||||
) -> AppResult<JsonResponse<Vec<CapabilitySetResponse>>> {
|
|
||||||
CapabilitySetService::new().list(&mut conn, tenant_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/api/capabilities",
|
|
||||||
responses((status = 200, body = [crate::models::ApiCapability])),
|
|
||||||
tag = "Capability Sets"
|
|
||||||
)]
|
|
||||||
pub async fn list_capabilities(
|
|
||||||
TenantScopedConn { .. }: TenantScopedConn,
|
|
||||||
) -> AppResult<JsonResponse<Vec<crate::models::ApiCapability>>> {
|
|
||||||
CapabilitySetService::new().list_capabilities()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/api/capability-sets/{id}",
|
|
||||||
params(("id" = Uuid, Path, description = "Capability set ID")),
|
|
||||||
responses((status = 200, body = CapabilitySetResponse), (status = 404, description = "Not found")),
|
|
||||||
tag = "Capability Sets"
|
|
||||||
)]
|
|
||||||
pub async fn get_capability_set(
|
|
||||||
TenantScopedConn {
|
|
||||||
mut conn,
|
|
||||||
tenant_id,
|
|
||||||
..
|
|
||||||
}: TenantScopedConn,
|
|
||||||
Path(id): Path<Uuid>,
|
|
||||||
) -> AppResult<JsonResponse<CapabilitySetResponse>> {
|
|
||||||
CapabilitySetService::new().get(&mut conn, tenant_id, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/capability-sets",
|
|
||||||
request_body = CreateCapabilitySetRequest,
|
|
||||||
responses((status = 201, body = CapabilitySetResponse)),
|
|
||||||
tag = "Capability Sets"
|
|
||||||
)]
|
|
||||||
pub async fn create_capability_set(
|
|
||||||
TenantScopedConn {
|
|
||||||
mut conn,
|
|
||||||
tenant_id,
|
|
||||||
..
|
|
||||||
}: TenantScopedConn,
|
|
||||||
Json(payload): Json<CreateCapabilitySetRequest>,
|
|
||||||
) -> AppResult<JsonResponse<CapabilitySetResponse>> {
|
|
||||||
CapabilitySetService::new().create(&mut conn, tenant_id, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
patch,
|
|
||||||
path = "/api/capability-sets/{id}",
|
|
||||||
params(("id" = Uuid, Path, description = "Capability set ID")),
|
|
||||||
request_body = UpdateCapabilitySetRequest,
|
|
||||||
responses((status = 200, body = CapabilitySetResponse)),
|
|
||||||
tag = "Capability Sets"
|
|
||||||
)]
|
|
||||||
pub async fn update_capability_set(
|
|
||||||
TenantScopedConn {
|
|
||||||
mut conn,
|
|
||||||
tenant_id,
|
|
||||||
..
|
|
||||||
}: TenantScopedConn,
|
|
||||||
Path(id): Path<Uuid>,
|
|
||||||
Json(payload): Json<UpdateCapabilitySetRequest>,
|
|
||||||
) -> AppResult<JsonResponse<CapabilitySetResponse>> {
|
|
||||||
CapabilitySetService::new().update(&mut conn, tenant_id, id, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
delete,
|
|
||||||
path = "/api/capability-sets/{id}",
|
|
||||||
params(("id" = Uuid, Path, description = "Capability set ID")),
|
|
||||||
responses((status = 204), (status = 409, description = "Set in use")),
|
|
||||||
tag = "Capability Sets"
|
|
||||||
)]
|
|
||||||
pub async fn delete_capability_set(
|
|
||||||
TenantScopedConn {
|
|
||||||
mut conn,
|
|
||||||
tenant_id,
|
|
||||||
..
|
|
||||||
}: TenantScopedConn,
|
|
||||||
Path(id): Path<Uuid>,
|
|
||||||
) -> AppResult<StatusCode> {
|
|
||||||
CapabilitySetService::new().delete(&mut conn, tenant_id, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(OpenApi)]
|
|
||||||
#[openapi(
|
|
||||||
paths(
|
|
||||||
crate::routes::capability_sets::list_capability_sets,
|
|
||||||
crate::routes::capability_sets::list_capabilities,
|
|
||||||
crate::routes::capability_sets::get_capability_set,
|
|
||||||
crate::routes::capability_sets::create_capability_set,
|
|
||||||
crate::routes::capability_sets::update_capability_set,
|
|
||||||
crate::routes::capability_sets::delete_capability_set,
|
|
||||||
),
|
|
||||||
components(schemas(
|
|
||||||
crate::models::ApiCapability,
|
|
||||||
crate::services::capability_sets::CapabilitySetResponse,
|
|
||||||
crate::services::capability_sets::CreateCapabilitySetRequest,
|
|
||||||
crate::services::capability_sets::UpdateCapabilitySetRequest,
|
|
||||||
))
|
|
||||||
)]
|
|
||||||
pub struct CapabilitySetsApiDoc;
|
|
||||||
@@ -1,312 +0,0 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use axum::{extract::Path, http::StatusCode, Json};
|
|
||||||
use chrono::Utc;
|
|
||||||
use diesel::{dsl::count_star, prelude::*, result::DatabaseErrorKind, PgConnection};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use serde_json::Value;
|
|
||||||
use utoipa::ToSchema;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
auth::TenantScopedConn,
|
|
||||||
error::{AppError, AppResult},
|
|
||||||
http::responders::{no_content, ok_json, IntoAppResult, JsonResponse, RowsAffectedExt},
|
|
||||||
models::{Correspondent, NewCorrespondent},
|
|
||||||
schema::{correspondents, document_correspondents},
|
|
||||||
utils::{
|
|
||||||
named_entity::{ensure_name_available, normalize_name},
|
|
||||||
time::to_iso,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
|
||||||
pub struct CorrespondentSummary {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub name: String,
|
|
||||||
#[schema(value_type = Object)]
|
|
||||||
pub metadata: Value,
|
|
||||||
pub created_at: String,
|
|
||||||
pub updated_at: String,
|
|
||||||
pub usage_count: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize, ToSchema)]
|
|
||||||
pub struct CreateCorrespondentRequest {
|
|
||||||
pub name: String,
|
|
||||||
#[serde(default)]
|
|
||||||
#[schema(nullable, value_type = Object)]
|
|
||||||
pub metadata: Option<Value>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize, ToSchema)]
|
|
||||||
pub struct UpdateCorrespondentRequest {
|
|
||||||
#[schema(nullable)]
|
|
||||||
pub name: Option<String>,
|
|
||||||
#[schema(nullable, value_type = Object)]
|
|
||||||
pub metadata: Option<Value>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(AsChangeset, Default)]
|
|
||||||
#[diesel(table_name = correspondents)]
|
|
||||||
struct CorrespondentChangeset<'a> {
|
|
||||||
name: Option<&'a str>,
|
|
||||||
metadata: Option<&'a Value>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/api/correspondents",
|
|
||||||
responses((status = 200, description = "Correspondents", body = [CorrespondentSummary])),
|
|
||||||
tag = "Correspondents"
|
|
||||||
)]
|
|
||||||
pub async fn list_correspondents(
|
|
||||||
TenantScopedConn {
|
|
||||||
mut conn,
|
|
||||||
tenant_id,
|
|
||||||
..
|
|
||||||
}: TenantScopedConn,
|
|
||||||
) -> AppResult<JsonResponse<Vec<CorrespondentSummary>>> {
|
|
||||||
let correspondents_list: Vec<Correspondent> = correspondents::table
|
|
||||||
.filter(correspondents::tenant_id.eq(tenant_id))
|
|
||||||
.order(correspondents::name.asc())
|
|
||||||
.load(&mut conn)?;
|
|
||||||
|
|
||||||
let usage_rows: Vec<(Uuid, i64)> = document_correspondents::table
|
|
||||||
.filter(document_correspondents::tenant_id.eq(tenant_id))
|
|
||||||
.group_by(document_correspondents::correspondent_id)
|
|
||||||
.select((document_correspondents::correspondent_id, count_star()))
|
|
||||||
.load(&mut conn)?;
|
|
||||||
|
|
||||||
let mut usage_map: HashMap<Uuid, i64> = HashMap::new();
|
|
||||||
for (correspondent_id, count) in usage_rows {
|
|
||||||
usage_map.insert(correspondent_id, count);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut response = Vec::with_capacity(correspondents_list.len());
|
|
||||||
for correspondent in correspondents_list {
|
|
||||||
let total = usage_map.remove(&correspondent.id).unwrap_or(0);
|
|
||||||
response.push(build_summary(correspondent, total));
|
|
||||||
}
|
|
||||||
|
|
||||||
ok_json(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/correspondents",
|
|
||||||
request_body = CreateCorrespondentRequest,
|
|
||||||
responses((status = 200, description = "Correspondent created", body = CorrespondentSummary)),
|
|
||||||
tag = "Correspondents"
|
|
||||||
)]
|
|
||||||
pub async fn create_correspondent(
|
|
||||||
TenantScopedConn {
|
|
||||||
mut conn,
|
|
||||||
tenant_id,
|
|
||||||
..
|
|
||||||
}: TenantScopedConn,
|
|
||||||
Json(payload): Json<CreateCorrespondentRequest>,
|
|
||||||
) -> AppResult<JsonResponse<CorrespondentSummary>> {
|
|
||||||
let name = normalize_name(&payload.name, || {
|
|
||||||
AppError::bad_request("name must not be empty")
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let metadata_value = normalize_metadata(payload.metadata);
|
|
||||||
let new_id = Uuid::new_v4();
|
|
||||||
let new_correspondent = NewCorrespondent {
|
|
||||||
id: new_id,
|
|
||||||
name: name.clone(),
|
|
||||||
metadata: metadata_value,
|
|
||||||
tenant_id,
|
|
||||||
};
|
|
||||||
|
|
||||||
match diesel::insert_into(correspondents::table)
|
|
||||||
.values(&new_correspondent)
|
|
||||||
.execute(&mut conn)
|
|
||||||
{
|
|
||||||
Ok(_) => {}
|
|
||||||
Err(diesel::result::Error::DatabaseError(DatabaseErrorKind::UniqueViolation, _)) => {
|
|
||||||
return Err(AppError::bad_request("correspondent name already exists"));
|
|
||||||
}
|
|
||||||
Err(err) => return Err(AppError::from(err)),
|
|
||||||
}
|
|
||||||
|
|
||||||
let correspondent: Correspondent = correspondents::table
|
|
||||||
.find(new_id)
|
|
||||||
.filter(correspondents::tenant_id.eq(tenant_id))
|
|
||||||
.first(&mut conn)
|
|
||||||
.into_app_result()?;
|
|
||||||
|
|
||||||
ok_json(build_summary(correspondent, 0))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
patch,
|
|
||||||
path = "/api/correspondents/{id}",
|
|
||||||
params(("id" = Uuid, Path, description = "Correspondent ID")),
|
|
||||||
request_body = UpdateCorrespondentRequest,
|
|
||||||
responses((status = 200, description = "Correspondent updated", body = CorrespondentSummary)),
|
|
||||||
tag = "Correspondents"
|
|
||||||
)]
|
|
||||||
pub async fn update_correspondent(
|
|
||||||
Path(correspondent_id): Path<Uuid>,
|
|
||||||
TenantScopedConn {
|
|
||||||
mut conn,
|
|
||||||
tenant_id,
|
|
||||||
..
|
|
||||||
}: TenantScopedConn,
|
|
||||||
Json(payload): Json<UpdateCorrespondentRequest>,
|
|
||||||
) -> AppResult<JsonResponse<CorrespondentSummary>> {
|
|
||||||
let existing: Correspondent = correspondents::table
|
|
||||||
.find(correspondent_id)
|
|
||||||
.filter(correspondents::tenant_id.eq(tenant_id))
|
|
||||||
.first(&mut conn)
|
|
||||||
.into_app_result()?;
|
|
||||||
|
|
||||||
let mut new_name: Option<String> = None;
|
|
||||||
if let Some(ref candidate) = payload.name {
|
|
||||||
let normalized = normalize_name(candidate, || {
|
|
||||||
AppError::bad_request("name must not be empty")
|
|
||||||
})?;
|
|
||||||
if normalized != existing.name {
|
|
||||||
ensure_name_available(
|
|
||||||
|| {
|
|
||||||
correspondents::table
|
|
||||||
.filter(correspondents::name.eq(&normalized))
|
|
||||||
.filter(correspondents::id.ne(correspondent_id))
|
|
||||||
.filter(correspondents::tenant_id.eq(tenant_id))
|
|
||||||
.first::<Correspondent>(&mut conn)
|
|
||||||
.optional()
|
|
||||||
},
|
|
||||||
|| AppError::bad_request("correspondent name already exists"),
|
|
||||||
)?;
|
|
||||||
new_name = Some(normalized);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut new_metadata: Option<Value> = None;
|
|
||||||
if let Some(metadata) = payload.metadata.clone() {
|
|
||||||
let candidate = normalize_metadata(Some(metadata));
|
|
||||||
if candidate != existing.metadata {
|
|
||||||
new_metadata = Some(candidate);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if new_name.is_none() && new_metadata.is_none() {
|
|
||||||
let usage = load_usage_for_correspondent(&mut conn, tenant_id, correspondent_id)?;
|
|
||||||
return ok_json(build_summary(existing.clone(), usage));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut changeset = CorrespondentChangeset::default();
|
|
||||||
if let Some(ref name) = new_name {
|
|
||||||
changeset.name = Some(name.as_str());
|
|
||||||
}
|
|
||||||
if let Some(ref metadata) = new_metadata {
|
|
||||||
changeset.metadata = Some(metadata);
|
|
||||||
}
|
|
||||||
|
|
||||||
let now = Utc::now().naive_utc();
|
|
||||||
diesel::update(
|
|
||||||
correspondents::table
|
|
||||||
.find(correspondent_id)
|
|
||||||
.filter(correspondents::tenant_id.eq(tenant_id)),
|
|
||||||
)
|
|
||||||
.set((&changeset, correspondents::updated_at.eq(now)))
|
|
||||||
.execute(&mut conn)
|
|
||||||
.into_app_result()?
|
|
||||||
.or_not_found()?;
|
|
||||||
|
|
||||||
let updated: Correspondent = correspondents::table
|
|
||||||
.find(correspondent_id)
|
|
||||||
.filter(correspondents::tenant_id.eq(tenant_id))
|
|
||||||
.first(&mut conn)
|
|
||||||
.into_app_result()?;
|
|
||||||
let usage = load_usage_for_correspondent(&mut conn, tenant_id, correspondent_id)?;
|
|
||||||
ok_json(build_summary(updated, usage))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
delete,
|
|
||||||
path = "/api/correspondents/{id}",
|
|
||||||
params(("id" = Uuid, Path, description = "Correspondent ID")),
|
|
||||||
responses((status = 204, description = "Correspondent deleted")),
|
|
||||||
tag = "Correspondents"
|
|
||||||
)]
|
|
||||||
pub async fn delete_correspondent(
|
|
||||||
Path(correspondent_id): Path<Uuid>,
|
|
||||||
TenantScopedConn {
|
|
||||||
mut conn,
|
|
||||||
tenant_id,
|
|
||||||
..
|
|
||||||
}: TenantScopedConn,
|
|
||||||
) -> AppResult<StatusCode> {
|
|
||||||
let usage: i64 = document_correspondents::table
|
|
||||||
.filter(document_correspondents::tenant_id.eq(tenant_id))
|
|
||||||
.filter(document_correspondents::correspondent_id.eq(correspondent_id))
|
|
||||||
.select(count_star())
|
|
||||||
.first(&mut conn)?;
|
|
||||||
|
|
||||||
if usage > 0 {
|
|
||||||
return Err(AppError::bad_request(
|
|
||||||
"cannot delete correspondent that is still assigned to documents",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
diesel::delete(
|
|
||||||
correspondents::table
|
|
||||||
.filter(correspondents::id.eq(correspondent_id))
|
|
||||||
.filter(correspondents::tenant_id.eq(tenant_id)),
|
|
||||||
)
|
|
||||||
.execute(&mut conn)
|
|
||||||
.into_app_result()?
|
|
||||||
.or_not_found()?;
|
|
||||||
no_content()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_summary(correspondent: Correspondent, usage_count: i64) -> CorrespondentSummary {
|
|
||||||
CorrespondentSummary {
|
|
||||||
id: correspondent.id,
|
|
||||||
name: correspondent.name,
|
|
||||||
metadata: correspondent.metadata,
|
|
||||||
created_at: to_iso(correspondent.created_at),
|
|
||||||
updated_at: to_iso(correspondent.updated_at),
|
|
||||||
usage_count,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_metadata(input: Option<Value>) -> Value {
|
|
||||||
match input {
|
|
||||||
None | Some(Value::Null) => Value::Object(Default::default()),
|
|
||||||
Some(value) => value,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn load_usage_for_correspondent(
|
|
||||||
conn: &mut PgConnection,
|
|
||||||
tenant_id: Uuid,
|
|
||||||
correspondent_id: Uuid,
|
|
||||||
) -> AppResult<i64> {
|
|
||||||
let total: i64 = document_correspondents::table
|
|
||||||
.filter(document_correspondents::correspondent_id.eq(correspondent_id))
|
|
||||||
.filter(document_correspondents::tenant_id.eq(tenant_id))
|
|
||||||
.select(count_star())
|
|
||||||
.get_result(conn)?;
|
|
||||||
|
|
||||||
Ok(total)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(utoipa::OpenApi)]
|
|
||||||
#[openapi(
|
|
||||||
paths(
|
|
||||||
crate::routes::correspondents::list_correspondents,
|
|
||||||
crate::routes::correspondents::create_correspondent,
|
|
||||||
crate::routes::correspondents::update_correspondent,
|
|
||||||
crate::routes::correspondents::delete_correspondent
|
|
||||||
),
|
|
||||||
components(schemas(
|
|
||||||
crate::routes::correspondents::CorrespondentSummary,
|
|
||||||
crate::routes::correspondents::CreateCorrespondentRequest,
|
|
||||||
crate::routes::correspondents::UpdateCorrespondentRequest
|
|
||||||
))
|
|
||||||
)]
|
|
||||||
pub struct CorrespondentsApiDoc;
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user