Initial commit
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- staging
|
||||
- dev
|
||||
tags:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- service: frontend
|
||||
context: frontend
|
||||
dockerfile: frontend/Dockerfile
|
||||
- service: backend
|
||||
context: backend
|
||||
dockerfile: backend/Dockerfile
|
||||
|
||||
steps:
|
||||
- name: Checkout with submodules
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Derive repository metadata
|
||||
id: repo_meta
|
||||
run: |
|
||||
repo="${GITHUB_REPOSITORY}"
|
||||
owner="${repo%%/*}"
|
||||
name="${repo##*/}"
|
||||
echo "repo_owner=$owner" >> "$GITHUB_OUTPUT"
|
||||
echo "repo_name=$name" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Compute base tag
|
||||
id: compute_tag
|
||||
env:
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
GITHUB_REF_TYPE: ${{ github.ref_type }}
|
||||
GITHUB_REF_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
sha="${GITHUB_SHA}"
|
||||
ref_type="${GITHUB_REF_TYPE}"
|
||||
ref_name="${GITHUB_REF_NAME}"
|
||||
|
||||
short="${sha:0:7}"
|
||||
tag="$short"
|
||||
|
||||
if [ "$ref_type" = "tag" ]; then
|
||||
tag="$ref_name"
|
||||
elif [ "$ref_name" = "dev" ]; then
|
||||
tag="${tag}-dev"
|
||||
elif [ "$ref_name" = "staging" ]; then
|
||||
tag="${tag}-staging"
|
||||
fi
|
||||
|
||||
echo "base_tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Login to local registry
|
||||
if: ${{ vars.REGISTRY_URL != '' }}
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ${{ vars.REGISTRY_URL }}
|
||||
username: ${{ vars.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ github.token }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Determine branch alias tag
|
||||
id: branch_alias
|
||||
env:
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
alias=""
|
||||
case "${REF_NAME}" in
|
||||
dev) alias="latest-dev" ;;
|
||||
staging) alias="latest-staging" ;;
|
||||
main) alias="latest" ;;
|
||||
esac
|
||||
echo "alias=$alias" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Assemble image tags
|
||||
id: tag_list
|
||||
env:
|
||||
REGISTRY_URL: ${{ vars.REGISTRY_URL }}
|
||||
REPO_NAME: ${{ steps.repo_meta.outputs.repo_name }}
|
||||
SERVICE: ${{ matrix.service }}
|
||||
BASE_TAG: ${{ steps.compute_tag.outputs.base_tag }}
|
||||
GIT_SHA: ${{ github.sha }}
|
||||
BRANCH_ALIAS: ${{ steps.branch_alias.outputs.alias }}
|
||||
shell: bash
|
||||
run: |
|
||||
tags=""
|
||||
|
||||
if [ -n "${REGISTRY_URL}" ]; then
|
||||
repo_tag="${REGISTRY_URL}/${REPO_NAME}-${SERVICE}"
|
||||
tags="${tags}${repo_tag}:${BASE_TAG}\n${repo_tag}:${GIT_SHA}"
|
||||
fi
|
||||
|
||||
ghcr_tag="ghcr.io/papercrate-dms/${REPO_NAME}-${SERVICE}"
|
||||
if [ -n "${tags}" ]; then
|
||||
tags="${tags}\n"
|
||||
fi
|
||||
tags="${tags}${ghcr_tag}:${BASE_TAG}\n${ghcr_tag}:${GIT_SHA}"
|
||||
|
||||
if [ -n "${BRANCH_ALIAS}" ]; then
|
||||
if [ -n "${REGISTRY_URL}" ]; then
|
||||
tags="${tags}\n${repo_tag}:${BRANCH_ALIAS}"
|
||||
fi
|
||||
tags="${tags}\n${ghcr_tag}:${BRANCH_ALIAS}"
|
||||
fi
|
||||
|
||||
export TAGS="${tags}"
|
||||
|
||||
python -c 'import os; tags=[t.strip() for t in os.environ["TAGS"].split("\\n") if t.strip()]; print("tags=" + ",".join(tags))' | tee -a "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build and Push ${{ matrix.service }} Image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ${{ matrix.context }}
|
||||
file: ${{ matrix.dockerfile }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
provenance: false
|
||||
tags: ${{ steps.tag_list.outputs.tags }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# Rust
|
||||
/backend/target/
|
||||
/backend/.env
|
||||
/backend/.env.*
|
||||
/backend/.cargo/
|
||||
backend/libpdfium.*
|
||||
|
||||
# Node/Frontend
|
||||
/frontend/node_modules/
|
||||
/frontend/dist/
|
||||
/frontend/.env
|
||||
/frontend/.env.*
|
||||
|
||||
# Logs and temp
|
||||
*.log
|
||||
*.tmp
|
||||
*.swp
|
||||
|
||||
# Docker artifacts
|
||||
*.pid
|
||||
|
||||
# Environment overrides
|
||||
.env
|
||||
.env.local
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,661 @@
|
||||
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/>.
|
||||
@@ -0,0 +1,48 @@
|
||||
# Papercrate
|
||||
|
||||

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

|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
For development workflows (local stack, integration tests, migrations, and
|
||||
configuration details) see [DEVELOPMENT.md](./DEVELOPMENT.md).
|
||||
Generated
+4449
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,99 @@
|
||||
[package]
|
||||
name = "papercrate"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
# Web framework
|
||||
axum = { version = "0.8", features = ["multipart"] }
|
||||
tokio = { version = "1.48", features = ["full"] }
|
||||
tower = { version = "0.5", features = ["make", "util"] }
|
||||
tower-http = { version = "0.6", features = ["cors", "trace"] }
|
||||
axum-extra = { version = "0.12", features = ["typed-header"] }
|
||||
|
||||
# Database
|
||||
diesel = { version = "2.3.3", features = ["postgres", "uuid", "chrono", "serde_json", "r2d2"] }
|
||||
diesel_migrations = "2.1"
|
||||
uuid = { version = "1.6", features = ["v4", "serde"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
|
||||
# S3
|
||||
rust-s3 = { version = "0.37", features = ["with-tokio", "tokio-rustls-tls"] }
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
envy = "0.4"
|
||||
serde-aux = "4.4"
|
||||
|
||||
# Utilities
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
dotenv = "0.15"
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
hmac = "0.12"
|
||||
bytes = "1.5"
|
||||
async-trait = "0.1"
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] }
|
||||
pdfium-render = "0.8.36"
|
||||
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
|
||||
thiserror = "2.0"
|
||||
anyhow = "1.0"
|
||||
|
||||
# Authentication & security
|
||||
argon2 = "0.5"
|
||||
jsonwebtoken = { version = "10", features = ["rust_crypto"] }
|
||||
webauthn-rs = { version = "0.5", features = ["danger-allow-state-serialisation", "danger-credential-internals"] }
|
||||
serde_bytes = "0.11"
|
||||
serde_cbor_2 = "0.13"
|
||||
|
||||
# Misc
|
||||
rand = "0.9"
|
||||
hyper = "1.2"
|
||||
http-body-util = "0.1"
|
||||
chrono-tz = "0.8"
|
||||
|
||||
[dev-dependencies]
|
||||
once_cell = "1.19"
|
||||
webauthn-rs-core = "0.5"
|
||||
serde_yaml = "0.9"
|
||||
|
||||
[build-dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_yaml = "0.9"
|
||||
serde_json = "1.0"
|
||||
regex = "1.11"
|
||||
|
||||
[[bin]]
|
||||
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"
|
||||
@@ -0,0 +1,143 @@
|
||||
# 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"]
|
||||
@@ -0,0 +1,123 @@
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use regex::escape;
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CaseSuite {
|
||||
cases: Vec<CaseName>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CaseName {
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct MonthSuite {
|
||||
months: Vec<MonthDefinition>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct MonthDefinition {
|
||||
name: String,
|
||||
month: u32,
|
||||
#[serde(default)]
|
||||
locales: Vec<String>,
|
||||
}
|
||||
|
||||
fn sanitize(name: &str) -> String {
|
||||
let mut out = String::with_capacity(name.len());
|
||||
for ch in name.chars() {
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
out.push(ch);
|
||||
} else {
|
||||
out.push('_');
|
||||
}
|
||||
}
|
||||
if out.is_empty() {
|
||||
"case".to_string()
|
||||
} else if out.chars().next().unwrap().is_ascii_digit() {
|
||||
format!("_{}", out)
|
||||
} else {
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
fn quote(value: &str) -> String {
|
||||
serde_json::to_string(value).expect("string literal")
|
||||
}
|
||||
|
||||
fn generate_tests() -> Result<String, Box<dyn std::error::Error>> {
|
||||
let yaml_path = PathBuf::from("tests/data/issued_at_cases.yaml");
|
||||
let contents = fs::read_to_string(&yaml_path)?;
|
||||
let suite: CaseSuite = serde_yaml::from_str(&contents)?;
|
||||
|
||||
let mut output =
|
||||
String::from("#[cfg(test)]\npub mod issued_at_generated_tests {\n use super::*;\n");
|
||||
|
||||
for case in suite.cases {
|
||||
let ident = sanitize(&case.name);
|
||||
output.push_str(&format!(
|
||||
" #[test]\n fn {}() {{\n run_named_case(\"{}\");\n }}\n",
|
||||
ident, case.name
|
||||
));
|
||||
}
|
||||
|
||||
output.push_str("}\n");
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn generate_months() -> Result<String, Box<dyn std::error::Error>> {
|
||||
let yaml_path = PathBuf::from("resources/issued_at_months.yaml");
|
||||
let contents = fs::read_to_string(&yaml_path)?;
|
||||
let suite: MonthSuite = serde_yaml::from_str(&contents)?;
|
||||
|
||||
let mut pattern_parts = Vec::with_capacity(suite.months.len());
|
||||
let mut entries = String::new();
|
||||
for entry in &suite.months {
|
||||
pattern_parts.push(escape(&entry.name));
|
||||
let locales_literal = if entry.locales.is_empty() {
|
||||
"&[]".to_string()
|
||||
} else {
|
||||
let joined = entry
|
||||
.locales
|
||||
.iter()
|
||||
.map(|loc| quote(loc))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
format!("&[{}]", joined)
|
||||
};
|
||||
entries.push_str(&format!(
|
||||
" MonthVariant {{ name: {}, month: {}, locales: {} }},\n",
|
||||
quote(&entry.name),
|
||||
entry.month,
|
||||
locales_literal
|
||||
));
|
||||
}
|
||||
|
||||
let pattern_literal = quote(&pattern_parts.join("|"));
|
||||
let output = format!(
|
||||
"pub(super) static MONTH_VARIANTS: &[MonthVariant] = &[\n{entries}];\n\n",
|
||||
entries = entries
|
||||
) + &format!(
|
||||
"pub(super) const MONTH_PATTERN: &str = {};\n",
|
||||
pattern_literal
|
||||
);
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("cargo:rerun-if-changed=tests/data/issued_at_cases.yaml");
|
||||
println!("cargo:rerun-if-changed=resources/issued_at_months.yaml");
|
||||
|
||||
let out_dir = PathBuf::from(env::var("OUT_DIR")?);
|
||||
fs::write(
|
||||
out_dir.join("issued_at_generated_tests.rs"),
|
||||
generate_tests()?,
|
||||
)?;
|
||||
fs::write(out_dir.join("issued_at_months.rs"), generate_months()?)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
[print_schema]
|
||||
file = "src/schema.rs"
|
||||
custom_type_derives = ["diesel::query_builder::QueryId", "Clone"]
|
||||
|
||||
[migrations_directory]
|
||||
dir = "migrations"
|
||||
@@ -0,0 +1,25 @@
|
||||
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";
|
||||
@@ -0,0 +1,295 @@
|
||||
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);
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE documents
|
||||
RENAME COLUMN created_at TO uploaded_at;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE documents
|
||||
RENAME COLUMN uploaded_at TO created_at;
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP TABLE magic_tokens;
|
||||
DROP TYPE magic_token_kind;
|
||||
@@ -0,0 +1,18 @@
|
||||
CREATE TYPE magic_token_kind AS ENUM ('email_login', 'demo_login');
|
||||
|
||||
CREATE TABLE magic_tokens (
|
||||
id UUID PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
kind magic_token_kind NOT NULL,
|
||||
token_hash VARCHAR NOT NULL UNIQUE,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
max_uses INTEGER,
|
||||
used_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
last_used_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX magic_tokens_token_hash_idx ON magic_tokens (token_hash);
|
||||
CREATE INDEX magic_tokens_expires_at_idx ON magic_tokens (expires_at);
|
||||
@@ -0,0 +1,28 @@
|
||||
-- Move tables and types back to the public schema
|
||||
ALTER TABLE tenant.webdav_tokens SET SCHEMA public;
|
||||
ALTER TABLE tenant.user_memberships SET SCHEMA public;
|
||||
ALTER TABLE tenant.refresh_tokens SET SCHEMA public;
|
||||
ALTER TABLE tenant.tags SET SCHEMA public;
|
||||
ALTER TABLE tenant.document_correspondents SET SCHEMA public;
|
||||
ALTER TABLE tenant.document_tags SET SCHEMA public;
|
||||
ALTER TABLE tenant.document_asset_objects SET SCHEMA public;
|
||||
ALTER TABLE tenant.document_assets SET SCHEMA public;
|
||||
ALTER TABLE tenant.document_versions SET SCHEMA public;
|
||||
ALTER TABLE tenant.documents SET SCHEMA public;
|
||||
ALTER TABLE tenant.folders SET SCHEMA public;
|
||||
ALTER TABLE tenant.correspondents SET SCHEMA public;
|
||||
|
||||
ALTER FUNCTION shared.touch_jobs_updated_at() SET SCHEMA public;
|
||||
|
||||
ALTER TABLE shared.magic_tokens SET SCHEMA public;
|
||||
ALTER TABLE shared.jobs SET SCHEMA public;
|
||||
ALTER TABLE shared.webauthn_challenges SET SCHEMA public;
|
||||
ALTER TABLE shared.user_passkeys SET SCHEMA public;
|
||||
ALTER TABLE shared.users SET SCHEMA public;
|
||||
ALTER TABLE shared.tenants SET SCHEMA public;
|
||||
|
||||
ALTER TYPE shared.magic_token_kind SET SCHEMA public;
|
||||
ALTER TYPE shared.tenant_status SET SCHEMA public;
|
||||
|
||||
DROP SCHEMA IF EXISTS tenant CASCADE;
|
||||
DROP SCHEMA IF EXISTS shared CASCADE;
|
||||
@@ -0,0 +1,45 @@
|
||||
CREATE SCHEMA IF NOT EXISTS shared;
|
||||
CREATE SCHEMA IF NOT EXISTS tenant;
|
||||
|
||||
-- Move global types and tables into the shared schema
|
||||
ALTER TYPE tenant_status SET SCHEMA shared;
|
||||
ALTER TYPE magic_token_kind SET SCHEMA shared;
|
||||
|
||||
ALTER TABLE tenants SET SCHEMA shared;
|
||||
ALTER TABLE users SET SCHEMA shared;
|
||||
ALTER TABLE user_passkeys SET SCHEMA shared;
|
||||
ALTER TABLE webauthn_challenges SET SCHEMA shared;
|
||||
ALTER TABLE jobs SET SCHEMA shared;
|
||||
ALTER TABLE magic_tokens SET SCHEMA shared;
|
||||
|
||||
ALTER FUNCTION touch_jobs_updated_at() SET SCHEMA shared;
|
||||
|
||||
-- Move tenant-scoped tables into the tenant schema
|
||||
ALTER TABLE correspondents SET SCHEMA tenant;
|
||||
ALTER TABLE folders SET SCHEMA tenant;
|
||||
ALTER TABLE documents SET SCHEMA tenant;
|
||||
ALTER TABLE document_versions SET SCHEMA tenant;
|
||||
ALTER TABLE document_assets SET SCHEMA tenant;
|
||||
ALTER TABLE document_asset_objects SET SCHEMA tenant;
|
||||
ALTER TABLE document_tags SET SCHEMA tenant;
|
||||
ALTER TABLE document_correspondents SET SCHEMA tenant;
|
||||
ALTER TABLE tags SET SCHEMA tenant;
|
||||
ALTER TABLE refresh_tokens SET SCHEMA tenant;
|
||||
ALTER TABLE user_memberships SET SCHEMA tenant;
|
||||
ALTER TABLE webdav_tokens SET SCHEMA tenant;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'papercrate_app') THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
GRANT USAGE ON SCHEMA shared TO papercrate_app;
|
||||
GRANT USAGE ON SCHEMA tenant TO papercrate_app;
|
||||
GRANT SELECT ON ALL TABLES IN SCHEMA shared TO papercrate_app;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA tenant TO papercrate_app;
|
||||
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA shared GRANT SELECT ON TABLES TO papercrate_app;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA tenant GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO papercrate_app;
|
||||
END
|
||||
$$;
|
||||
@@ -0,0 +1,51 @@
|
||||
DROP POLICY IF EXISTS tenant_membership_select_policy ON tenant.user_memberships;
|
||||
ALTER TABLE tenant.user_memberships NO FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE tenant.user_memberships DISABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.webdav_tokens;
|
||||
ALTER TABLE tenant.webdav_tokens NO FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE tenant.webdav_tokens DISABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.tags;
|
||||
ALTER TABLE tenant.tags NO FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE tenant.tags DISABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS tenant_refresh_token_policy ON tenant.refresh_tokens;
|
||||
ALTER TABLE tenant.refresh_tokens NO FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE tenant.refresh_tokens DISABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.folders;
|
||||
ALTER TABLE tenant.folders NO FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE tenant.folders DISABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.documents;
|
||||
ALTER TABLE tenant.documents NO FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE tenant.documents DISABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.document_versions;
|
||||
ALTER TABLE tenant.document_versions NO FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE tenant.document_versions DISABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.document_tags;
|
||||
ALTER TABLE tenant.document_tags NO FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE tenant.document_tags DISABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.document_correspondents;
|
||||
ALTER TABLE tenant.document_correspondents NO FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE tenant.document_correspondents DISABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.document_assets;
|
||||
ALTER TABLE tenant.document_assets NO FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE tenant.document_assets DISABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.document_asset_objects;
|
||||
ALTER TABLE tenant.document_asset_objects NO FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE tenant.document_asset_objects DISABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.correspondents;
|
||||
ALTER TABLE tenant.correspondents NO FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE tenant.correspondents DISABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP FUNCTION IF EXISTS shared.current_refresh_token_hash();
|
||||
DROP FUNCTION IF EXISTS shared.current_user_id();
|
||||
DROP FUNCTION IF EXISTS shared.current_tenant_id();
|
||||
@@ -0,0 +1,69 @@
|
||||
CREATE OR REPLACE FUNCTION shared.current_tenant_id() RETURNS uuid AS $$
|
||||
SELECT CASE
|
||||
WHEN setting IS NULL OR setting = '' THEN NULL
|
||||
ELSE setting::uuid
|
||||
END
|
||||
FROM (SELECT current_setting('papercrate.tenant_id', true) AS setting) s;
|
||||
$$ LANGUAGE SQL STABLE;
|
||||
|
||||
CREATE OR REPLACE FUNCTION shared.current_user_id() RETURNS uuid AS $$
|
||||
SELECT CASE
|
||||
WHEN setting IS NULL OR setting = '' THEN NULL
|
||||
ELSE setting::uuid
|
||||
END
|
||||
FROM (SELECT current_setting('papercrate.user_id', true) AS setting) s;
|
||||
$$ LANGUAGE SQL STABLE;
|
||||
|
||||
CREATE OR REPLACE FUNCTION shared.current_refresh_token_hash() RETURNS text AS $$
|
||||
SELECT NULLIF(current_setting('papercrate.refresh_token_hash', true), '')
|
||||
$$ LANGUAGE SQL STABLE;
|
||||
|
||||
-- Helper to create tenant isolation policy
|
||||
CREATE OR REPLACE FUNCTION shared.ensure_tenant_policy(table_reg regclass) RETURNS void AS $$
|
||||
BEGIN
|
||||
EXECUTE format('ALTER TABLE %s ENABLE ROW LEVEL SECURITY', table_reg);
|
||||
EXECUTE format('ALTER TABLE %s FORCE ROW LEVEL SECURITY', table_reg);
|
||||
EXECUTE format(
|
||||
'CREATE POLICY tenant_isolation_policy ON %s USING (tenant_id = shared.current_tenant_id()) WITH CHECK (tenant_id = shared.current_tenant_id())',
|
||||
table_reg
|
||||
);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
SELECT shared.ensure_tenant_policy('tenant.correspondents');
|
||||
SELECT shared.ensure_tenant_policy('tenant.document_asset_objects');
|
||||
SELECT shared.ensure_tenant_policy('tenant.document_assets');
|
||||
SELECT shared.ensure_tenant_policy('tenant.document_correspondents');
|
||||
SELECT shared.ensure_tenant_policy('tenant.document_tags');
|
||||
SELECT shared.ensure_tenant_policy('tenant.document_versions');
|
||||
SELECT shared.ensure_tenant_policy('tenant.documents');
|
||||
SELECT shared.ensure_tenant_policy('tenant.folders');
|
||||
SELECT shared.ensure_tenant_policy('tenant.tags');
|
||||
SELECT shared.ensure_tenant_policy('tenant.webdav_tokens');
|
||||
|
||||
-- user_memberships has a special read policy to allow tenant discovery during login
|
||||
ALTER TABLE tenant.user_memberships ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE tenant.user_memberships FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY tenant_membership_select_policy ON tenant.user_memberships
|
||||
USING (
|
||||
tenant_id = shared.current_tenant_id()
|
||||
OR (
|
||||
shared.current_user_id() IS NOT NULL
|
||||
AND user_id = shared.current_user_id()
|
||||
)
|
||||
)
|
||||
WITH CHECK (tenant_id = shared.current_tenant_id());
|
||||
|
||||
ALTER TABLE tenant.refresh_tokens ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE tenant.refresh_tokens FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY tenant_refresh_token_policy ON tenant.refresh_tokens
|
||||
USING (
|
||||
tenant_id = shared.current_tenant_id()
|
||||
OR (
|
||||
shared.current_refresh_token_hash() IS NOT NULL
|
||||
AND token_hash = shared.current_refresh_token_hash()
|
||||
)
|
||||
)
|
||||
WITH CHECK (tenant_id = shared.current_tenant_id());
|
||||
|
||||
DROP FUNCTION shared.ensure_tenant_policy(regclass);
|
||||
@@ -0,0 +1,5 @@
|
||||
DROP POLICY IF EXISTS tenant_webdav_token_policy ON tenant.webdav_tokens;
|
||||
ALTER TABLE tenant.webdav_tokens NO FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE tenant.webdav_tokens DISABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP FUNCTION IF EXISTS shared.current_webdav_token_prefix();
|
||||
@@ -0,0 +1,15 @@
|
||||
CREATE OR REPLACE FUNCTION shared.current_webdav_token_prefix() RETURNS text AS $$
|
||||
SELECT NULLIF(current_setting('papercrate.webdav_token_prefix', true), '')
|
||||
$$ LANGUAGE SQL STABLE;
|
||||
|
||||
ALTER TABLE tenant.webdav_tokens ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE tenant.webdav_tokens FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY tenant_webdav_token_policy ON tenant.webdav_tokens
|
||||
USING (
|
||||
tenant_id = shared.current_tenant_id()
|
||||
OR (
|
||||
shared.current_webdav_token_prefix() IS NOT NULL
|
||||
AND token_prefix = shared.current_webdav_token_prefix()
|
||||
)
|
||||
)
|
||||
WITH CHECK (tenant_id = shared.current_tenant_id());
|
||||
@@ -0,0 +1,31 @@
|
||||
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());
|
||||
@@ -0,0 +1,28 @@
|
||||
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());
|
||||
@@ -0,0 +1,26 @@
|
||||
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();
|
||||
@@ -0,0 +1,26 @@
|
||||
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();
|
||||
@@ -0,0 +1,13 @@
|
||||
ALTER TABLE tenant.document_tags
|
||||
DROP CONSTRAINT IF EXISTS document_tags_assigned_by_fkey,
|
||||
ADD CONSTRAINT document_tags_assigned_by_fkey
|
||||
FOREIGN KEY (assigned_by)
|
||||
REFERENCES shared.users (id)
|
||||
ON DELETE NO ACTION;
|
||||
|
||||
ALTER TABLE tenant.document_correspondents
|
||||
DROP CONSTRAINT IF EXISTS document_correspondents_assigned_by_fkey,
|
||||
ADD CONSTRAINT document_correspondents_assigned_by_fkey
|
||||
FOREIGN KEY (assigned_by)
|
||||
REFERENCES shared.users (id)
|
||||
ON DELETE NO ACTION;
|
||||
@@ -0,0 +1,13 @@
|
||||
ALTER TABLE tenant.document_tags
|
||||
DROP CONSTRAINT IF EXISTS document_tags_assigned_by_fkey,
|
||||
ADD CONSTRAINT document_tags_assigned_by_fkey
|
||||
FOREIGN KEY (assigned_by)
|
||||
REFERENCES shared.users (id)
|
||||
ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE tenant.document_correspondents
|
||||
DROP CONSTRAINT IF EXISTS document_correspondents_assigned_by_fkey,
|
||||
ADD CONSTRAINT document_correspondents_assigned_by_fkey
|
||||
FOREIGN KEY (assigned_by)
|
||||
REFERENCES shared.users (id)
|
||||
ON DELETE SET NULL;
|
||||
@@ -0,0 +1,28 @@
|
||||
CREATE TYPE api_token_capability AS ENUM ('api', 'webdav');
|
||||
|
||||
ALTER TABLE tenant.api_tokens
|
||||
ADD COLUMN capabilities api_token_capability[] NOT NULL DEFAULT ARRAY[]::api_token_capability[];
|
||||
|
||||
UPDATE tenant.api_tokens t
|
||||
SET capabilities = ARRAY['api']::api_token_capability[]
|
||||
FROM tenant.capability_sets cs
|
||||
WHERE t.capability_set_id = cs.id
|
||||
AND cs.slug = 'owner';
|
||||
|
||||
UPDATE tenant.api_tokens t
|
||||
SET capabilities = ARRAY['webdav']::api_token_capability[]
|
||||
FROM tenant.capability_sets cs
|
||||
WHERE t.capability_set_id = cs.id
|
||||
AND cs.slug = 'webdav'
|
||||
AND (t.capabilities IS NULL OR array_length(t.capabilities, 1) = 0);
|
||||
|
||||
ALTER TABLE tenant.api_tokens
|
||||
DROP COLUMN capability_set_id;
|
||||
|
||||
ALTER TABLE tenant.user_memberships
|
||||
DROP COLUMN capability_set_id;
|
||||
|
||||
DROP TABLE IF EXISTS tenant.capability_set_capabilities;
|
||||
DROP TABLE IF EXISTS tenant.capability_sets;
|
||||
|
||||
DROP TYPE IF EXISTS api_capability;
|
||||
@@ -0,0 +1,158 @@
|
||||
CREATE TYPE api_capability AS ENUM (
|
||||
'documents:read',
|
||||
'documents:edit',
|
||||
'documents:write',
|
||||
'documents:upload',
|
||||
'folders:read',
|
||||
'folders:edit',
|
||||
'folders:write',
|
||||
'tags:read',
|
||||
'tags:edit',
|
||||
'tags:write',
|
||||
'correspondents:read',
|
||||
'correspondents:edit',
|
||||
'correspondents:write',
|
||||
'profile:read',
|
||||
'profile:write',
|
||||
'webdav:read',
|
||||
'webdav:write',
|
||||
'capability_sets:read',
|
||||
'capability_sets:write'
|
||||
);
|
||||
|
||||
CREATE TABLE tenant.capability_sets (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES shared.tenants(id) ON DELETE CASCADE,
|
||||
slug TEXT NOT NULL,
|
||||
cap_version INT NOT NULL DEFAULT 1,
|
||||
is_system BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (tenant_id, slug)
|
||||
);
|
||||
|
||||
CREATE TABLE tenant.capability_set_capabilities (
|
||||
capability_set_id UUID NOT NULL REFERENCES tenant.capability_sets(id) ON DELETE CASCADE,
|
||||
capability api_capability NOT NULL,
|
||||
PRIMARY KEY (capability_set_id, capability)
|
||||
);
|
||||
|
||||
ALTER TABLE tenant.api_tokens
|
||||
ADD COLUMN capability_set_id UUID REFERENCES tenant.capability_sets(id);
|
||||
|
||||
ALTER TABLE tenant.user_memberships
|
||||
ADD COLUMN capability_set_id UUID REFERENCES tenant.capability_sets(id);
|
||||
|
||||
WITH owner_sets AS (
|
||||
INSERT INTO tenant.capability_sets (tenant_id, slug, is_system)
|
||||
SELECT id, 'owner', TRUE
|
||||
FROM shared.tenants
|
||||
RETURNING id, tenant_id
|
||||
),
|
||||
user_sets AS (
|
||||
INSERT INTO tenant.capability_sets (tenant_id, slug, is_system)
|
||||
SELECT id, 'user', TRUE
|
||||
FROM shared.tenants
|
||||
RETURNING id, tenant_id
|
||||
),
|
||||
readonly_sets AS (
|
||||
INSERT INTO tenant.capability_sets (tenant_id, slug, is_system)
|
||||
SELECT id, 'readonly', TRUE
|
||||
FROM shared.tenants
|
||||
RETURNING id, tenant_id
|
||||
),
|
||||
webdav_sets AS (
|
||||
INSERT INTO tenant.capability_sets (tenant_id, slug, is_system)
|
||||
SELECT id, 'webdav', TRUE
|
||||
FROM shared.tenants
|
||||
RETURNING id, tenant_id
|
||||
)
|
||||
INSERT INTO tenant.capability_set_capabilities (capability_set_id, capability)
|
||||
SELECT set_id,
|
||||
capability
|
||||
FROM (
|
||||
SELECT os.id AS set_id,
|
||||
UNNEST(ARRAY[
|
||||
'documents:read'::api_capability,
|
||||
'documents:edit'::api_capability,
|
||||
'documents:write'::api_capability,
|
||||
'documents:upload'::api_capability,
|
||||
'folders:read'::api_capability,
|
||||
'folders:edit'::api_capability,
|
||||
'folders:write'::api_capability,
|
||||
'tags:read'::api_capability,
|
||||
'tags:edit'::api_capability,
|
||||
'tags:write'::api_capability,
|
||||
'correspondents:read'::api_capability,
|
||||
'correspondents:edit'::api_capability,
|
||||
'correspondents:write'::api_capability,
|
||||
'profile:read'::api_capability,
|
||||
'profile:write'::api_capability,
|
||||
'webdav:read'::api_capability,
|
||||
'webdav:write'::api_capability,
|
||||
'capability_sets:read'::api_capability,
|
||||
'capability_sets:write'::api_capability
|
||||
]) AS capability
|
||||
FROM owner_sets os
|
||||
UNION ALL
|
||||
SELECT us.id,
|
||||
UNNEST(ARRAY[
|
||||
'documents:read'::api_capability,
|
||||
'documents:edit'::api_capability,
|
||||
'documents:write'::api_capability,
|
||||
'documents:upload'::api_capability,
|
||||
'folders:read'::api_capability,
|
||||
'folders:edit'::api_capability,
|
||||
'folders:write'::api_capability,
|
||||
'tags:read'::api_capability,
|
||||
'tags:edit'::api_capability,
|
||||
'tags:write'::api_capability,
|
||||
'correspondents:read'::api_capability,
|
||||
'correspondents:edit'::api_capability,
|
||||
'correspondents:write'::api_capability,
|
||||
'profile:read'::api_capability,
|
||||
'profile:write'::api_capability
|
||||
]) AS capability
|
||||
FROM user_sets us
|
||||
UNION ALL
|
||||
SELECT rs.id,
|
||||
UNNEST(ARRAY[
|
||||
'documents:read'::api_capability,
|
||||
'folders:read'::api_capability,
|
||||
'tags:read'::api_capability,
|
||||
'correspondents:read'::api_capability,
|
||||
'webdav:read'::api_capability
|
||||
]) AS capability
|
||||
FROM readonly_sets rs
|
||||
UNION ALL
|
||||
SELECT ws.id,
|
||||
UNNEST(ARRAY['webdav:read'::api_capability]) AS capability
|
||||
FROM webdav_sets ws
|
||||
) seeded;
|
||||
|
||||
UPDATE tenant.user_memberships um
|
||||
SET capability_set_id = cs.id
|
||||
FROM tenant.capability_sets cs
|
||||
WHERE cs.tenant_id = um.tenant_id
|
||||
AND cs.slug = 'owner';
|
||||
|
||||
UPDATE tenant.api_tokens t
|
||||
SET capability_set_id = cs.id
|
||||
FROM tenant.capability_sets cs
|
||||
WHERE cs.tenant_id = t.tenant_id
|
||||
AND cs.slug = 'owner';
|
||||
|
||||
UPDATE tenant.api_tokens t
|
||||
SET capability_set_id = cs.id
|
||||
FROM tenant.capability_sets cs
|
||||
WHERE cs.tenant_id = t.tenant_id
|
||||
AND cs.slug = 'webdav'
|
||||
AND t.capability_set_id IS NULL;
|
||||
|
||||
ALTER TABLE tenant.api_tokens
|
||||
ALTER COLUMN capability_set_id SET NOT NULL;
|
||||
|
||||
ALTER TABLE tenant.api_tokens
|
||||
DROP COLUMN capabilities;
|
||||
|
||||
DROP TYPE IF EXISTS api_token_capability;
|
||||
@@ -0,0 +1 @@
|
||||
DROP INDEX IF EXISTS shared.jobs_purge_document_pending_unique;
|
||||
@@ -0,0 +1,8 @@
|
||||
CREATE UNIQUE INDEX jobs_purge_document_pending_unique
|
||||
ON shared.jobs (
|
||||
tenant_id,
|
||||
((payload ->> 'document_id')::uuid)
|
||||
)
|
||||
WHERE job_type = 'purge-document'
|
||||
AND payload ? 'document_id'
|
||||
AND status IN ('queued', 'processing');
|
||||
@@ -0,0 +1,5 @@
|
||||
DROP INDEX IF EXISTS tenant.idx_documents_tenant_folder_updated_at;
|
||||
DROP INDEX IF EXISTS tenant.idx_documents_tenant_folder_created_at;
|
||||
DROP INDEX IF EXISTS tenant.idx_documents_tenant_folder_issued_at;
|
||||
DROP INDEX IF EXISTS tenant.idx_documents_tenant_folder_title_order;
|
||||
DROP COLLATION IF EXISTS unicode_ci;
|
||||
@@ -0,0 +1,37 @@
|
||||
CREATE COLLATION IF NOT EXISTS unicode_ci
|
||||
(provider = icu, locale = 'und-u-ks-level2');
|
||||
|
||||
CREATE INDEX idx_documents_tenant_folder_title_order
|
||||
ON tenant.documents (
|
||||
tenant_id,
|
||||
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
title COLLATE "unicode_ci"
|
||||
)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE INDEX idx_documents_tenant_folder_issued_at
|
||||
ON tenant.documents (
|
||||
tenant_id,
|
||||
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
issued_at,
|
||||
title COLLATE "unicode_ci"
|
||||
)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE INDEX idx_documents_tenant_folder_created_at
|
||||
ON tenant.documents (
|
||||
tenant_id,
|
||||
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
created_at,
|
||||
title COLLATE "unicode_ci"
|
||||
)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE INDEX idx_documents_tenant_folder_updated_at
|
||||
ON tenant.documents (
|
||||
tenant_id,
|
||||
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
updated_at,
|
||||
title COLLATE "unicode_ci"
|
||||
)
|
||||
WHERE deleted_at IS NULL;
|
||||
@@ -0,0 +1,13 @@
|
||||
ALTER TABLE shared.jobs
|
||||
DROP CONSTRAINT jobs_tenant_id_fkey;
|
||||
|
||||
ALTER TABLE shared.jobs
|
||||
ALTER COLUMN tenant_id SET NOT NULL;
|
||||
|
||||
ALTER TABLE shared.jobs
|
||||
ADD CONSTRAINT jobs_tenant_id_fkey
|
||||
FOREIGN KEY (tenant_id)
|
||||
REFERENCES shared.tenants(id);
|
||||
|
||||
ALTER TABLE shared.jobs
|
||||
DROP COLUMN result;
|
||||
@@ -0,0 +1,14 @@
|
||||
ALTER TABLE shared.jobs
|
||||
ALTER COLUMN tenant_id DROP NOT NULL;
|
||||
|
||||
ALTER TABLE shared.jobs
|
||||
DROP CONSTRAINT jobs_tenant_id_fkey;
|
||||
|
||||
ALTER TABLE shared.jobs
|
||||
ADD CONSTRAINT jobs_tenant_id_fkey
|
||||
FOREIGN KEY (tenant_id)
|
||||
REFERENCES shared.tenants(id)
|
||||
ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE shared.jobs
|
||||
ADD COLUMN result JSONB;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- diesel:run_in_transaction = false
|
||||
|
||||
-- Enum values cannot be removed safely; this down migration intentionally left empty.
|
||||
SELECT 1;
|
||||
@@ -0,0 +1,46 @@
|
||||
-- diesel:run_in_transaction = false
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
BEGIN
|
||||
EXECUTE 'ALTER TYPE tenant.api_capability ADD VALUE ''tenants:write''';
|
||||
EXCEPTION
|
||||
WHEN undefined_object THEN
|
||||
BEGIN
|
||||
EXECUTE 'ALTER TYPE api_capability ADD VALUE ''tenants:write''';
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END;
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END;
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
BEGIN
|
||||
EXECUTE 'ALTER TYPE tenant.api_capability ADD VALUE ''tenants:reset''';
|
||||
EXCEPTION
|
||||
WHEN undefined_object THEN
|
||||
BEGIN
|
||||
EXECUTE 'ALTER TYPE api_capability ADD VALUE ''tenants:reset''';
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END;
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END;
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
BEGIN
|
||||
EXECUTE 'ALTER TYPE tenant.api_capability ADD VALUE ''tenants:delete''';
|
||||
EXCEPTION
|
||||
WHEN undefined_object THEN
|
||||
BEGIN
|
||||
EXECUTE 'ALTER TYPE api_capability ADD VALUE ''tenants:delete''';
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END;
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END;
|
||||
END $$;
|
||||
@@ -0,0 +1,9 @@
|
||||
-- diesel:run_in_transaction = false
|
||||
|
||||
DELETE FROM tenant.capability_set_capabilities
|
||||
WHERE capability IN (
|
||||
'tenants:write'::api_capability,
|
||||
'tenants:reset'::api_capability,
|
||||
'tenants:delete'::api_capability
|
||||
)
|
||||
AND capability_set_id IN (SELECT id FROM tenant.capability_sets WHERE slug = 'owner');
|
||||
@@ -0,0 +1,22 @@
|
||||
-- diesel:run_in_transaction = false
|
||||
|
||||
WITH owner_sets AS (
|
||||
SELECT id FROM tenant.capability_sets WHERE slug = 'owner'
|
||||
)
|
||||
INSERT INTO tenant.capability_set_capabilities (capability_set_id, capability)
|
||||
SELECT id, 'tenants:write'::api_capability FROM owner_sets
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
WITH owner_sets AS (
|
||||
SELECT id FROM tenant.capability_sets WHERE slug = 'owner'
|
||||
)
|
||||
INSERT INTO tenant.capability_set_capabilities (capability_set_id, capability)
|
||||
SELECT id, 'tenants:reset'::api_capability FROM owner_sets
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
WITH owner_sets AS (
|
||||
SELECT id FROM tenant.capability_sets WHERE slug = 'owner'
|
||||
)
|
||||
INSERT INTO tenant.capability_set_capabilities (capability_set_id, capability)
|
||||
SELECT id, 'tenants:delete'::api_capability FROM owner_sets
|
||||
ON CONFLICT DO NOTHING;
|
||||
@@ -0,0 +1,24 @@
|
||||
CREATE TABLE tenant.document_asset_objects (
|
||||
id UUID PRIMARY KEY,
|
||||
asset_id UUID NOT NULL REFERENCES tenant.document_assets(id) ON DELETE CASCADE,
|
||||
ordinal INT NOT NULL,
|
||||
s3_key TEXT NOT NULL,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
|
||||
CONSTRAINT document_asset_objects_ordinal_positive CHECK (ordinal >= 1),
|
||||
CONSTRAINT document_asset_objects_asset_ordinal_unique UNIQUE (asset_id, ordinal)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_document_asset_objects_asset_ordinal
|
||||
ON tenant.document_asset_objects(asset_id, ordinal);
|
||||
|
||||
CREATE INDEX document_asset_objects_tenant_id_idx ON tenant.document_asset_objects(tenant_id);
|
||||
|
||||
ALTER TABLE tenant.document_assets ADD COLUMN cardinality INT;
|
||||
UPDATE tenant.document_assets SET cardinality = 1;
|
||||
|
||||
INSERT INTO tenant.document_asset_objects (id, asset_id, ordinal, s3_key, metadata, tenant_id)
|
||||
SELECT gen_random_uuid(), id, 1, s3_key, metadata, tenant_id
|
||||
FROM tenant.document_assets;
|
||||
|
||||
ALTER TABLE tenant.document_assets DROP COLUMN s3_key;
|
||||
@@ -0,0 +1,27 @@
|
||||
-- Prevent concurrent inserts/updates during backfill.
|
||||
LOCK TABLE tenant.document_asset_objects IN ACCESS EXCLUSIVE MODE;
|
||||
LOCK TABLE tenant.document_assets IN ACCESS EXCLUSIVE MODE;
|
||||
|
||||
ALTER TABLE tenant.document_assets ADD COLUMN s3_key TEXT;
|
||||
|
||||
UPDATE tenant.document_assets AS da
|
||||
SET s3_key = o.s3_key,
|
||||
metadata = COALESCE(da.metadata, '{}'::jsonb) || COALESCE(o.metadata, '{}'::jsonb)
|
||||
FROM tenant.document_asset_objects AS o
|
||||
WHERE o.asset_id = da.id
|
||||
AND o.ordinal = 1;
|
||||
|
||||
DELETE FROM tenant.document_asset_objects WHERE ordinal <> 1;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM tenant.document_assets WHERE s3_key IS NULL) THEN
|
||||
RAISE EXCEPTION 'cannot drop document_asset_objects: some assets are missing a populated ordinal 1 object (s3_key null)';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
ALTER TABLE tenant.document_assets ALTER COLUMN s3_key SET NOT NULL;
|
||||
ALTER TABLE tenant.document_assets DROP COLUMN cardinality;
|
||||
|
||||
DROP TABLE tenant.document_asset_objects;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Revert column rename.
|
||||
ALTER TABLE tenant.documents
|
||||
RENAME COLUMN mime_type TO content_type;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Rename document content_type column to mime_type for consistency with API.
|
||||
ALTER TABLE tenant.documents
|
||||
RENAME COLUMN content_type TO mime_type;
|
||||
@@ -0,0 +1,3 @@
|
||||
UPDATE tenant.document_assets
|
||||
SET asset_type = 'ocr-text'
|
||||
WHERE asset_type = 'text-content';
|
||||
@@ -0,0 +1,3 @@
|
||||
UPDATE tenant.document_assets
|
||||
SET asset_type = 'text-content'
|
||||
WHERE asset_type = 'ocr-text';
|
||||
@@ -0,0 +1 @@
|
||||
DROP INDEX IF EXISTS tenant.idx_documents_title_trgm;
|
||||
@@ -0,0 +1,6 @@
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
|
||||
CREATE INDEX idx_documents_title_trgm
|
||||
ON tenant.documents
|
||||
USING gin (title gin_trgm_ops)
|
||||
WHERE deleted_at IS NULL;
|
||||
@@ -0,0 +1,21 @@
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'papercrate_app') THEN
|
||||
CREATE ROLE papercrate_app NOLOGIN;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'papercrate_app_login') THEN
|
||||
CREATE ROLE papercrate_app_login LOGIN PASSWORD 'papercrate_app';
|
||||
GRANT papercrate_app TO papercrate_app_login;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- Ensure the login role inherits and uses a sensible search path by default
|
||||
ALTER ROLE papercrate_app_login INHERIT;
|
||||
ALTER ROLE papercrate_app_login SET search_path = 'tenant, shared, public';
|
||||
|
||||
GRANT CONNECT ON DATABASE papercrate TO papercrate_app;
|
||||
GRANT CONNECT ON DATABASE papercrate TO papercrate_app_login;
|
||||
GRANT USAGE ON SCHEMA public TO papercrate_app;
|
||||
GRANT USAGE ON SCHEMA public TO papercrate_app_login;
|
||||
@@ -0,0 +1,648 @@
|
||||
months:
|
||||
- name: "january"
|
||||
month: 1
|
||||
locales:
|
||||
- "en"
|
||||
- name: "jan"
|
||||
month: 1
|
||||
locales:
|
||||
- "en"
|
||||
- name: "janvier"
|
||||
month: 1
|
||||
locales:
|
||||
- "fr"
|
||||
- name: "janv"
|
||||
month: 1
|
||||
locales:
|
||||
- "fr"
|
||||
- name: "januar"
|
||||
month: 1
|
||||
locales:
|
||||
- "de"
|
||||
- name: "janu\u00e1r"
|
||||
month: 1
|
||||
locales:
|
||||
- "hu"
|
||||
- name: "leden"
|
||||
month: 1
|
||||
locales:
|
||||
- "cs"
|
||||
- name: "sije\u010danj"
|
||||
month: 1
|
||||
locales:
|
||||
- "hr"
|
||||
- name: "sijecanj"
|
||||
month: 1
|
||||
locales:
|
||||
- "hr"
|
||||
- name: "stycze\u0144"
|
||||
month: 1
|
||||
locales:
|
||||
- "pl"
|
||||
- name: "styczen"
|
||||
month: 1
|
||||
locales:
|
||||
- "pl"
|
||||
- name: "ocak"
|
||||
month: 1
|
||||
locales:
|
||||
- "tr"
|
||||
- name: "february"
|
||||
month: 2
|
||||
locales:
|
||||
- "en"
|
||||
- name: "feb"
|
||||
month: 2
|
||||
locales:
|
||||
- "en"
|
||||
- name: "f\u00e9vrier"
|
||||
month: 2
|
||||
locales:
|
||||
- "fr"
|
||||
- name: "fevrier"
|
||||
month: 2
|
||||
locales:
|
||||
- "fr"
|
||||
- name: "f\u00e9vr"
|
||||
month: 2
|
||||
locales:
|
||||
- "fr"
|
||||
- name: "fevr"
|
||||
month: 2
|
||||
locales:
|
||||
- "fr"
|
||||
- name: "februar"
|
||||
month: 2
|
||||
locales:
|
||||
- "de"
|
||||
- name: "\u00fanor"
|
||||
month: 2
|
||||
locales:
|
||||
- "cs"
|
||||
- name: "unor"
|
||||
month: 2
|
||||
locales:
|
||||
- "cs"
|
||||
- name: "\u00fanora"
|
||||
month: 2
|
||||
locales:
|
||||
- "cs"
|
||||
- name: "unora"
|
||||
month: 2
|
||||
locales:
|
||||
- "cs"
|
||||
- name: "velja\u010da"
|
||||
month: 2
|
||||
locales:
|
||||
- "hr"
|
||||
- name: "veljaca"
|
||||
month: 2
|
||||
locales:
|
||||
- "hr"
|
||||
- name: "velja\u010de"
|
||||
month: 2
|
||||
locales:
|
||||
- "hr"
|
||||
- name: "veljace"
|
||||
month: 2
|
||||
locales:
|
||||
- "hr"
|
||||
- name: "luty"
|
||||
month: 2
|
||||
locales:
|
||||
- "pl"
|
||||
- name: "\u015fubat"
|
||||
month: 2
|
||||
locales:
|
||||
- "tr"
|
||||
- name: "subat"
|
||||
month: 2
|
||||
locales:
|
||||
- "tr"
|
||||
- name: "march"
|
||||
month: 3
|
||||
locales:
|
||||
- "en"
|
||||
- name: "mar"
|
||||
month: 3
|
||||
locales:
|
||||
- "en"
|
||||
- name: "m\u00e4rz"
|
||||
month: 3
|
||||
locales:
|
||||
- "de"
|
||||
- name: "maerz"
|
||||
month: 3
|
||||
locales:
|
||||
- "de"
|
||||
- name: "mars"
|
||||
month: 3
|
||||
locales:
|
||||
- "fr"
|
||||
- name: "m\u00e4r"
|
||||
month: 3
|
||||
locales:
|
||||
- "de"
|
||||
- name: "marz"
|
||||
month: 3
|
||||
locales:
|
||||
- "de"
|
||||
- name: "b\u0159ezen"
|
||||
month: 3
|
||||
locales:
|
||||
- "cs"
|
||||
- name: "brezen"
|
||||
month: 3
|
||||
locales:
|
||||
- "cs"
|
||||
- name: "b\u0159ezna"
|
||||
month: 3
|
||||
locales:
|
||||
- "cs"
|
||||
- name: "brezna"
|
||||
month: 3
|
||||
locales:
|
||||
- "cs"
|
||||
- name: "o\u017eujak"
|
||||
month: 3
|
||||
locales:
|
||||
- "hr"
|
||||
- name: "ozujak"
|
||||
month: 3
|
||||
locales:
|
||||
- "hr"
|
||||
- name: "o\u017eujka"
|
||||
month: 3
|
||||
locales:
|
||||
- "hr"
|
||||
- name: "ozujka"
|
||||
month: 3
|
||||
locales:
|
||||
- "hr"
|
||||
- name: "marzec"
|
||||
month: 3
|
||||
locales:
|
||||
- "pl"
|
||||
- name: "mart"
|
||||
month: 3
|
||||
locales:
|
||||
- "sr"
|
||||
- "bs"
|
||||
- name: "m\u00e1rcius"
|
||||
month: 3
|
||||
locales:
|
||||
- "hu"
|
||||
- name: "martie"
|
||||
month: 3
|
||||
locales:
|
||||
- "ro"
|
||||
- name: "april"
|
||||
month: 4
|
||||
locales:
|
||||
- "en"
|
||||
- name: "apr"
|
||||
month: 4
|
||||
locales:
|
||||
- "en"
|
||||
- name: "avril"
|
||||
month: 4
|
||||
locales:
|
||||
- "fr"
|
||||
- name: "abril"
|
||||
month: 4
|
||||
locales:
|
||||
- "es"
|
||||
- "pt"
|
||||
- name: "duben"
|
||||
month: 4
|
||||
locales:
|
||||
- "cs"
|
||||
- name: "travanj"
|
||||
month: 4
|
||||
locales:
|
||||
- "hr"
|
||||
- name: "nisan"
|
||||
month: 4
|
||||
locales:
|
||||
- "tr"
|
||||
- name: "kwiecie\u0144"
|
||||
month: 4
|
||||
locales:
|
||||
- "pl"
|
||||
- name: "kwiecien"
|
||||
month: 4
|
||||
locales:
|
||||
- "pl"
|
||||
- name: "aprile"
|
||||
month: 4
|
||||
locales:
|
||||
- "it"
|
||||
- name: "may"
|
||||
month: 5
|
||||
locales:
|
||||
- "en"
|
||||
- name: "mai"
|
||||
month: 5
|
||||
locales:
|
||||
- "fr"
|
||||
- "de"
|
||||
- name: "mayo"
|
||||
month: 5
|
||||
locales:
|
||||
- "es"
|
||||
- name: "kv\u011bten"
|
||||
month: 5
|
||||
locales:
|
||||
- "cs"
|
||||
- name: "kveten"
|
||||
month: 5
|
||||
locales:
|
||||
- "cs"
|
||||
- name: "kv\u011btna"
|
||||
month: 5
|
||||
locales:
|
||||
- "cs"
|
||||
- name: "kvetna"
|
||||
month: 5
|
||||
locales:
|
||||
- "cs"
|
||||
- name: "svibanj"
|
||||
month: 5
|
||||
locales:
|
||||
- "hr"
|
||||
- name: "maj"
|
||||
month: 5
|
||||
locales:
|
||||
- "pl"
|
||||
- "bs"
|
||||
- "sr"
|
||||
- "hr"
|
||||
- name: "may\u0131s"
|
||||
month: 5
|
||||
locales:
|
||||
- "tr"
|
||||
- name: "mayis"
|
||||
month: 5
|
||||
locales:
|
||||
- "tr"
|
||||
- name: "maggio"
|
||||
month: 5
|
||||
locales:
|
||||
- "it"
|
||||
- name: "m\u00e1j"
|
||||
month: 5
|
||||
locales:
|
||||
- "hu"
|
||||
- "sk"
|
||||
- name: "june"
|
||||
month: 6
|
||||
locales:
|
||||
- "en"
|
||||
- name: "jun"
|
||||
month: 6
|
||||
locales:
|
||||
- "en"
|
||||
- "de"
|
||||
- name: "juin"
|
||||
month: 6
|
||||
locales:
|
||||
- "fr"
|
||||
- name: "junio"
|
||||
month: 6
|
||||
locales:
|
||||
- "es"
|
||||
- name: "juni"
|
||||
month: 6
|
||||
locales:
|
||||
- "de"
|
||||
- name: "\u010derven"
|
||||
month: 6
|
||||
locales:
|
||||
- "cs"
|
||||
- name: "cerven"
|
||||
month: 6
|
||||
locales:
|
||||
- "cs"
|
||||
- name: "lipanj"
|
||||
month: 6
|
||||
locales:
|
||||
- "hr"
|
||||
- name: "haziran"
|
||||
month: 6
|
||||
locales:
|
||||
- "tr"
|
||||
- name: "czerwiec"
|
||||
month: 6
|
||||
locales:
|
||||
- "pl"
|
||||
- name: "j\u00fanius"
|
||||
month: 6
|
||||
locales:
|
||||
- "hu"
|
||||
- name: "giugno"
|
||||
month: 6
|
||||
locales:
|
||||
- "it"
|
||||
- name: "july"
|
||||
month: 7
|
||||
locales:
|
||||
- "en"
|
||||
- name: "jul"
|
||||
month: 7
|
||||
locales:
|
||||
- "en"
|
||||
- "de"
|
||||
- name: "juillet"
|
||||
month: 7
|
||||
locales:
|
||||
- "fr"
|
||||
- name: "julio"
|
||||
month: 7
|
||||
locales:
|
||||
- "es"
|
||||
- name: "temmuz"
|
||||
month: 7
|
||||
locales:
|
||||
- "tr"
|
||||
- name: "\u010dervenec"
|
||||
month: 7
|
||||
locales:
|
||||
- "cs"
|
||||
- name: "cervenec"
|
||||
month: 7
|
||||
locales:
|
||||
- "cs"
|
||||
- name: "srpanj"
|
||||
month: 7
|
||||
locales:
|
||||
- "hr"
|
||||
- name: "lipiec"
|
||||
month: 7
|
||||
locales:
|
||||
- "pl"
|
||||
- name: "luglio"
|
||||
month: 7
|
||||
locales:
|
||||
- "it"
|
||||
- name: "j\u00falius"
|
||||
month: 7
|
||||
locales:
|
||||
- "hu"
|
||||
- name: "august"
|
||||
month: 8
|
||||
locales:
|
||||
- "en"
|
||||
- name: "aug"
|
||||
month: 8
|
||||
locales:
|
||||
- "en"
|
||||
- "de"
|
||||
- name: "ao\u00fbt"
|
||||
month: 8
|
||||
locales:
|
||||
- "fr"
|
||||
- name: "aout"
|
||||
month: 8
|
||||
locales:
|
||||
- "fr"
|
||||
- name: "agosto"
|
||||
month: 8
|
||||
locales:
|
||||
- "es"
|
||||
- "pt"
|
||||
- "it"
|
||||
- name: "a\u011fustos"
|
||||
month: 8
|
||||
locales:
|
||||
- "tr"
|
||||
- name: "agustos"
|
||||
month: 8
|
||||
locales:
|
||||
- "tr"
|
||||
- name: "kolovoz"
|
||||
month: 8
|
||||
locales:
|
||||
- "hr"
|
||||
- name: "srpen"
|
||||
month: 8
|
||||
locales:
|
||||
- "cs"
|
||||
- name: "sierpie\u0144"
|
||||
month: 8
|
||||
locales:
|
||||
- "pl"
|
||||
- name: "sierpien"
|
||||
month: 8
|
||||
locales:
|
||||
- "pl"
|
||||
- name: "augustus"
|
||||
month: 8
|
||||
locales:
|
||||
- "nl"
|
||||
- name: "agost"
|
||||
month: 8
|
||||
locales:
|
||||
- "it"
|
||||
- "ca"
|
||||
- name: "september"
|
||||
month: 9
|
||||
locales:
|
||||
- "en"
|
||||
- name: "sept"
|
||||
month: 9
|
||||
locales:
|
||||
- "en"
|
||||
- "fr"
|
||||
- name: "sep"
|
||||
month: 9
|
||||
locales:
|
||||
- "en"
|
||||
- name: "septembre"
|
||||
month: 9
|
||||
locales:
|
||||
- "fr"
|
||||
- name: "septiembre"
|
||||
month: 9
|
||||
locales:
|
||||
- "es"
|
||||
- name: "eyl\u00fcl"
|
||||
month: 9
|
||||
locales:
|
||||
- "tr"
|
||||
- name: "eylul"
|
||||
month: 9
|
||||
locales:
|
||||
- "tr"
|
||||
- name: "z\u00e1\u0159\u00ed"
|
||||
month: 9
|
||||
locales:
|
||||
- "cs"
|
||||
- name: "zari"
|
||||
month: 9
|
||||
locales:
|
||||
- "cs"
|
||||
- name: "rujan"
|
||||
month: 9
|
||||
locales:
|
||||
- "hr"
|
||||
- name: "wrzesie\u0144"
|
||||
month: 9
|
||||
locales:
|
||||
- "pl"
|
||||
- name: "wrzesien"
|
||||
month: 9
|
||||
locales:
|
||||
- "pl"
|
||||
- name: "septembrie"
|
||||
month: 9
|
||||
locales:
|
||||
- "ro"
|
||||
- name: "settembre"
|
||||
month: 9
|
||||
locales:
|
||||
- "it"
|
||||
- name: "october"
|
||||
month: 10
|
||||
locales:
|
||||
- "en"
|
||||
- name: "oct"
|
||||
month: 10
|
||||
locales:
|
||||
- "en"
|
||||
- name: "oktober"
|
||||
month: 10
|
||||
locales:
|
||||
- "de"
|
||||
- name: "okt\u00f3ber"
|
||||
month: 10
|
||||
locales:
|
||||
- "hu"
|
||||
- name: "octobre"
|
||||
month: 10
|
||||
locales:
|
||||
- "fr"
|
||||
- name: "octubre"
|
||||
month: 10
|
||||
locales:
|
||||
- "es"
|
||||
- name: "\u0159\u00edjen"
|
||||
month: 10
|
||||
locales:
|
||||
- "cs"
|
||||
- name: "rijen"
|
||||
month: 10
|
||||
locales:
|
||||
- "hr"
|
||||
- name: "pa\u017adziernik"
|
||||
month: 10
|
||||
locales:
|
||||
- "pl"
|
||||
- name: "pazdziernik"
|
||||
month: 10
|
||||
locales:
|
||||
- "pl"
|
||||
- name: "ekim"
|
||||
month: 10
|
||||
locales:
|
||||
- "tr"
|
||||
- name: "octombrie"
|
||||
month: 10
|
||||
locales:
|
||||
- "ro"
|
||||
- name: "november"
|
||||
month: 11
|
||||
locales:
|
||||
- "en"
|
||||
- name: "nov"
|
||||
month: 11
|
||||
locales:
|
||||
- "en"
|
||||
- name: "novembre"
|
||||
month: 11
|
||||
locales:
|
||||
- "fr"
|
||||
- name: "noviembre"
|
||||
month: 11
|
||||
locales:
|
||||
- "es"
|
||||
- name: "studeni"
|
||||
month: 11
|
||||
locales:
|
||||
- "hr"
|
||||
- name: "kas\u0131m"
|
||||
month: 11
|
||||
locales:
|
||||
- "tr"
|
||||
- name: "kasim"
|
||||
month: 11
|
||||
locales:
|
||||
- "tr"
|
||||
- name: "listopad"
|
||||
month: 11
|
||||
locales:
|
||||
- "pl"
|
||||
- "cs"
|
||||
- name: "novembro"
|
||||
month: 11
|
||||
locales:
|
||||
- "pt"
|
||||
- name: "listopadu"
|
||||
month: 11
|
||||
locales:
|
||||
- "cs"
|
||||
- name: "noiembrie"
|
||||
month: 11
|
||||
locales:
|
||||
- "ro"
|
||||
- name: "december"
|
||||
month: 12
|
||||
locales:
|
||||
- "en"
|
||||
- name: "dec"
|
||||
month: 12
|
||||
locales:
|
||||
- "en"
|
||||
- name: "dezember"
|
||||
month: 12
|
||||
locales:
|
||||
- "de"
|
||||
- name: "d\u00e9cembre"
|
||||
month: 12
|
||||
locales:
|
||||
- "fr"
|
||||
- name: "decembre"
|
||||
month: 12
|
||||
locales:
|
||||
- "fr"
|
||||
- name: "prosinec"
|
||||
month: 12
|
||||
locales:
|
||||
- "cs"
|
||||
- name: "prosinac"
|
||||
month: 12
|
||||
locales:
|
||||
- "hr"
|
||||
- name: "grudzie\u0144"
|
||||
month: 12
|
||||
locales:
|
||||
- "pl"
|
||||
- name: "grudzien"
|
||||
month: 12
|
||||
locales:
|
||||
- "pl"
|
||||
- name: "grudnia"
|
||||
month: 12
|
||||
locales:
|
||||
- "pl"
|
||||
- name: "aral\u0131k"
|
||||
month: 12
|
||||
locales:
|
||||
- "tr"
|
||||
- name: "aralik"
|
||||
month: 12
|
||||
locales:
|
||||
- "tr"
|
||||
- name: "decembrie"
|
||||
month: 12
|
||||
locales:
|
||||
- "ro"
|
||||
@@ -0,0 +1,324 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
http::{Request, StatusCode},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use tower::{Layer, Service};
|
||||
|
||||
use crate::{auth::AuthenticatedUser, error::AppError, models::ApiCapability};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum CapabilityStrategy {
|
||||
All,
|
||||
Any,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RequireCapabilitiesLayer {
|
||||
required: Arc<Vec<ApiCapability>>,
|
||||
strategy: CapabilityStrategy,
|
||||
}
|
||||
|
||||
impl RequireCapabilitiesLayer {
|
||||
pub fn all<I>(caps: I) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = ApiCapability>,
|
||||
{
|
||||
Self {
|
||||
required: Arc::new(caps.into_iter().collect()),
|
||||
strategy: CapabilityStrategy::All,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn any<I>(caps: I) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = ApiCapability>,
|
||||
{
|
||||
Self {
|
||||
required: Arc::new(caps.into_iter().collect()),
|
||||
strategy: CapabilityStrategy::Any,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> Layer<S> for RequireCapabilitiesLayer {
|
||||
type Service = RequireCapabilities<S>;
|
||||
|
||||
fn layer(&self, inner: S) -> Self::Service {
|
||||
RequireCapabilities {
|
||||
inner,
|
||||
required: Arc::clone(&self.required),
|
||||
strategy: self.strategy,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RequireCapabilities<S> {
|
||||
inner: S,
|
||||
required: Arc<Vec<ApiCapability>>,
|
||||
strategy: CapabilityStrategy,
|
||||
}
|
||||
|
||||
impl<S, B> Service<Request<B>> for RequireCapabilities<S>
|
||||
where
|
||||
S: Service<Request<B>, Response = axum::response::Response> + Send,
|
||||
S::Future: Send + 'static,
|
||||
B: Send + 'static,
|
||||
{
|
||||
type Response = S::Response;
|
||||
type Error = S::Error;
|
||||
type Future = std::pin::Pin<
|
||||
Box<dyn std::future::Future<Output = Result<Self::Response, Self::Error>> + Send>,
|
||||
>;
|
||||
|
||||
fn poll_ready(
|
||||
&mut self,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<Result<(), Self::Error>> {
|
||||
self.inner.poll_ready(cx)
|
||||
}
|
||||
|
||||
fn call(&mut self, req: Request<B>) -> Self::Future {
|
||||
if self.required.is_empty() {
|
||||
let fut = self.inner.call(req);
|
||||
return Box::pin(async move { fut.await });
|
||||
}
|
||||
|
||||
let (parts, body) = req.into_parts();
|
||||
let user = match parts.extensions.get::<AuthenticatedUser>() {
|
||||
Some(user) => user,
|
||||
None => {
|
||||
let response = AppError::unauthorized().into_response();
|
||||
return Box::pin(async move { Ok(response) });
|
||||
}
|
||||
};
|
||||
|
||||
let allowed = match self.strategy {
|
||||
CapabilityStrategy::All => self
|
||||
.required
|
||||
.iter()
|
||||
.all(|cap| user.capabilities.contains(cap)),
|
||||
CapabilityStrategy::Any => self
|
||||
.required
|
||||
.iter()
|
||||
.any(|cap| user.capabilities.contains(cap)),
|
||||
};
|
||||
|
||||
if !allowed {
|
||||
let response = AppError::new(StatusCode::FORBIDDEN, "missing required capability")
|
||||
.with_code("missing_capability")
|
||||
.into_response();
|
||||
return Box::pin(async move { Ok(response) });
|
||||
}
|
||||
|
||||
let req = Request::from_parts(parts, body);
|
||||
let fut = self.inner.call(req);
|
||||
Box::pin(async move { fut.await })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
use chrono::Utc;
|
||||
use diesel::{pg::Pg, prelude::*, Connection};
|
||||
use sha2::{Digest, Sha256};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
error::AppError,
|
||||
models::{ApiCapability, CapabilitySet, NewCapabilitySet, NewCapabilitySetCapability},
|
||||
schema::{
|
||||
capability_set_capabilities, capability_set_capabilities::dsl as csc_dsl, capability_sets,
|
||||
capability_sets::dsl as cs_dsl,
|
||||
},
|
||||
};
|
||||
|
||||
const OWNER_CAPABILITIES: [ApiCapability; 22] = [
|
||||
ApiCapability::CorrespondentsEdit,
|
||||
ApiCapability::CorrespondentsRead,
|
||||
ApiCapability::CorrespondentsWrite,
|
||||
ApiCapability::DocumentsEdit,
|
||||
ApiCapability::DocumentsRead,
|
||||
ApiCapability::DocumentsUpload,
|
||||
ApiCapability::DocumentsWrite,
|
||||
ApiCapability::FoldersEdit,
|
||||
ApiCapability::FoldersRead,
|
||||
ApiCapability::FoldersWrite,
|
||||
ApiCapability::ProfileRead,
|
||||
ApiCapability::ProfileWrite,
|
||||
ApiCapability::TagsEdit,
|
||||
ApiCapability::TagsRead,
|
||||
ApiCapability::TagsWrite,
|
||||
ApiCapability::WebdavRead,
|
||||
ApiCapability::WebdavWrite,
|
||||
ApiCapability::CapabilitySetsRead,
|
||||
ApiCapability::CapabilitySetsWrite,
|
||||
ApiCapability::TenantsWrite,
|
||||
ApiCapability::TenantsReset,
|
||||
ApiCapability::TenantsDelete,
|
||||
];
|
||||
|
||||
const USER_CAPABILITIES: [ApiCapability; 16] = [
|
||||
ApiCapability::CorrespondentsEdit,
|
||||
ApiCapability::CorrespondentsRead,
|
||||
ApiCapability::CorrespondentsWrite,
|
||||
ApiCapability::DocumentsEdit,
|
||||
ApiCapability::DocumentsRead,
|
||||
ApiCapability::DocumentsUpload,
|
||||
ApiCapability::DocumentsWrite,
|
||||
ApiCapability::FoldersEdit,
|
||||
ApiCapability::FoldersRead,
|
||||
ApiCapability::FoldersWrite,
|
||||
ApiCapability::ProfileRead,
|
||||
ApiCapability::ProfileWrite,
|
||||
ApiCapability::TagsEdit,
|
||||
ApiCapability::TagsRead,
|
||||
ApiCapability::TagsWrite,
|
||||
ApiCapability::WebdavRead,
|
||||
];
|
||||
|
||||
const READONLY_CAPABILITIES: [ApiCapability; 5] = [
|
||||
ApiCapability::CorrespondentsRead,
|
||||
ApiCapability::DocumentsRead,
|
||||
ApiCapability::FoldersRead,
|
||||
ApiCapability::TagsRead,
|
||||
ApiCapability::WebdavRead,
|
||||
];
|
||||
|
||||
const WEBDAV_CAPABILITIES: [ApiCapability; 1] = [ApiCapability::WebdavRead];
|
||||
|
||||
pub fn owner_capabilities() -> &'static [ApiCapability] {
|
||||
&OWNER_CAPABILITIES
|
||||
}
|
||||
|
||||
pub fn user_capabilities() -> &'static [ApiCapability] {
|
||||
&USER_CAPABILITIES
|
||||
}
|
||||
|
||||
pub fn readonly_capabilities() -> &'static [ApiCapability] {
|
||||
&READONLY_CAPABILITIES
|
||||
}
|
||||
|
||||
pub fn webdav_capabilities() -> &'static [ApiCapability] {
|
||||
&WEBDAV_CAPABILITIES
|
||||
}
|
||||
|
||||
pub fn is_system_slug(slug: &str) -> bool {
|
||||
matches!(slug, "owner" | "user" | "readonly" | "webdav")
|
||||
}
|
||||
|
||||
pub fn create_capability_set<C>(
|
||||
conn: &mut C,
|
||||
tenant_id: Uuid,
|
||||
slug: &str,
|
||||
capabilities: Vec<ApiCapability>,
|
||||
) -> Result<CapabilitySet, AppError>
|
||||
where
|
||||
C: Connection<Backend = Pg> + diesel::connection::LoadConnection,
|
||||
{
|
||||
let normalized = normalize_capabilities(capabilities)?;
|
||||
|
||||
conn.transaction::<CapabilitySet, AppError, _>(|conn| {
|
||||
if cs_dsl::capability_sets
|
||||
.filter(cs_dsl::tenant_id.eq(tenant_id))
|
||||
.filter(cs_dsl::slug.eq(slug))
|
||||
.first::<CapabilitySet>(conn)
|
||||
.optional()
|
||||
.map_err(AppError::from)?
|
||||
.is_some()
|
||||
{
|
||||
return Err(AppError::conflict("capability set slug already exists"));
|
||||
}
|
||||
|
||||
let set = NewCapabilitySet {
|
||||
id: Uuid::new_v4(),
|
||||
tenant_id,
|
||||
slug: slug.to_owned(),
|
||||
cap_version: 1,
|
||||
is_system: false,
|
||||
};
|
||||
|
||||
diesel::insert_into(capability_sets::table)
|
||||
.values(&set)
|
||||
.execute(conn)
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
persist_capabilities(conn, set.id, &normalized)?;
|
||||
|
||||
capability_sets::table
|
||||
.find(set.id)
|
||||
.first::<CapabilitySet>(conn)
|
||||
.map_err(AppError::from)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn normalize_capabilities(
|
||||
mut capabilities: Vec<ApiCapability>,
|
||||
) -> Result<Vec<ApiCapability>, AppError> {
|
||||
if capabilities.is_empty() {
|
||||
return Err(AppError::bad_request("at least one capability is required"));
|
||||
}
|
||||
|
||||
capabilities.sort_by(|a, b| a.as_str().cmp(b.as_str()));
|
||||
capabilities.dedup();
|
||||
Ok(capabilities)
|
||||
}
|
||||
|
||||
pub fn load_capabilities_for_set<C>(
|
||||
conn: &mut C,
|
||||
capability_set_id: Uuid,
|
||||
) -> Result<Vec<ApiCapability>, AppError>
|
||||
where
|
||||
C: Connection<Backend = Pg> + diesel::connection::LoadConnection,
|
||||
{
|
||||
let mut capabilities: Vec<ApiCapability> = csc_dsl::capability_set_capabilities
|
||||
.filter(csc_dsl::capability_set_id.eq(capability_set_id))
|
||||
.select(csc_dsl::capability)
|
||||
.load(conn)
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
capabilities.sort_by(|a, b| a.as_str().cmp(b.as_str()));
|
||||
Ok(capabilities)
|
||||
}
|
||||
|
||||
pub fn ensure_capability_set<C>(
|
||||
conn: &mut C,
|
||||
tenant_id: Uuid,
|
||||
capabilities: &[ApiCapability],
|
||||
) -> Result<CapabilitySet, AppError>
|
||||
where
|
||||
C: Connection<Backend = Pg> + diesel::connection::LoadConnection,
|
||||
{
|
||||
if capabilities.is_empty() {
|
||||
return Err(AppError::bad_request("at least one capability is required"));
|
||||
}
|
||||
|
||||
let slug = compute_slug(capabilities);
|
||||
conn.transaction::<CapabilitySet, AppError, _>(|conn| {
|
||||
if let Some(existing) = cs_dsl::capability_sets
|
||||
.filter(cs_dsl::tenant_id.eq(tenant_id))
|
||||
.filter(cs_dsl::slug.eq(&slug))
|
||||
.first::<CapabilitySet>(conn)
|
||||
.optional()
|
||||
.map_err(AppError::from)?
|
||||
{
|
||||
ensure_capability_membership(conn, &existing, capabilities)?;
|
||||
return Ok(existing);
|
||||
}
|
||||
|
||||
let set = NewCapabilitySet {
|
||||
id: Uuid::new_v4(),
|
||||
tenant_id,
|
||||
slug: slug.clone(),
|
||||
cap_version: 1,
|
||||
is_system: matches!(slug.as_str(), "owner" | "user" | "readonly" | "webdav"),
|
||||
};
|
||||
|
||||
diesel::insert_into(capability_sets::table)
|
||||
.values(&set)
|
||||
.execute(conn)
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
persist_capabilities(conn, set.id, capabilities)?;
|
||||
|
||||
Ok(capability_sets::table
|
||||
.find(set.id)
|
||||
.first::<CapabilitySet>(conn)
|
||||
.map_err(AppError::from)?)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn refresh_capability_set<C>(
|
||||
conn: &mut C,
|
||||
set: &CapabilitySet,
|
||||
capabilities: &[ApiCapability],
|
||||
) -> Result<CapabilitySet, AppError>
|
||||
where
|
||||
C: Connection<Backend = Pg> + diesel::connection::LoadConnection,
|
||||
{
|
||||
conn.transaction::<CapabilitySet, AppError, _>(|conn| {
|
||||
diesel::delete(
|
||||
csc_dsl::capability_set_capabilities.filter(csc_dsl::capability_set_id.eq(set.id)),
|
||||
)
|
||||
.execute(conn)
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
persist_capabilities(conn, set.id, capabilities)?;
|
||||
|
||||
diesel::update(capability_sets::table.find(set.id))
|
||||
.set((
|
||||
cs_dsl::cap_version.eq(set.cap_version + 1),
|
||||
cs_dsl::updated_at.eq(Utc::now().naive_utc()),
|
||||
))
|
||||
.execute(conn)
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
capability_sets::table
|
||||
.find(set.id)
|
||||
.first::<CapabilitySet>(conn)
|
||||
.map_err(AppError::from)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn load_capability_set<C>(conn: &mut C, id: Uuid) -> Result<CapabilitySet, AppError>
|
||||
where
|
||||
C: Connection<Backend = Pg> + diesel::connection::LoadConnection,
|
||||
{
|
||||
capability_sets::table
|
||||
.find(id)
|
||||
.first::<CapabilitySet>(conn)
|
||||
.map_err(AppError::from)
|
||||
}
|
||||
|
||||
pub fn compute_slug(capabilities: &[ApiCapability]) -> String {
|
||||
if capabilities == owner_capabilities() {
|
||||
return "owner".to_string();
|
||||
}
|
||||
|
||||
if capabilities == user_capabilities() {
|
||||
return "user".to_string();
|
||||
}
|
||||
|
||||
if capabilities == readonly_capabilities() {
|
||||
return "readonly".to_string();
|
||||
}
|
||||
|
||||
if capabilities == webdav_capabilities() {
|
||||
return "webdav".to_string();
|
||||
}
|
||||
|
||||
let joined = capabilities
|
||||
.iter()
|
||||
.map(|cap| cap.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
|
||||
let digest = Sha256::digest(joined.as_bytes());
|
||||
let hex = hex::encode(digest);
|
||||
format!("caps-{}", &hex[..12])
|
||||
}
|
||||
|
||||
fn ensure_capability_membership<C>(
|
||||
conn: &mut C,
|
||||
set: &CapabilitySet,
|
||||
desired: &[ApiCapability],
|
||||
) -> Result<(), AppError>
|
||||
where
|
||||
C: Connection<Backend = Pg> + diesel::connection::LoadConnection,
|
||||
{
|
||||
let current = load_capabilities_for_set(conn, set.id)?;
|
||||
if current == desired {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let _ = refresh_capability_set(conn, set, desired)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn persist_capabilities<C>(
|
||||
conn: &mut C,
|
||||
set_id: Uuid,
|
||||
capabilities: &[ApiCapability],
|
||||
) -> Result<(), AppError>
|
||||
where
|
||||
C: Connection<Backend = Pg> + diesel::connection::LoadConnection,
|
||||
{
|
||||
if capabilities.is_empty() {
|
||||
return Err(AppError::bad_request("at least one capability is required"));
|
||||
}
|
||||
|
||||
let records: Vec<NewCapabilitySetCapability> = capabilities
|
||||
.iter()
|
||||
.map(|cap| NewCapabilitySetCapability {
|
||||
capability_set_id: set_id,
|
||||
capability: *cap,
|
||||
})
|
||||
.collect();
|
||||
|
||||
diesel::insert_into(capability_set_capabilities::table)
|
||||
.values(&records)
|
||||
.execute(conn)
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
use anyhow::Result;
|
||||
use chrono::{Duration, Utc};
|
||||
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
|
||||
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)]
|
||||
pub struct JwtService {
|
||||
encoding: EncodingKey,
|
||||
decoding: DecodingKey,
|
||||
issuer: String,
|
||||
audience: String,
|
||||
expiry: Duration,
|
||||
download_audience: String,
|
||||
download_expiry: Duration,
|
||||
selector_audience: String,
|
||||
selector_expiry: Duration,
|
||||
signup_audience: String,
|
||||
signup_expiry: Duration,
|
||||
}
|
||||
|
||||
impl JwtService {
|
||||
pub fn from_config(config: &AppConfig) -> Result<Self> {
|
||||
let access_expiry = Duration::minutes(config.jwt_expiry_minutes);
|
||||
|
||||
Ok(Self {
|
||||
encoding: EncodingKey::from_secret(config.jwt_secret.as_bytes()),
|
||||
decoding: DecodingKey::from_secret(config.jwt_secret.as_bytes()),
|
||||
issuer: config.jwt_issuer.clone(),
|
||||
audience: config.jwt_audience.clone(),
|
||||
expiry: access_expiry,
|
||||
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> {
|
||||
let now = Utc::now();
|
||||
let exp = now + self.expiry;
|
||||
let claims = Claims {
|
||||
sub: context.user_id,
|
||||
tenant_id: context.tenant_id,
|
||||
username: context.username,
|
||||
principal_kind: context.principal_kind,
|
||||
principal_id: context.principal_id,
|
||||
capability_set_id: context.capability_set_id,
|
||||
cap_version: context.cap_version,
|
||||
iss: self.issuer.clone(),
|
||||
aud: self.audience.clone(),
|
||||
iat: now.timestamp() as usize,
|
||||
exp: exp.timestamp() as usize,
|
||||
};
|
||||
|
||||
Ok(encode(&Header::default(), &claims, &self.encoding)?)
|
||||
}
|
||||
|
||||
pub fn verify_token(&self, token: &str) -> Result<Claims> {
|
||||
let mut validation = Validation::default();
|
||||
validation.set_audience(&[self.audience.clone()]);
|
||||
validation.set_issuer(&[self.issuer.clone()]);
|
||||
let data = decode::<Claims>(token, &self.decoding, &validation)?;
|
||||
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)]
|
||||
pub struct Claims {
|
||||
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 iss: String,
|
||||
pub aud: String,
|
||||
pub iat: usize,
|
||||
pub exp: usize,
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
pub mod api_tokens;
|
||||
pub mod capability_guard;
|
||||
pub mod capability_sets;
|
||||
pub mod jwt;
|
||||
pub mod passkeys;
|
||||
pub mod password;
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use axum::{
|
||||
extract::FromRequestParts,
|
||||
http::{request::Parts, StatusCode},
|
||||
};
|
||||
use axum_extra::headers::{authorization::Bearer, Authorization};
|
||||
use axum_extra::TypedHeader;
|
||||
use diesel::{pg::PgConnection, prelude::*};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::{
|
||||
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)]
|
||||
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 user_id: uuid::Uuid,
|
||||
pub username: String,
|
||||
pub tenant_id: uuid::Uuid,
|
||||
pub principal_kind: PrincipalKind,
|
||||
pub principal_id: Uuid,
|
||||
pub capability_set_id: Uuid,
|
||||
pub cap_version: i32,
|
||||
pub capabilities: Vec<ApiCapability>,
|
||||
}
|
||||
|
||||
impl FromRequestParts<AppState> for AuthenticatedUser {
|
||||
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 {
|
||||
if let Some(user) = parts.extensions.get::<AuthenticatedUser>() {
|
||||
return Ok(user.clone());
|
||||
}
|
||||
|
||||
let TypedHeader(Authorization(bearer)) =
|
||||
TypedHeader::<Authorization<Bearer>>::from_request_parts(parts, &state)
|
||||
.await
|
||||
.map_err(|_| AppError::unauthorized())?;
|
||||
|
||||
let claims = state
|
||||
.jwt
|
||||
.verify_token(bearer.token())
|
||||
.map_err(|_| AppError::unauthorized())?;
|
||||
|
||||
let mut tenant_conn = state.db_for_tenant(claims.tenant_id)?;
|
||||
let capability_set = load_capability_set(&mut tenant_conn, claims.capability_set_id)
|
||||
.map_err(|_| AppError::unauthorized())?;
|
||||
|
||||
if capability_set.cap_version != claims.cap_version {
|
||||
return Err(AppError::unauthorized());
|
||||
}
|
||||
|
||||
let capabilities = load_capabilities_for_set(&mut tenant_conn, capability_set.id)
|
||||
.map_err(|_| AppError::unauthorized())?;
|
||||
|
||||
let user = AuthenticatedUser {
|
||||
user_id: claims.sub,
|
||||
username: claims.username,
|
||||
tenant_id: claims.tenant_id,
|
||||
principal_kind: claims.principal_kind,
|
||||
principal_id: claims.principal_id,
|
||||
capability_set_id: claims.capability_set_id,
|
||||
cap_version: claims.cap_version,
|
||||
capabilities,
|
||||
};
|
||||
|
||||
parts.extensions.insert(user.clone());
|
||||
parts
|
||||
.extensions
|
||||
.insert(TenantConnectionHolder::new(tenant_conn));
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
@@ -0,0 +1,592 @@
|
||||
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,
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use argon2::{
|
||||
password_hash::{
|
||||
rand_core::OsRng as PasswordHashOsRng, PasswordHash, PasswordHasher, PasswordVerifier,
|
||||
SaltString,
|
||||
},
|
||||
Argon2,
|
||||
};
|
||||
|
||||
pub fn verify_password(password: &str, password_hash: &str) -> Result<bool> {
|
||||
let parsed_hash = PasswordHash::new(password_hash).map_err(|err| anyhow!(err))?;
|
||||
Ok(Argon2::default()
|
||||
.verify_password(password.as_bytes(), &parsed_hash)
|
||||
.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())
|
||||
}
|
||||
@@ -0,0 +1,888 @@
|
||||
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(())
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
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(())
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::signal;
|
||||
|
||||
use papercrate::{default_handlers, utils::bootstrap::init_component, Worker};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let state = init_component("worker", Some(1)).await?;
|
||||
tracing::info!(component = "worker", "starting worker process");
|
||||
let worker = Worker::new(state, default_handlers(), Duration::from_secs(2));
|
||||
|
||||
tokio::select! {
|
||||
_ = worker.run() => {}
|
||||
_ = signal::ctrl_c() => {
|
||||
tracing::info!("worker received shutdown signal");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
use anyhow::{Context, Result};
|
||||
use url::Url;
|
||||
|
||||
use serde::de::Deserializer;
|
||||
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 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,
|
||||
#[serde(default = "default_server_port")]
|
||||
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,
|
||||
#[serde(default = "default_jwt_issuer")]
|
||||
pub jwt_issuer: String,
|
||||
#[serde(default = "default_jwt_audience")]
|
||||
pub jwt_audience: String,
|
||||
#[serde(default = "default_jwt_expiry_minutes")]
|
||||
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>,
|
||||
#[serde(default)]
|
||||
pub aws_access_key_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub aws_secret_access_key: Option<String>,
|
||||
#[serde(default = "default_aws_region")]
|
||||
pub aws_region: 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 {
|
||||
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> {
|
||||
let config: AppConfig = envy::from_env()
|
||||
.context("failed to parse application configuration from environment")?;
|
||||
Ok(config.normalize())
|
||||
}
|
||||
|
||||
pub fn redacted_database_url(&self) -> String {
|
||||
redact_database_url(&self.database_url)
|
||||
}
|
||||
|
||||
pub fn redacted_migrations_database_url(&self) -> String {
|
||||
redact_database_url(self.migrations_database_url())
|
||||
}
|
||||
|
||||
pub fn migrations_database_url(&self) -> &str {
|
||||
if let Some(ref url) = self.migrations_database_url {
|
||||
url
|
||||
} else {
|
||||
&self.database_url
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
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, "***");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use diesel::pg::PgConnection;
|
||||
use diesel::r2d2::{ConnectionManager, CustomizeConnection, Pool};
|
||||
use diesel::RunQueryDsl;
|
||||
|
||||
pub type PgPool = Pool<ConnectionManager<PgConnection>>;
|
||||
|
||||
pub const DEFAULT_MAX_POOL_SIZE: u32 = 2;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct SchemaCustomizer;
|
||||
|
||||
impl CustomizeConnection<PgConnection, diesel::r2d2::Error> for SchemaCustomizer {
|
||||
fn on_acquire(&self, conn: &mut PgConnection) -> Result<(), diesel::r2d2::Error> {
|
||||
diesel::sql_query(
|
||||
"SELECT set_config('search_path', (
|
||||
SELECT string_agg(schema_name, ', ')
|
||||
FROM (
|
||||
SELECT 'tenant' AS schema_name WHERE EXISTS (
|
||||
SELECT 1 FROM pg_namespace WHERE nspname = 'tenant'
|
||||
)
|
||||
UNION ALL
|
||||
SELECT 'shared' AS schema_name WHERE EXISTS (
|
||||
SELECT 1 FROM pg_namespace WHERE nspname = 'shared'
|
||||
)
|
||||
UNION ALL
|
||||
SELECT 'public' AS schema_name
|
||||
) AS schemas
|
||||
), false)",
|
||||
)
|
||||
.execute(conn)
|
||||
.map(|_| ())
|
||||
.map_err(diesel::r2d2::Error::QueryError)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init_pool(database_url: &str) -> anyhow::Result<PgPool> {
|
||||
init_pool_with_size(database_url, DEFAULT_MAX_POOL_SIZE)
|
||||
}
|
||||
|
||||
pub fn init_pool_with_size(database_url: &str, max_size: u32) -> anyhow::Result<PgPool> {
|
||||
let manager = ConnectionManager::<PgConnection>::new(database_url);
|
||||
let pool_size = max_size.max(1);
|
||||
let pool = Pool::builder()
|
||||
.max_size(pool_size)
|
||||
.connection_timeout(Duration::from_secs(10))
|
||||
.connection_customizer(Box::new(SchemaCustomizer))
|
||||
.build(manager)?;
|
||||
Ok(pool)
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
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")
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
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};
|
||||
@@ -0,0 +1,58 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
pub const UNICODE_COLLATION_NAME: &str = "unicode_ci";
|
||||
pub const UNICODE_COLLATION_LOCALE: &str = "und-u-ks-level2";
|
||||
|
||||
const TITLE_ASC: &str = "title COLLATE \"unicode_ci\" ASC";
|
||||
const TITLE_DESC: &str = "title COLLATE \"unicode_ci\" DESC";
|
||||
const ISSUED_AT_ASC: &str = "issued_at ASC NULLS LAST";
|
||||
const ISSUED_AT_DESC: &str = "issued_at DESC NULLS LAST";
|
||||
const CREATED_AT_ASC: &str = "created_at ASC";
|
||||
const CREATED_AT_DESC: &str = "created_at DESC";
|
||||
const UPDATED_AT_ASC: &str = "updated_at ASC";
|
||||
const UPDATED_AT_DESC: &str = "updated_at DESC";
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DocumentSortField {
|
||||
Title,
|
||||
IssuedAt,
|
||||
CreatedAt,
|
||||
UpdatedAt,
|
||||
}
|
||||
|
||||
impl Default for DocumentSortField {
|
||||
fn default() -> Self {
|
||||
DocumentSortField::Title
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SortDirection {
|
||||
Asc,
|
||||
Desc,
|
||||
}
|
||||
|
||||
impl Default for SortDirection {
|
||||
fn default() -> Self {
|
||||
SortDirection::Asc
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ordering_clauses(
|
||||
field: DocumentSortField,
|
||||
direction: SortDirection,
|
||||
) -> (&'static str, Option<&'static str>) {
|
||||
match (field, direction) {
|
||||
(DocumentSortField::Title, SortDirection::Asc) => (TITLE_ASC, None),
|
||||
(DocumentSortField::Title, SortDirection::Desc) => (TITLE_DESC, None),
|
||||
(DocumentSortField::IssuedAt, SortDirection::Asc) => (ISSUED_AT_ASC, Some(TITLE_ASC)),
|
||||
(DocumentSortField::IssuedAt, SortDirection::Desc) => (ISSUED_AT_DESC, Some(TITLE_ASC)),
|
||||
(DocumentSortField::CreatedAt, SortDirection::Asc) => (CREATED_AT_ASC, Some(TITLE_ASC)),
|
||||
(DocumentSortField::CreatedAt, SortDirection::Desc) => (CREATED_AT_DESC, Some(TITLE_ASC)),
|
||||
(DocumentSortField::UpdatedAt, SortDirection::Asc) => (UPDATED_AT_ASC, Some(TITLE_ASC)),
|
||||
(DocumentSortField::UpdatedAt, SortDirection::Desc) => (UPDATED_AT_DESC, Some(TITLE_ASC)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::documents::correspondents::{
|
||||
load_correspondents_for_documents, DocumentCorrespondentResponse,
|
||||
};
|
||||
use crate::documents::tags::load_tags_for_documents;
|
||||
use crate::error::AppResult;
|
||||
use crate::models::Tag;
|
||||
use crate::state::PgPooledConnection;
|
||||
|
||||
/// Loads tags and correspondents for the provided documents in a single pass.
|
||||
pub fn load_tags_and_correspondents(
|
||||
conn: &mut PgPooledConnection,
|
||||
document_ids: &[Uuid],
|
||||
) -> AppResult<HashMap<Uuid, (Vec<Tag>, Vec<DocumentCorrespondentResponse>)>> {
|
||||
if document_ids.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let tags_map = load_tags_for_documents(conn, document_ids)?;
|
||||
let mut correspondents_map = load_correspondents_for_documents(conn, document_ids)?;
|
||||
|
||||
let mut result = HashMap::with_capacity(document_ids.len());
|
||||
for id in document_ids {
|
||||
let tags = tags_map.get(id).cloned().unwrap_or_default();
|
||||
let correspondents = correspondents_map.remove(id).unwrap_or_default();
|
||||
result.insert(*id, (tags, correspondents));
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
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(())
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
use axum::{
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use std::fmt::{self, Display};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
pub type AppResult<T> = Result<T, AppError>;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct AppError {
|
||||
status: StatusCode,
|
||||
message: String,
|
||||
code: Option<String>,
|
||||
details: Option<Value>,
|
||||
}
|
||||
|
||||
impl AppError {
|
||||
pub fn new(status: StatusCode, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status,
|
||||
message: message.into(),
|
||||
code: None,
|
||||
details: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bad_request(message: impl Into<String>) -> Self {
|
||||
Self::new(StatusCode::BAD_REQUEST, message)
|
||||
}
|
||||
|
||||
pub fn conflict(message: impl Into<String>) -> Self {
|
||||
Self::new(StatusCode::CONFLICT, message)
|
||||
}
|
||||
|
||||
pub fn unauthorized() -> Self {
|
||||
Self::new(StatusCode::UNAUTHORIZED, "unauthorized")
|
||||
}
|
||||
|
||||
pub fn forbidden(message: impl Into<String>) -> Self {
|
||||
Self::new(StatusCode::FORBIDDEN, message)
|
||||
}
|
||||
|
||||
pub fn not_found() -> Self {
|
||||
Self::new(StatusCode::NOT_FOUND, "resource not found")
|
||||
}
|
||||
|
||||
pub fn internal<E: Display>(error: E) -> Self {
|
||||
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 {
|
||||
fn into_response(self) -> Response {
|
||||
let status = self.status;
|
||||
let body = Json(ApiErrorResponse {
|
||||
error: self.message,
|
||||
code: self.code,
|
||||
details: self.details,
|
||||
});
|
||||
(status, body).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct ApiErrorResponse {
|
||||
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 {
|
||||
fn from(value: diesel::result::Error) -> Self {
|
||||
match value {
|
||||
diesel::result::Error::NotFound => AppError::not_found(),
|
||||
other => {
|
||||
let message = format!("database operation failed: {other}");
|
||||
tracing::error!(error = ?other, message);
|
||||
AppError::internal(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<jsonwebtoken::errors::Error> for AppError {
|
||||
fn from(value: jsonwebtoken::errors::Error) -> Self {
|
||||
AppError::internal(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<anyhow::Error> for AppError {
|
||||
fn from(value: anyhow::Error) -> Self {
|
||||
AppError::internal(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for AppError {
|
||||
fn from(value: std::io::Error) -> Self {
|
||||
AppError::internal(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for AppError {
|
||||
fn from(value: serde_json::Error) -> Self {
|
||||
AppError::internal(value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod responders;
|
||||
@@ -0,0 +1,157 @@
|
||||
use axum::{
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::error::{AppError, AppResult};
|
||||
|
||||
/// Helper trait to convert error-centric results into the application's error type.
|
||||
pub trait IntoAppResult<T> {
|
||||
fn into_app_result(self) -> AppResult<T>;
|
||||
}
|
||||
|
||||
impl<T, E> IntoAppResult<T> for Result<T, E>
|
||||
where
|
||||
AppError: From<E>,
|
||||
{
|
||||
fn into_app_result(self) -> AppResult<T> {
|
||||
self.map_err(AppError::from)
|
||||
}
|
||||
}
|
||||
|
||||
/// Extension helpers for optional values to map them into `AppResult`.
|
||||
pub trait OptionAppResultExt<T> {
|
||||
fn or_not_found(self) -> AppResult<T>;
|
||||
fn or_bad_request(self, message: impl Into<String>) -> AppResult<T>;
|
||||
}
|
||||
|
||||
impl<T> OptionAppResultExt<T> for Option<T> {
|
||||
fn or_not_found(self) -> AppResult<T> {
|
||||
self.ok_or_else(AppError::not_found)
|
||||
}
|
||||
|
||||
fn or_bad_request(self, message: impl Into<String>) -> AppResult<T> {
|
||||
self.ok_or_else(|| AppError::bad_request(message))
|
||||
}
|
||||
}
|
||||
|
||||
/// Provides helpers for statements returning number of affected rows.
|
||||
pub trait RowsAffectedExt: Sized {
|
||||
fn or_error(self, error: AppError) -> AppResult<usize>;
|
||||
fn or_not_found(self) -> AppResult<usize> {
|
||||
self.or_error(AppError::not_found())
|
||||
}
|
||||
}
|
||||
|
||||
impl RowsAffectedExt for usize {
|
||||
fn or_error(self, error: AppError) -> AppResult<usize> {
|
||||
if self == 0 {
|
||||
Err(error)
|
||||
} else {
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrapper providing a consistent JSON response with a status code.
|
||||
pub struct JsonResponse<T> {
|
||||
status: StatusCode,
|
||||
payload: T,
|
||||
}
|
||||
|
||||
impl<T> JsonResponse<T> {
|
||||
pub fn new(status: StatusCode, payload: T) -> Self {
|
||||
Self { status, payload }
|
||||
}
|
||||
|
||||
pub fn ok(payload: T) -> Self {
|
||||
Self::new(StatusCode::OK, payload)
|
||||
}
|
||||
|
||||
pub fn created(payload: T) -> Self {
|
||||
Self::new(StatusCode::CREATED, payload)
|
||||
}
|
||||
|
||||
pub fn accepted(payload: T) -> Self {
|
||||
Self::new(StatusCode::ACCEPTED, payload)
|
||||
}
|
||||
|
||||
pub fn into_inner(self) -> T {
|
||||
self.payload
|
||||
}
|
||||
|
||||
pub fn as_inner(&self) -> &T {
|
||||
&self.payload
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> From<T> for JsonResponse<T> {
|
||||
fn from(value: T) -> Self {
|
||||
Self::ok(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> IntoResponse for JsonResponse<T>
|
||||
where
|
||||
T: Serialize,
|
||||
{
|
||||
fn into_response(self) -> Response {
|
||||
(self.status, Json(self.payload)).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper for returning empty responses with a status code.
|
||||
pub fn empty(status: StatusCode) -> AppResult<StatusCode> {
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
/// Helper for returning `204 No Content`.
|
||||
pub fn no_content() -> AppResult<StatusCode> {
|
||||
empty(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
/// Helper for returning JSON payloads with `200 OK`.
|
||||
pub fn ok_json<T>(value: T) -> AppResult<JsonResponse<T>>
|
||||
where
|
||||
T: Serialize,
|
||||
{
|
||||
Ok(JsonResponse::ok(value))
|
||||
}
|
||||
|
||||
/// Helper for returning JSON payloads with `201 Created`.
|
||||
pub fn created_json<T>(value: T) -> AppResult<JsonResponse<T>>
|
||||
where
|
||||
T: Serialize,
|
||||
{
|
||||
Ok(JsonResponse::created(value))
|
||||
}
|
||||
|
||||
/// Helper for returning JSON payloads with `202 Accepted`.
|
||||
pub fn accepted_json<T>(value: T) -> AppResult<JsonResponse<T>>
|
||||
where
|
||||
T: Serialize,
|
||||
{
|
||||
Ok(JsonResponse::accepted(value))
|
||||
}
|
||||
|
||||
/// Standard wrapper for paginated responses.
|
||||
#[derive(Serialize)]
|
||||
pub struct PaginatedResponse<T, M>
|
||||
where
|
||||
T: Serialize,
|
||||
M: Serialize,
|
||||
{
|
||||
pub data: T,
|
||||
pub meta: M,
|
||||
}
|
||||
|
||||
pub fn paginated_json<T, M>(data: T, meta: M) -> AppResult<JsonResponse<PaginatedResponse<T, M>>>
|
||||
where
|
||||
T: Serialize,
|
||||
M: Serialize,
|
||||
{
|
||||
let payload = PaginatedResponse { data, meta };
|
||||
Ok(JsonResponse::ok(payload))
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use chrono::{DateTime, Datelike, NaiveDate, TimeZone, Utc};
|
||||
use chrono_tz::Tz;
|
||||
use once_cell::sync::Lazy;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::config::AppConfig;
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum DateOrder {
|
||||
Dmy,
|
||||
Mdy,
|
||||
Ymd,
|
||||
}
|
||||
|
||||
impl DateOrder {
|
||||
pub fn parse(value: &str) -> Self {
|
||||
Self::try_parse(value).unwrap_or(DateOrder::Dmy)
|
||||
}
|
||||
|
||||
pub fn try_parse(value: &str) -> Option<Self> {
|
||||
match value.trim().to_ascii_uppercase().as_str() {
|
||||
"YMD" => Some(DateOrder::Ymd),
|
||||
"MDY" => Some(DateOrder::Mdy),
|
||||
"DMY" => Some(DateOrder::Dmy),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static MIN_ISSUED_AT_DATE: Lazy<NaiveDate> =
|
||||
Lazy::new(|| NaiveDate::from_ymd_opt(1901, 1, 1).expect("valid minimum issued_at date"));
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct IssuedAtSettings {
|
||||
pub timezone: Tz,
|
||||
pub date_order: DateOrder,
|
||||
pub filename_date_order: Option<DateOrder>,
|
||||
pub locales: HashSet<String>,
|
||||
pub ignore_dates: HashSet<NaiveDate>,
|
||||
pub min_date: NaiveDate,
|
||||
}
|
||||
|
||||
impl IssuedAtSettings {
|
||||
pub fn from_config(config: &AppConfig) -> Self {
|
||||
let timezone = config.service_timezone.parse::<Tz>().unwrap_or_else(|_| {
|
||||
warn!(
|
||||
timezone = %config.service_timezone,
|
||||
"invalid service timezone configured; falling back to UTC"
|
||||
);
|
||||
chrono_tz::UTC
|
||||
});
|
||||
|
||||
let date_order = DateOrder::parse(&config.issued_at_date_order);
|
||||
let filename_date_order =
|
||||
config
|
||||
.issued_at_filename_date_order
|
||||
.as_deref()
|
||||
.and_then(|value| {
|
||||
DateOrder::try_parse(value).or_else(|| {
|
||||
warn!(value, "invalid issued_at filename date order; ignoring");
|
||||
None
|
||||
})
|
||||
});
|
||||
|
||||
let locales = config
|
||||
.issued_at_date_parser_locales
|
||||
.iter()
|
||||
.filter_map(|value| {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_ascii_lowercase())
|
||||
}
|
||||
})
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
// Ignore dates are evaluated after normalizing candidate timestamps to
|
||||
// the configured service timezone, so administrators should provide
|
||||
// local calendar dates rather than UTC midnights.
|
||||
let ignore_dates = config
|
||||
.issued_at_ignore_dates
|
||||
.iter()
|
||||
.filter_map(|value| {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
match NaiveDate::parse_from_str(trimmed, "%Y-%m-%d") {
|
||||
Ok(date) => Some(date),
|
||||
Err(err) => {
|
||||
warn!(value = trimmed, error = %err, "invalid issued_at ignore date");
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Self {
|
||||
timezone,
|
||||
date_order,
|
||||
filename_date_order,
|
||||
locales,
|
||||
ignore_dates,
|
||||
min_date: *MIN_ISSUED_AT_DATE,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the immutable (lowercase) locale allowlist supplied via config.
|
||||
pub fn locales(&self) -> &HashSet<String> {
|
||||
&self.locales
|
||||
}
|
||||
|
||||
/// Returns the immutable set of local-calendar dates that should be ignored.
|
||||
pub fn ignore_dates(&self) -> &HashSet<NaiveDate> {
|
||||
&self.ignore_dates
|
||||
}
|
||||
|
||||
/// Returns the configured service timezone (copy type).
|
||||
pub fn timezone(&self) -> Tz {
|
||||
self.timezone
|
||||
}
|
||||
|
||||
pub fn date_order(&self) -> DateOrder {
|
||||
self.date_order
|
||||
}
|
||||
|
||||
pub fn filename_date_order(&self) -> Option<DateOrder> {
|
||||
self.filename_date_order
|
||||
}
|
||||
|
||||
pub fn normalize_naive(
|
||||
&self,
|
||||
date: NaiveDate,
|
||||
now_utc: chrono::DateTime<Utc>,
|
||||
) -> Option<DateTime<Utc>> {
|
||||
if !self.is_valid_with_now(date, now_utc) {
|
||||
return None;
|
||||
}
|
||||
self.timezone
|
||||
.with_ymd_and_hms(date.year(), date.month(), date.day(), 0, 0, 0)
|
||||
.earliest()
|
||||
.map(|dt| dt.with_timezone(&Utc))
|
||||
}
|
||||
|
||||
pub fn normalize_datetime(
|
||||
&self,
|
||||
dt: chrono::DateTime<Utc>,
|
||||
now_utc: chrono::DateTime<Utc>,
|
||||
) -> Option<DateTime<Utc>> {
|
||||
let local_date = dt.with_timezone(&self.timezone).date_naive();
|
||||
self.normalize_naive(local_date, now_utc)
|
||||
}
|
||||
|
||||
fn is_valid_with_now(&self, date: NaiveDate, now_utc: chrono::DateTime<Utc>) -> bool {
|
||||
if date < self.min_date {
|
||||
return false;
|
||||
}
|
||||
let now_local = now_utc.with_timezone(&self.timezone).date_naive();
|
||||
if date > now_local {
|
||||
return false;
|
||||
}
|
||||
!self.ignore_dates.contains(&date)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::{Duration as ChronoDuration, NaiveDateTime, Utc};
|
||||
use diesel::pg::PgConnection;
|
||||
use diesel::prelude::*;
|
||||
use serde_json::Value;
|
||||
use thiserror::Error;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::models::{Job, NewJob};
|
||||
use crate::schema::jobs;
|
||||
|
||||
pub const STATUS_QUEUED: &str = "queued";
|
||||
pub const STATUS_PROCESSING: &str = "processing";
|
||||
pub const STATUS_SUCCEEDED: &str = "succeeded";
|
||||
pub const STATUS_FAILED: &str = "failed";
|
||||
|
||||
pub const JOB_ANALYZE_DOCUMENT: &str = "analyze-document";
|
||||
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)]
|
||||
pub enum JobQueueError {
|
||||
#[error("database error: {0}")]
|
||||
Database(#[from] diesel::result::Error),
|
||||
}
|
||||
|
||||
pub type JobQueueResult<T> = Result<T, JobQueueError>;
|
||||
|
||||
pub fn enqueue_job(
|
||||
conn: &mut PgConnection,
|
||||
tenant_id: Uuid,
|
||||
job_type: &str,
|
||||
payload: Value,
|
||||
run_after: Option<NaiveDateTime>,
|
||||
) -> JobQueueResult<Job> {
|
||||
let new_job = NewJob {
|
||||
id: Uuid::new_v4(),
|
||||
job_type: job_type.to_string(),
|
||||
payload,
|
||||
status: STATUS_QUEUED.to_string(),
|
||||
run_after: run_after.unwrap_or_else(|| Utc::now().naive_utc()),
|
||||
tenant_id,
|
||||
};
|
||||
|
||||
diesel::insert_into(jobs::table)
|
||||
.values(&new_job)
|
||||
.execute(conn)?;
|
||||
|
||||
let job = jobs::table.find(new_job.id).first(conn)?;
|
||||
Ok(job)
|
||||
}
|
||||
|
||||
pub fn reserve_job(conn: &mut PgConnection, job_types: &[&str]) -> JobQueueResult<Option<Job>> {
|
||||
let now = Utc::now().naive_utc();
|
||||
|
||||
conn.transaction(|conn| {
|
||||
let job_opt = jobs::table
|
||||
.filter(jobs::status.eq(STATUS_QUEUED))
|
||||
.filter(jobs::run_after.le(now))
|
||||
.filter(jobs::job_type.eq_any(job_types))
|
||||
.order(jobs::run_after.asc())
|
||||
.for_update()
|
||||
.skip_locked()
|
||||
.first::<Job>(conn)
|
||||
.optional()?;
|
||||
|
||||
if let Some(job) = job_opt {
|
||||
diesel::update(jobs::table.find(job.id))
|
||||
.set((
|
||||
jobs::status.eq(STATUS_PROCESSING),
|
||||
jobs::attempts.eq(job.attempts + 1),
|
||||
jobs::updated_at.eq(now),
|
||||
))
|
||||
.execute(conn)?;
|
||||
|
||||
let refreshed = jobs::table.find(job.id).first(conn)?;
|
||||
Ok::<Option<Job>, diesel::result::Error>(Some(refreshed))
|
||||
} else {
|
||||
Ok::<Option<Job>, diesel::result::Error>(None)
|
||||
}
|
||||
})
|
||||
.map_err(JobQueueError::from)
|
||||
}
|
||||
|
||||
pub fn mark_job_succeeded(conn: &mut PgConnection, job_id: Uuid) -> JobQueueResult<()> {
|
||||
diesel::update(jobs::table.find(job_id))
|
||||
.set((
|
||||
jobs::status.eq(STATUS_SUCCEEDED),
|
||||
jobs::last_error.eq::<Option<String>>(None),
|
||||
jobs::updated_at.eq(Utc::now().naive_utc()),
|
||||
))
|
||||
.execute(conn)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn retry_job_after(
|
||||
conn: &mut PgConnection,
|
||||
job_id: Uuid,
|
||||
delay: Duration,
|
||||
error_message: &str,
|
||||
) -> JobQueueResult<()> {
|
||||
let next_run = Utc::now()
|
||||
+ ChronoDuration::from_std(delay).unwrap_or_else(|_| ChronoDuration::seconds(30));
|
||||
|
||||
diesel::update(jobs::table.find(job_id))
|
||||
.set((
|
||||
jobs::status.eq(STATUS_QUEUED),
|
||||
jobs::run_after.eq(next_run.naive_utc()),
|
||||
jobs::last_error.eq(Some(error_message.to_string())),
|
||||
jobs::updated_at.eq(Utc::now().naive_utc()),
|
||||
))
|
||||
.execute(conn)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn mark_job_failed(
|
||||
conn: &mut PgConnection,
|
||||
job_id: Uuid,
|
||||
error_message: &str,
|
||||
) -> JobQueueResult<()> {
|
||||
diesel::update(jobs::table.find(job_id))
|
||||
.set((
|
||||
jobs::status.eq(STATUS_FAILED),
|
||||
jobs::last_error.eq(Some(error_message.to_string())),
|
||||
jobs::updated_at.eq(Utc::now().naive_utc()),
|
||||
))
|
||||
.execute(conn)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
pub mod auth;
|
||||
pub mod config;
|
||||
pub mod db;
|
||||
pub mod documents;
|
||||
pub mod error;
|
||||
pub mod http;
|
||||
pub mod issued_at;
|
||||
pub mod jobs;
|
||||
pub mod models;
|
||||
pub mod openapi;
|
||||
pub mod routes;
|
||||
pub mod s3;
|
||||
pub mod schema;
|
||||
pub mod services;
|
||||
pub mod state;
|
||||
pub mod storage;
|
||||
pub mod tenants;
|
||||
pub mod utils;
|
||||
pub mod workers;
|
||||
pub use workers::{default_handlers, Worker};
|
||||
pub mod migrations;
|
||||
pub mod test_support;
|
||||
@@ -0,0 +1,28 @@
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use tokio::net::TcpListener;
|
||||
use tower::make::Shared;
|
||||
|
||||
use papercrate::{routes, utils::bootstrap::init_component};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let state = init_component("api", None).await?;
|
||||
let server_host = state.config.server_host.clone();
|
||||
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 addr: SocketAddr = format!("{}:{}", server_host, server_port).parse()?;
|
||||
let listener = TcpListener::bind(addr).await?;
|
||||
tracing::info!("listening on {}", addr);
|
||||
|
||||
axum::serve(listener, Shared::new(router)).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
use diesel_migrations::{embed_migrations, EmbeddedMigrations};
|
||||
|
||||
pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
|
||||
@@ -0,0 +1,775 @@
|
||||
use chrono::NaiveDateTime;
|
||||
use diesel::deserialize::FromSql;
|
||||
use diesel::pg::{Pg, PgValue};
|
||||
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 utoipa::ToSchema;
|
||||
|
||||
use crate::schema::sql_types::{
|
||||
ApiCapability as ApiCapabilitySql, MagicTokenKind as MagicTokenKindSql,
|
||||
TenantStatus as TenantStatusSql,
|
||||
};
|
||||
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)]
|
||||
#[diesel(table_name = users)]
|
||||
pub struct User {
|
||||
pub id: Uuid,
|
||||
pub username: String,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Insertable)]
|
||||
#[diesel(table_name = users)]
|
||||
pub struct NewUser {
|
||||
pub id: Uuid,
|
||||
pub username: 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)]
|
||||
#[diesel(table_name = folders)]
|
||||
pub struct Folder {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub parent_id: Option<Uuid>,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Insertable)]
|
||||
#[diesel(table_name = folders)]
|
||||
pub struct NewFolder {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub parent_id: Option<Uuid>,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
||||
#[diesel(table_name = documents)]
|
||||
#[diesel(belongs_to(Folder, foreign_key = folder_id))]
|
||||
pub struct Document {
|
||||
pub id: Uuid,
|
||||
pub filename: String,
|
||||
pub original_name: String,
|
||||
pub mime_type: Option<String>,
|
||||
pub folder_id: Option<Uuid>,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
pub deleted_at: Option<NaiveDateTime>,
|
||||
pub metadata: serde_json::Value,
|
||||
pub issued_at: Option<NaiveDateTime>,
|
||||
pub title: String,
|
||||
pub current_version_id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Insertable)]
|
||||
#[diesel(table_name = documents)]
|
||||
pub struct NewDocument {
|
||||
pub id: Uuid,
|
||||
pub filename: String,
|
||||
pub original_name: String,
|
||||
pub mime_type: Option<String>,
|
||||
pub folder_id: Option<Uuid>,
|
||||
pub current_version_id: Uuid,
|
||||
pub metadata: serde_json::Value,
|
||||
pub issued_at: Option<NaiveDateTime>,
|
||||
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)]
|
||||
#[diesel(table_name = document_versions)]
|
||||
#[diesel(belongs_to(Document))]
|
||||
pub struct DocumentVersion {
|
||||
pub id: Uuid,
|
||||
pub document_id: Uuid,
|
||||
pub version_number: i32,
|
||||
pub s3_key: String,
|
||||
pub size_bytes: i64,
|
||||
pub checksum: String,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub metadata: serde_json::Value,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Insertable)]
|
||||
#[diesel(table_name = document_versions)]
|
||||
pub struct NewDocumentVersion {
|
||||
pub id: Uuid,
|
||||
pub document_id: Uuid,
|
||||
pub version_number: i32,
|
||||
pub s3_key: String,
|
||||
pub size_bytes: i64,
|
||||
pub checksum: String,
|
||||
pub metadata: serde_json::Value,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
||||
#[diesel(table_name = document_assets)]
|
||||
#[diesel(belongs_to(DocumentVersion, foreign_key = document_version_id))]
|
||||
pub struct DocumentAsset {
|
||||
pub id: Uuid,
|
||||
pub document_version_id: Uuid,
|
||||
pub asset_type: String,
|
||||
pub mime_type: String,
|
||||
pub metadata: serde_json::Value,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub s3_key: String,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Insertable)]
|
||||
#[diesel(table_name = document_assets)]
|
||||
pub struct NewDocumentAsset {
|
||||
pub id: Uuid,
|
||||
pub document_version_id: Uuid,
|
||||
pub asset_type: String,
|
||||
pub mime_type: String,
|
||||
pub metadata: serde_json::Value,
|
||||
pub s3_key: String,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Queryable, Identifiable)]
|
||||
#[diesel(table_name = jobs)]
|
||||
pub struct Job {
|
||||
pub id: Uuid,
|
||||
pub job_type: String,
|
||||
pub payload: serde_json::Value,
|
||||
pub status: String,
|
||||
pub attempts: i32,
|
||||
pub run_after: NaiveDateTime,
|
||||
pub last_error: Option<String>,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
pub tenant_id: Option<Uuid>,
|
||||
pub result: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Insertable)]
|
||||
#[diesel(table_name = jobs)]
|
||||
pub struct NewJob {
|
||||
pub id: Uuid,
|
||||
pub job_type: String,
|
||||
pub payload: serde_json::Value,
|
||||
pub status: String,
|
||||
pub run_after: NaiveDateTime,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Queryable, Identifiable)]
|
||||
#[diesel(table_name = tags)]
|
||||
pub struct Tag {
|
||||
pub id: Uuid,
|
||||
pub label: String,
|
||||
pub color: Option<String>,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Insertable)]
|
||||
#[diesel(table_name = tags)]
|
||||
pub struct NewTag {
|
||||
pub id: Uuid,
|
||||
pub label: String,
|
||||
pub color: Option<String>,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Queryable, Associations)]
|
||||
#[diesel(table_name = document_tags)]
|
||||
#[diesel(belongs_to(Document))]
|
||||
#[diesel(belongs_to(Tag))]
|
||||
#[diesel(primary_key(document_id, tag_id))]
|
||||
pub struct DocumentTag {
|
||||
pub document_id: Uuid,
|
||||
pub tag_id: Uuid,
|
||||
pub assigned_at: NaiveDateTime,
|
||||
pub assigned_by: Option<Uuid>,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Insertable)]
|
||||
#[diesel(table_name = document_tags)]
|
||||
pub struct NewDocumentTag {
|
||||
pub document_id: Uuid,
|
||||
pub tag_id: 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,
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::Response,
|
||||
Json,
|
||||
};
|
||||
use axum_extra::{
|
||||
headers::{authorization::Bearer, Authorization, Cookie},
|
||||
typed_header::TypedHeader,
|
||||
};
|
||||
use utoipa::OpenApi;
|
||||
|
||||
use crate::{
|
||||
auth::{
|
||||
passkeys::{
|
||||
AuthenticationChallengeResponse, PasskeyLoginFinishPayload, PasskeyLoginStartPayload,
|
||||
PasskeyRegistrationFinishPayload, PasskeySummary, RegistrationChallengeResponse,
|
||||
},
|
||||
AuthenticatedUser, TenantScopedConn,
|
||||
},
|
||||
error::{AppError, AppResult},
|
||||
http::responders::JsonResponse,
|
||||
services::auth::{
|
||||
ApiTokenExchangeRequest, AuthService, LoginRequest, LoginResponse, LoginResponseVariants,
|
||||
SignupFinishRequest, SignupStartRequest, SignupStartResponse, TenantListResponse,
|
||||
TenantSelectionRequest, TenantSelectionResponse, TenantSnippet, SESSION_COOKIE_NAME,
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
paths(
|
||||
login,
|
||||
api_token_exchange,
|
||||
signup_start,
|
||||
signup_finish,
|
||||
refresh,
|
||||
logout,
|
||||
me,
|
||||
select_tenant,
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<LoginRequest>,
|
||||
) -> AppResult<Response> {
|
||||
AuthService::new(&state).login(payload)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/auth/exchange-api-token",
|
||||
request_body = ApiTokenExchangeRequest,
|
||||
responses((status = 200, description = "Access token issued", body = LoginResponse)),
|
||||
tag = "Auth"
|
||||
)]
|
||||
pub async fn api_token_exchange(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<ApiTokenExchangeRequest>,
|
||||
) -> AppResult<JsonResponse<LoginResponse>> {
|
||||
AuthService::new(&state).exchange_api_token(payload)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/auth/signup/start",
|
||||
request_body = SignupStartRequest,
|
||||
responses(
|
||||
(status = 200, description = "Signup challenge created", body = SignupStartResponse),
|
||||
(status = 400, description = "Invalid signup request"),
|
||||
(status = 409, description = "Username already exists")
|
||||
),
|
||||
tag = "Auth"
|
||||
)]
|
||||
pub async fn signup_start(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<SignupStartRequest>,
|
||||
) -> AppResult<JsonResponse<SignupStartResponse>> {
|
||||
AuthService::new(&state).signup_start(payload)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/auth/signup/finish",
|
||||
request_body = SignupFinishRequest,
|
||||
responses(
|
||||
(status = 200, description = "Signup completed", body = LoginResponseVariants),
|
||||
(status = 400, description = "Invalid signup completion"),
|
||||
(status = 409, description = "Username already exists")
|
||||
),
|
||||
tag = "Auth"
|
||||
)]
|
||||
pub async fn signup_finish(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<SignupFinishRequest>,
|
||||
) -> AppResult<Response> {
|
||||
AuthService::new(&state).signup_finish(payload)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/auth/refresh",
|
||||
responses(
|
||||
(status = 200, description = "Refreshed access token", body = LoginResponse),
|
||||
(status = 401, description = "Missing or invalid refresh token")
|
||||
),
|
||||
tag = "Auth"
|
||||
)]
|
||||
pub async fn refresh(
|
||||
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> {
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
use axum::{extract::Path, http::StatusCode, Json};
|
||||
use utoipa::OpenApi;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
auth::TenantScopedConn,
|
||||
error::AppResult,
|
||||
http::responders::JsonResponse,
|
||||
services::capability_sets::{
|
||||
CapabilitySetResponse, CapabilitySetService, CreateCapabilitySetRequest,
|
||||
UpdateCapabilitySetRequest,
|
||||
},
|
||||
};
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/capability-sets",
|
||||
responses((status = 200, body = [CapabilitySetResponse])),
|
||||
tag = "Capability Sets"
|
||||
)]
|
||||
pub async fn list_capability_sets(
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
) -> AppResult<JsonResponse<Vec<CapabilitySetResponse>>> {
|
||||
CapabilitySetService::new().list(&mut conn, tenant_id)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/capabilities",
|
||||
responses((status = 200, body = [crate::models::ApiCapability])),
|
||||
tag = "Capability Sets"
|
||||
)]
|
||||
pub async fn list_capabilities(
|
||||
TenantScopedConn { .. }: TenantScopedConn,
|
||||
) -> AppResult<JsonResponse<Vec<crate::models::ApiCapability>>> {
|
||||
CapabilitySetService::new().list_capabilities()
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/capability-sets/{id}",
|
||||
params(("id" = Uuid, Path, description = "Capability set ID")),
|
||||
responses((status = 200, body = CapabilitySetResponse), (status = 404, description = "Not found")),
|
||||
tag = "Capability Sets"
|
||||
)]
|
||||
pub async fn get_capability_set(
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> AppResult<JsonResponse<CapabilitySetResponse>> {
|
||||
CapabilitySetService::new().get(&mut conn, tenant_id, id)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/capability-sets",
|
||||
request_body = CreateCapabilitySetRequest,
|
||||
responses((status = 201, body = CapabilitySetResponse)),
|
||||
tag = "Capability Sets"
|
||||
)]
|
||||
pub async fn create_capability_set(
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
Json(payload): Json<CreateCapabilitySetRequest>,
|
||||
) -> AppResult<JsonResponse<CapabilitySetResponse>> {
|
||||
CapabilitySetService::new().create(&mut conn, tenant_id, payload)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
patch,
|
||||
path = "/api/capability-sets/{id}",
|
||||
params(("id" = Uuid, Path, description = "Capability set ID")),
|
||||
request_body = UpdateCapabilitySetRequest,
|
||||
responses((status = 200, body = CapabilitySetResponse)),
|
||||
tag = "Capability Sets"
|
||||
)]
|
||||
pub async fn update_capability_set(
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(payload): Json<UpdateCapabilitySetRequest>,
|
||||
) -> AppResult<JsonResponse<CapabilitySetResponse>> {
|
||||
CapabilitySetService::new().update(&mut conn, tenant_id, id, payload)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/capability-sets/{id}",
|
||||
params(("id" = Uuid, Path, description = "Capability set ID")),
|
||||
responses((status = 204), (status = 409, description = "Set in use")),
|
||||
tag = "Capability Sets"
|
||||
)]
|
||||
pub async fn delete_capability_set(
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> AppResult<StatusCode> {
|
||||
CapabilitySetService::new().delete(&mut conn, tenant_id, id)
|
||||
}
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
paths(
|
||||
crate::routes::capability_sets::list_capability_sets,
|
||||
crate::routes::capability_sets::list_capabilities,
|
||||
crate::routes::capability_sets::get_capability_set,
|
||||
crate::routes::capability_sets::create_capability_set,
|
||||
crate::routes::capability_sets::update_capability_set,
|
||||
crate::routes::capability_sets::delete_capability_set,
|
||||
),
|
||||
components(schemas(
|
||||
crate::models::ApiCapability,
|
||||
crate::services::capability_sets::CapabilitySetResponse,
|
||||
crate::services::capability_sets::CreateCapabilitySetRequest,
|
||||
crate::services::capability_sets::UpdateCapabilitySetRequest,
|
||||
))
|
||||
)]
|
||||
pub struct CapabilitySetsApiDoc;
|
||||
@@ -0,0 +1,312 @@
|
||||
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;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,248 @@
|
||||
use axum::{
|
||||
extract::{Json, Path, Query, State},
|
||||
http::StatusCode,
|
||||
};
|
||||
use utoipa::OpenApi;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
auth::TenantScopedConn,
|
||||
error::{AppError, AppResult},
|
||||
http::responders::{created_json, no_content, ok_json, JsonResponse},
|
||||
services::folders::{
|
||||
CreateFolderRequest, EnsureFolderPathRequest, FolderContentsData, FolderContentsQuery,
|
||||
FolderInfo, FolderService, FolderTreeNode, UpdateFolderRequest,
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
use crate::services::documents::DocumentResponse;
|
||||
|
||||
#[derive(utoipa::ToSchema, serde::Serialize)]
|
||||
pub struct FolderResponse {
|
||||
pub folder: FolderInfo,
|
||||
}
|
||||
|
||||
#[derive(utoipa::ToSchema, serde::Serialize)]
|
||||
pub struct FolderContentsResponse {
|
||||
#[schema(nullable)]
|
||||
pub folder: Option<FolderInfo>,
|
||||
pub subfolders: Vec<FolderInfo>,
|
||||
pub documents: Vec<DocumentResponse>,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/folders/{id}",
|
||||
params(("id" = Uuid, Path, description = "Folder ID")),
|
||||
responses((status = 200, description = "Folder detail", body = FolderResponse)),
|
||||
tag = "Folders"
|
||||
)]
|
||||
pub async fn get_folder(
|
||||
State(state): State<AppState>,
|
||||
Path(folder_id): Path<Uuid>,
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
) -> AppResult<JsonResponse<FolderResponse>> {
|
||||
let service = FolderService::new(&state);
|
||||
let folder = service.get_folder(&mut conn, tenant_id, folder_id)?;
|
||||
ok_json(FolderResponse { folder })
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/folders/path",
|
||||
request_body = EnsureFolderPathRequest,
|
||||
responses((status = 200, description = "Folder path ensured", body = FolderResponse)),
|
||||
tag = "Folders"
|
||||
)]
|
||||
pub async fn ensure_folder_path(
|
||||
State(state): State<AppState>,
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
Json(payload): Json<EnsureFolderPathRequest>,
|
||||
) -> AppResult<JsonResponse<FolderResponse>> {
|
||||
let service = FolderService::new(&state);
|
||||
let folder = service.ensure_folder_path(&mut conn, tenant_id, payload)?;
|
||||
ok_json(FolderResponse { folder })
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/folders",
|
||||
request_body = CreateFolderRequest,
|
||||
responses(
|
||||
(status = 201, description = "Folder created", body = FolderResponse),
|
||||
(status = 200, description = "Folder already existed", body = FolderResponse)
|
||||
),
|
||||
tag = "Folders"
|
||||
)]
|
||||
pub async fn create_folder(
|
||||
State(state): State<AppState>,
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
Json(payload): Json<CreateFolderRequest>,
|
||||
) -> AppResult<JsonResponse<FolderResponse>> {
|
||||
let service = FolderService::new(&state);
|
||||
let (folder, created) = service.create_folder(&mut conn, tenant_id, payload)?;
|
||||
let response = FolderResponse { folder };
|
||||
if created {
|
||||
created_json(response)
|
||||
} else {
|
||||
ok_json(response)
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/folders/{id}/contents",
|
||||
params(("id" = String, Path, description = "Folder ID or 'root'"), FolderContentsQuery),
|
||||
responses((status = 200, description = "Folder contents", body = FolderContentsResponse)),
|
||||
tag = "Folders"
|
||||
)]
|
||||
pub async fn list_folder_contents(
|
||||
State(state): State<AppState>,
|
||||
Path(folder_identifier): Path<String>,
|
||||
Query(query): Query<FolderContentsQuery>,
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
user_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
) -> AppResult<JsonResponse<FolderContentsResponse>> {
|
||||
let FolderContentsQuery {
|
||||
include_documents,
|
||||
sort,
|
||||
dir,
|
||||
} = query;
|
||||
|
||||
let folder_id = if folder_identifier.eq_ignore_ascii_case("root") {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
Uuid::parse_str(&folder_identifier)
|
||||
.map_err(|_| AppError::bad_request("folder identifier must be 'root' or a UUID"))?,
|
||||
)
|
||||
};
|
||||
|
||||
let service = FolderService::new(&state);
|
||||
let FolderContentsData {
|
||||
folder,
|
||||
subfolders,
|
||||
documents,
|
||||
} = service.list_folder_contents(
|
||||
&mut conn,
|
||||
tenant_id,
|
||||
folder_id,
|
||||
sort,
|
||||
dir,
|
||||
include_documents,
|
||||
)?;
|
||||
|
||||
let documents = if include_documents {
|
||||
service.hydrate_documents(&mut conn, tenant_id, user_id, documents)?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
ok_json(FolderContentsResponse {
|
||||
folder,
|
||||
subfolders,
|
||||
documents,
|
||||
})
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/folders/tree",
|
||||
responses((status = 200, description = "Folder hierarchy", body = [FolderTreeNode])),
|
||||
tag = "Folders"
|
||||
)]
|
||||
pub async fn list_folder_tree(
|
||||
State(state): State<AppState>,
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
) -> AppResult<JsonResponse<Vec<FolderTreeNode>>> {
|
||||
let service = FolderService::new(&state);
|
||||
let tree = service.list_folder_tree(&mut conn, tenant_id)?;
|
||||
ok_json(tree)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/folders/{id}",
|
||||
params(("id" = Uuid, Path, description = "Folder ID")),
|
||||
responses((status = 204, description = "Folder deleted")),
|
||||
tag = "Folders"
|
||||
)]
|
||||
pub async fn delete_folder(
|
||||
State(state): State<AppState>,
|
||||
Path(folder_id): Path<Uuid>,
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
) -> AppResult<StatusCode> {
|
||||
FolderService::new(&state).delete_folder(&mut conn, tenant_id, folder_id)?;
|
||||
no_content()
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
patch,
|
||||
path = "/api/folders/{id}",
|
||||
params(("id" = Uuid, Path, description = "Folder ID")),
|
||||
request_body = UpdateFolderRequest,
|
||||
responses((status = 204, description = "Folder updated")),
|
||||
tag = "Folders"
|
||||
)]
|
||||
pub async fn update_folder(
|
||||
State(state): State<AppState>,
|
||||
Path(folder_id): Path<Uuid>,
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
Json(payload): Json<UpdateFolderRequest>,
|
||||
) -> AppResult<StatusCode> {
|
||||
FolderService::new(&state).update_folder(&mut conn, tenant_id, folder_id, payload)?;
|
||||
no_content()
|
||||
}
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
paths(
|
||||
crate::routes::folders::create_folder,
|
||||
crate::routes::folders::ensure_folder_path,
|
||||
crate::routes::folders::get_folder,
|
||||
crate::routes::folders::list_folder_contents,
|
||||
crate::routes::folders::list_folder_tree,
|
||||
crate::routes::folders::delete_folder,
|
||||
crate::routes::folders::update_folder
|
||||
),
|
||||
components(schemas(
|
||||
crate::services::folders::CreateFolderRequest,
|
||||
crate::services::folders::EnsureFolderPathRequest,
|
||||
crate::routes::folders::FolderResponse,
|
||||
crate::services::folders::FolderInfo,
|
||||
crate::services::folders::FolderContentsQuery,
|
||||
crate::routes::folders::FolderContentsResponse,
|
||||
crate::services::folders::FolderTreeNode,
|
||||
crate::services::folders::UpdateFolderRequest
|
||||
))
|
||||
)]
|
||||
pub struct FoldersApiDoc;
|
||||
@@ -0,0 +1,46 @@
|
||||
use axum::{extract::State, http::StatusCode, response::Json};
|
||||
use diesel::RunQueryDsl;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(utoipa::OpenApi)]
|
||||
#[openapi(paths(crate::routes::health::health_check))]
|
||||
pub struct HealthApiDoc;
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/health",
|
||||
responses((status = 200, description = "Service is healthy")),
|
||||
tag = "Health"
|
||||
)]
|
||||
pub async fn health_check(State(state): State<AppState>) -> (StatusCode, Json<serde_json::Value>) {
|
||||
let database_ok = match state.db_unscoped() {
|
||||
Ok(mut conn) => diesel::sql_query("SELECT 1")
|
||||
.execute(&mut conn)
|
||||
.map(|_| true)
|
||||
.unwrap_or_else(|err| {
|
||||
tracing::error!(error = ?err, "health check database ping failed");
|
||||
false
|
||||
}),
|
||||
Err(err) => {
|
||||
tracing::error!(error = ?err, "health check database connection failed");
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
let status = if database_ok {
|
||||
StatusCode::OK
|
||||
} else {
|
||||
StatusCode::SERVICE_UNAVAILABLE
|
||||
};
|
||||
|
||||
let payload = json!({
|
||||
"status": if database_ok { "ok" } else { "error" },
|
||||
"checks": {
|
||||
"database": if database_ok { "ok" } else { "unavailable" }
|
||||
}
|
||||
});
|
||||
|
||||
(status, Json(payload))
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
use axum::http::HeaderValue;
|
||||
use axum::{
|
||||
extract::DefaultBodyLimit,
|
||||
middleware,
|
||||
response::{Html, Json},
|
||||
routing::{delete, get, patch, post},
|
||||
Router,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tower_http::{
|
||||
cors::{AllowOrigin, CorsLayer},
|
||||
trace::{DefaultMakeSpan, DefaultOnFailure, DefaultOnResponse, TraceLayer},
|
||||
};
|
||||
use utoipa::OpenApi;
|
||||
|
||||
use crate::{
|
||||
auth::{capability_guard::RequireCapabilitiesLayer, AuthenticatedUser},
|
||||
models::ApiCapability,
|
||||
openapi::ApiDoc,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
pub mod auth;
|
||||
pub mod capability_sets;
|
||||
pub mod correspondents;
|
||||
pub mod documents;
|
||||
pub mod folders;
|
||||
pub mod health;
|
||||
pub mod profile;
|
||||
pub mod tags;
|
||||
pub mod tenants;
|
||||
pub mod webdav;
|
||||
|
||||
pub fn create_router(state: AppState) -> Router<()> {
|
||||
let cors = if let Some(origins) = state.config.cors_allowed_origin.as_ref() {
|
||||
let headers: Vec<HeaderValue> = origins
|
||||
.split(',')
|
||||
.filter_map(|value| {
|
||||
let trimmed = value.trim();
|
||||
(!trimmed.is_empty()).then(|| {
|
||||
trimmed
|
||||
.parse::<HeaderValue>()
|
||||
.expect("invalid CORS allowed origin")
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let allow_origin = AllowOrigin::list(headers);
|
||||
|
||||
CorsLayer::new()
|
||||
.allow_origin(allow_origin)
|
||||
.allow_methods(tower_http::cors::AllowMethods::mirror_request())
|
||||
.allow_headers(tower_http::cors::AllowHeaders::mirror_request())
|
||||
.allow_credentials(true)
|
||||
} else {
|
||||
CorsLayer::new()
|
||||
.allow_origin(AllowOrigin::mirror_request())
|
||||
.allow_methods(tower_http::cors::AllowMethods::mirror_request())
|
||||
.allow_headers(tower_http::cors::AllowHeaders::mirror_request())
|
||||
.allow_credentials(true)
|
||||
};
|
||||
|
||||
let auth_routes = Router::new()
|
||||
.route("/signup/start", post(auth::signup_start))
|
||||
.route("/signup/finish", post(auth::signup_finish))
|
||||
.route("/login", post(auth::login))
|
||||
.route("/exchange-api-token", post(auth::api_token_exchange))
|
||||
.route("/refresh", post(auth::refresh))
|
||||
.route("/logout", post(auth::logout))
|
||||
.route("/select-tenant", post(auth::select_tenant))
|
||||
.route(
|
||||
"/passkeys/register/start",
|
||||
post(auth::passkey_register_start),
|
||||
)
|
||||
.route(
|
||||
"/passkeys/register/finish",
|
||||
post(auth::passkey_register_finish),
|
||||
)
|
||||
.route("/passkeys/login/start", post(auth::passkey_login_start))
|
||||
.route("/passkeys/login/finish", post(auth::passkey_login_finish))
|
||||
.route("/me", get(auth::me));
|
||||
|
||||
let documents_routes = Router::new()
|
||||
.route(
|
||||
"/check",
|
||||
get(documents::check_document).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::DocumentsRead,
|
||||
])),
|
||||
)
|
||||
.route(
|
||||
"/",
|
||||
get(documents::list_documents).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::DocumentsRead,
|
||||
])),
|
||||
)
|
||||
.route(
|
||||
"/",
|
||||
post(documents::upload_document).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::DocumentsWrite,
|
||||
ApiCapability::DocumentsUpload,
|
||||
])),
|
||||
)
|
||||
.route(
|
||||
"/bulk/move",
|
||||
post(documents::bulk_move_documents).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::DocumentsEdit,
|
||||
])),
|
||||
)
|
||||
.route(
|
||||
"/bulk/tags",
|
||||
post(documents::bulk_update_tags).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::DocumentsEdit,
|
||||
])),
|
||||
)
|
||||
.route(
|
||||
"/bulk/correspondents",
|
||||
post(documents::bulk_assign_correspondents).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::DocumentsEdit,
|
||||
])),
|
||||
)
|
||||
.route(
|
||||
"/bulk/reanalyze",
|
||||
post(documents::reanalyze_selected_documents).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::DocumentsWrite,
|
||||
])),
|
||||
)
|
||||
.route(
|
||||
"/{id}",
|
||||
get(documents::get_document).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::DocumentsRead,
|
||||
])),
|
||||
)
|
||||
.route(
|
||||
"/{id}/download",
|
||||
post(documents::refresh_document_download).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::DocumentsRead,
|
||||
])),
|
||||
)
|
||||
.route(
|
||||
"/{id}/trash",
|
||||
post(documents::trash_document).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::DocumentsWrite,
|
||||
])),
|
||||
)
|
||||
.route(
|
||||
"/{id}",
|
||||
delete(documents::delete_document).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::DocumentsWrite,
|
||||
])),
|
||||
)
|
||||
.route(
|
||||
"/{id}",
|
||||
patch(documents::update_document).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::DocumentsEdit,
|
||||
])),
|
||||
)
|
||||
.route(
|
||||
"/{id}/assets",
|
||||
get(documents::list_document_assets).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::DocumentsRead,
|
||||
])),
|
||||
)
|
||||
.route(
|
||||
"/{id}/assets",
|
||||
post(documents::request_document_assets).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::DocumentsWrite,
|
||||
])),
|
||||
)
|
||||
.route(
|
||||
"/{id}/folder",
|
||||
patch(documents::move_document).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::DocumentsEdit,
|
||||
])),
|
||||
)
|
||||
.route(
|
||||
"/{id}/versions",
|
||||
get(documents::list_document_versions).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::DocumentsRead,
|
||||
])),
|
||||
)
|
||||
.route(
|
||||
"/{id}/versions/{version_id}",
|
||||
get(documents::get_document_version).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::DocumentsRead,
|
||||
])),
|
||||
)
|
||||
.route(
|
||||
"/{id}/versions/{version_id}/download",
|
||||
post(documents::refresh_document_version_download).layer(
|
||||
RequireCapabilitiesLayer::all([ApiCapability::DocumentsRead]),
|
||||
),
|
||||
)
|
||||
.route(
|
||||
"/{id}/restore",
|
||||
post(documents::restore_document).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::DocumentsEdit,
|
||||
])),
|
||||
)
|
||||
.route(
|
||||
"/{id}/tags",
|
||||
post(documents::assign_tags).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::DocumentsEdit,
|
||||
])),
|
||||
)
|
||||
.route(
|
||||
"/{id}/tags/{tag_id}",
|
||||
delete(documents::remove_tag).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::DocumentsEdit,
|
||||
])),
|
||||
)
|
||||
.route(
|
||||
"/{id}/correspondents",
|
||||
post(documents::assign_correspondents).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::DocumentsEdit,
|
||||
])),
|
||||
)
|
||||
.route(
|
||||
"/{id}/correspondents/{correspondent_id}",
|
||||
delete(documents::remove_correspondent).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::DocumentsEdit,
|
||||
])),
|
||||
);
|
||||
|
||||
let download_routes =
|
||||
Router::new().route("/api/download/{token}", get(documents::download_with_token));
|
||||
|
||||
let folders_routes = Router::new()
|
||||
.route(
|
||||
"/",
|
||||
post(folders::create_folder)
|
||||
.layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersWrite])),
|
||||
)
|
||||
.route(
|
||||
"/path",
|
||||
post(folders::ensure_folder_path)
|
||||
.layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersWrite])),
|
||||
)
|
||||
.route(
|
||||
"/tree",
|
||||
get(folders::list_folder_tree)
|
||||
.layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersRead])),
|
||||
)
|
||||
.route(
|
||||
"/{id}",
|
||||
get(folders::get_folder)
|
||||
.layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersRead])),
|
||||
)
|
||||
.route(
|
||||
"/{id}",
|
||||
delete(folders::delete_folder)
|
||||
.layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersWrite])),
|
||||
)
|
||||
.route(
|
||||
"/{id}",
|
||||
patch(folders::update_folder)
|
||||
.layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersEdit])),
|
||||
)
|
||||
.route(
|
||||
"/{id}/contents",
|
||||
get(folders::list_folder_contents)
|
||||
.layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersRead])),
|
||||
);
|
||||
|
||||
let tags_routes = Router::new()
|
||||
.route(
|
||||
"/",
|
||||
get(tags::list_tags).layer(RequireCapabilitiesLayer::all([ApiCapability::TagsRead])),
|
||||
)
|
||||
.route(
|
||||
"/",
|
||||
post(tags::create_tag).layer(RequireCapabilitiesLayer::all([ApiCapability::TagsWrite])),
|
||||
)
|
||||
.route(
|
||||
"/{id}",
|
||||
patch(tags::update_tag).layer(RequireCapabilitiesLayer::all([ApiCapability::TagsEdit])),
|
||||
)
|
||||
.route(
|
||||
"/{id}",
|
||||
delete(tags::delete_tag)
|
||||
.layer(RequireCapabilitiesLayer::all([ApiCapability::TagsWrite])),
|
||||
);
|
||||
|
||||
let correspondents_routes = Router::new()
|
||||
.route(
|
||||
"/",
|
||||
get(correspondents::list_correspondents).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::CorrespondentsRead,
|
||||
])),
|
||||
)
|
||||
.route(
|
||||
"/",
|
||||
post(correspondents::create_correspondent).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::CorrespondentsWrite,
|
||||
])),
|
||||
)
|
||||
.route(
|
||||
"/{id}",
|
||||
patch(correspondents::update_correspondent).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::CorrespondentsEdit,
|
||||
])),
|
||||
)
|
||||
.route(
|
||||
"/{id}",
|
||||
delete(correspondents::delete_correspondent).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::CorrespondentsWrite,
|
||||
])),
|
||||
);
|
||||
|
||||
let profile_routes = Router::new()
|
||||
.route(
|
||||
"/api-tokens",
|
||||
get(profile::list_api_tokens)
|
||||
.layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileRead])),
|
||||
)
|
||||
.route(
|
||||
"/api-tokens",
|
||||
post(profile::create_api_token)
|
||||
.layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileWrite])),
|
||||
)
|
||||
.route(
|
||||
"/api-tokens/{id}/regenerate",
|
||||
post(profile::regenerate_api_token)
|
||||
.layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileWrite])),
|
||||
)
|
||||
.route(
|
||||
"/api-tokens/{id}",
|
||||
delete(profile::delete_api_token)
|
||||
.layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileWrite])),
|
||||
)
|
||||
.route(
|
||||
"/passkeys",
|
||||
get(profile::list_passkeys)
|
||||
.layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileRead])),
|
||||
)
|
||||
.route(
|
||||
"/passkeys/{id}",
|
||||
delete(profile::delete_passkey)
|
||||
.layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileWrite])),
|
||||
);
|
||||
|
||||
let capability_sets_routes = Router::new()
|
||||
.route(
|
||||
"/",
|
||||
get(capability_sets::list_capability_sets).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::CapabilitySetsRead,
|
||||
])),
|
||||
)
|
||||
.route(
|
||||
"/",
|
||||
post(capability_sets::create_capability_set).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::CapabilitySetsWrite,
|
||||
])),
|
||||
)
|
||||
.route(
|
||||
"/{id}",
|
||||
get(capability_sets::get_capability_set).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::CapabilitySetsRead,
|
||||
])),
|
||||
)
|
||||
.route(
|
||||
"/{id}",
|
||||
patch(capability_sets::update_capability_set).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::CapabilitySetsWrite,
|
||||
])),
|
||||
)
|
||||
.route(
|
||||
"/{id}",
|
||||
delete(capability_sets::delete_capability_set).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::CapabilitySetsWrite,
|
||||
])),
|
||||
);
|
||||
|
||||
let capabilities_routes = Router::new().route(
|
||||
"/",
|
||||
get(capability_sets::list_capabilities).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::CapabilitySetsRead,
|
||||
])),
|
||||
);
|
||||
|
||||
let protected_state = state.clone();
|
||||
let assets_routes = Router::new()
|
||||
.route(
|
||||
"/{asset_id}",
|
||||
get(documents::get_document_asset).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::DocumentsRead,
|
||||
])),
|
||||
)
|
||||
.route(
|
||||
"/{asset_id}/download",
|
||||
post(documents::refresh_asset_download).layer(RequireCapabilitiesLayer::all([
|
||||
ApiCapability::DocumentsRead,
|
||||
])),
|
||||
);
|
||||
|
||||
let manage_tenants_layer = RequireCapabilitiesLayer::all([ApiCapability::TenantsWrite]);
|
||||
let tenants_routes = Router::new()
|
||||
.route("/", get(tenants::list_tenants))
|
||||
.route("/{tenant_id}", get(tenants::get_tenant))
|
||||
.route(
|
||||
"/{tenant_id}",
|
||||
patch(tenants::update_tenant).layer(manage_tenants_layer.clone()),
|
||||
)
|
||||
.route(
|
||||
"/{tenant_id}/users",
|
||||
get(tenants::list_tenant_users).layer(manage_tenants_layer.clone()),
|
||||
)
|
||||
.route(
|
||||
"/{tenant_id}/users/{user_id}",
|
||||
get(tenants::get_tenant_user).layer(manage_tenants_layer.clone()),
|
||||
)
|
||||
.route(
|
||||
"/{tenant_id}/users/{user_id}",
|
||||
patch(tenants::update_tenant_user).layer(manage_tenants_layer.clone()),
|
||||
)
|
||||
.route(
|
||||
"/{tenant_id}/users/{user_id}",
|
||||
delete(tenants::delete_tenant_user).layer(manage_tenants_layer.clone()),
|
||||
);
|
||||
|
||||
let protected_routes = Router::new()
|
||||
.nest("/api/documents", documents_routes)
|
||||
.nest("/api/folders", folders_routes)
|
||||
.nest("/api/tags", tags_routes)
|
||||
.nest("/api/correspondents", correspondents_routes)
|
||||
.nest("/api/profile", profile_routes)
|
||||
.nest("/api/capability-sets", capability_sets_routes)
|
||||
.nest("/api/capabilities", capabilities_routes)
|
||||
.nest("/api/assets", assets_routes)
|
||||
.nest("/api/tenants", tenants_routes)
|
||||
.layer(middleware::from_extractor_with_state::<AuthenticatedUser, _>(protected_state));
|
||||
|
||||
let upload_limit = state.config.upload_body_limit_bytes;
|
||||
|
||||
let openapi_spec = Arc::new(ApiDoc::openapi());
|
||||
let docs_router = Router::new()
|
||||
.route(
|
||||
"/api/docs",
|
||||
get(move || async { Html(render_swagger_ui("/api/docs/openapi.json")) }),
|
||||
)
|
||||
.route(
|
||||
"/api/docs/openapi.json",
|
||||
get({
|
||||
let spec = openapi_spec.clone();
|
||||
move || async move { Json((*spec).clone()) }
|
||||
}),
|
||||
);
|
||||
|
||||
Router::new()
|
||||
.merge(download_routes)
|
||||
.merge(protected_routes)
|
||||
.merge(docs_router)
|
||||
.nest("/api/auth", auth_routes)
|
||||
.route("/api/health", get(health::health_check))
|
||||
.with_state(state)
|
||||
.layer(cors)
|
||||
.layer(DefaultBodyLimit::max(
|
||||
usize::try_from(upload_limit).unwrap_or(usize::MAX),
|
||||
))
|
||||
.layer(
|
||||
TraceLayer::new_for_http()
|
||||
.make_span_with(DefaultMakeSpan::new().level(tracing::Level::INFO))
|
||||
.on_response(DefaultOnResponse::new().level(tracing::Level::INFO))
|
||||
.on_failure(DefaultOnFailure::new().level(tracing::Level::ERROR)),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_swagger_ui(spec_url: &str) -> String {
|
||||
format!(
|
||||
r#"<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Papercrate API Docs</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
|
||||
<style>
|
||||
html {{ box-sizing: border-box; font-family: sans-serif; }}
|
||||
*, *:before, *:after {{ box-sizing: inherit; }}
|
||||
body {{ margin: 0; background: #fafafa; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
||||
<script>
|
||||
window.addEventListener('load', () => {{
|
||||
window.ui = SwaggerUIBundle({{
|
||||
url: '{spec_url}',
|
||||
dom_id: '#swagger-ui',
|
||||
deepLinking: true,
|
||||
}});
|
||||
}});
|
||||
</script>
|
||||
</body>
|
||||
</html>"#
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use utoipa::OpenApi;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
auth::{passkeys::PasskeySummary, TenantScopedConn},
|
||||
error::AppResult,
|
||||
http::responders::JsonResponse,
|
||||
services::profile::{
|
||||
ApiTokenCreatedResponse, ApiTokenResponse, CreateApiTokenRequest, ProfileService,
|
||||
RevokePasskeyQuery,
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/profile/passkeys",
|
||||
responses((status = 200, description = "List registered passkeys", body = [PasskeySummary])),
|
||||
tag = "Profile"
|
||||
)]
|
||||
pub async fn list_passkeys(
|
||||
State(state): State<AppState>,
|
||||
TenantScopedConn {
|
||||
mut conn, user_id, ..
|
||||
}: TenantScopedConn,
|
||||
) -> AppResult<JsonResponse<Vec<PasskeySummary>>> {
|
||||
ProfileService::new(&state).list_passkeys(&mut conn, user_id)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/profile/api-tokens",
|
||||
responses((status = 200, description = "List API tokens", body = [ApiTokenResponse])),
|
||||
tag = "Profile"
|
||||
)]
|
||||
pub async fn list_api_tokens(
|
||||
State(state): State<AppState>,
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
user_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
) -> AppResult<JsonResponse<Vec<ApiTokenResponse>>> {
|
||||
ProfileService::new(&state).list_api_tokens(&mut conn, tenant_id, user_id)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/profile/api-tokens",
|
||||
request_body = CreateApiTokenRequest,
|
||||
responses((status = 201, description = "API token created", body = ApiTokenCreatedResponse)),
|
||||
tag = "Profile"
|
||||
)]
|
||||
pub async fn create_api_token(
|
||||
State(state): State<AppState>,
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
user_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
Json(payload): Json<CreateApiTokenRequest>,
|
||||
) -> AppResult<JsonResponse<ApiTokenCreatedResponse>> {
|
||||
ProfileService::new(&state).create_api_token(&mut conn, tenant_id, user_id, payload)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/profile/api-tokens/{id}/regenerate",
|
||||
params(("id" = Uuid, Path, description = "API token ID")),
|
||||
responses((status = 200, description = "API token regenerated", body = ApiTokenCreatedResponse)),
|
||||
tag = "Profile"
|
||||
)]
|
||||
pub async fn regenerate_api_token(
|
||||
State(state): State<AppState>,
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
user_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
Path(token_id): Path<Uuid>,
|
||||
) -> AppResult<JsonResponse<ApiTokenCreatedResponse>> {
|
||||
ProfileService::new(&state).regenerate_api_token(&mut conn, tenant_id, user_id, token_id)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/profile/api-tokens/{id}",
|
||||
params(("id" = Uuid, Path, description = "API token ID")),
|
||||
responses((status = 204, description = "API token revoked")),
|
||||
tag = "Profile"
|
||||
)]
|
||||
pub async fn delete_api_token(
|
||||
State(state): State<AppState>,
|
||||
TenantScopedConn {
|
||||
mut conn, user_id, ..
|
||||
}: TenantScopedConn,
|
||||
Path(token_id): Path<Uuid>,
|
||||
) -> AppResult<StatusCode> {
|
||||
ProfileService::new(&state).delete_api_token(&mut conn, user_id, token_id)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/profile/passkeys/{id}",
|
||||
params(
|
||||
("id" = Uuid, Path, description = "Passkey ID"),
|
||||
("reason" = Option<String>, Query, description = "Optional reason for revoking the passkey")
|
||||
),
|
||||
responses((status = 204, description = "Passkey revoked")),
|
||||
tag = "Profile"
|
||||
)]
|
||||
pub async fn delete_passkey(
|
||||
State(state): State<AppState>,
|
||||
TenantScopedConn {
|
||||
mut conn, user_id, ..
|
||||
}: TenantScopedConn,
|
||||
Path(passkey_id): Path<Uuid>,
|
||||
Query(query): Query<RevokePasskeyQuery>,
|
||||
) -> AppResult<StatusCode> {
|
||||
ProfileService::new(&state).delete_passkey(&mut conn, user_id, passkey_id, query.reason)
|
||||
}
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
paths(
|
||||
crate::routes::profile::list_api_tokens,
|
||||
crate::routes::profile::create_api_token,
|
||||
crate::routes::profile::regenerate_api_token,
|
||||
crate::routes::profile::delete_api_token,
|
||||
crate::routes::profile::list_passkeys,
|
||||
crate::routes::profile::delete_passkey
|
||||
),
|
||||
components(schemas(
|
||||
crate::models::ApiCapability,
|
||||
crate::services::profile::ApiTokenResponse,
|
||||
crate::services::profile::ApiTokenCreatedResponse,
|
||||
crate::services::profile::CreateApiTokenRequest,
|
||||
crate::services::profile::RevokePasskeyQuery,
|
||||
crate::auth::passkeys::PasskeySummary
|
||||
))
|
||||
)]
|
||||
pub struct ProfileApiDoc;
|
||||
@@ -0,0 +1,358 @@
|
||||
use axum::{extract::Path, http::StatusCode, Json};
|
||||
use diesel::{dsl::count_star, prelude::*};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
auth::TenantScopedConn,
|
||||
error::{AppError, AppResult},
|
||||
http::responders::{no_content, ok_json, IntoAppResult, JsonResponse, RowsAffectedExt},
|
||||
models::{NewTag, Tag},
|
||||
schema::{document_tags, tags},
|
||||
utils::{
|
||||
json::deserialize_patch_field,
|
||||
named_entity::{ensure_name_available, normalize_name},
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct CreateTagRequest {
|
||||
pub label: String,
|
||||
#[schema(nullable)]
|
||||
pub color: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(AsChangeset, Default)]
|
||||
#[diesel(table_name = tags)]
|
||||
struct UpdateTagChangeset<'a> {
|
||||
label: Option<&'a str>,
|
||||
color: Option<Option<&'a str>>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn update_tag_request_deserializes_null_fields() {
|
||||
let request: UpdateTagRequest = serde_json::from_value(json!({
|
||||
"label": null,
|
||||
"color": null
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(matches!(request.label, Some(None)));
|
||||
assert!(matches!(request.color, Some(None)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_tag_request_omitted_fields_are_none() {
|
||||
let request: UpdateTagRequest = serde_json::from_value(json!({})).unwrap();
|
||||
assert!(request.label.is_none());
|
||||
assert!(request.color.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct TagCatalogEntry {
|
||||
pub id: Uuid,
|
||||
pub label: String,
|
||||
#[schema(nullable)]
|
||||
pub color: Option<String>,
|
||||
pub usage_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize, ToSchema)]
|
||||
pub struct UpdateTagRequest {
|
||||
#[serde(default, deserialize_with = "deserialize_patch_field")]
|
||||
#[schema(nullable, value_type = Option<String>)]
|
||||
pub label: Option<Option<String>>,
|
||||
#[serde(default, deserialize_with = "deserialize_patch_field")]
|
||||
#[schema(nullable, value_type = Option<String>)]
|
||||
pub color: Option<Option<String>>,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/tags",
|
||||
responses((status = 200, description = "Tags", body = [TagCatalogEntry])),
|
||||
tag = "Tags"
|
||||
)]
|
||||
pub async fn list_tags(
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
) -> AppResult<JsonResponse<Vec<TagCatalogEntry>>> {
|
||||
let tag_list: Vec<Tag> = tags::table
|
||||
.filter(tags::tenant_id.eq(tenant_id))
|
||||
.order(tags::label.asc())
|
||||
.load(&mut conn)?;
|
||||
|
||||
let usage_rows: Vec<(Uuid, i64)> = document_tags::table
|
||||
.filter(document_tags::tenant_id.eq(tenant_id))
|
||||
.group_by(document_tags::tag_id)
|
||||
.select((document_tags::tag_id, count_star()))
|
||||
.load(&mut conn)?;
|
||||
|
||||
let usage_map: HashMap<Uuid, i64> = usage_rows.into_iter().collect();
|
||||
|
||||
let response: Vec<TagCatalogEntry> = tag_list
|
||||
.into_iter()
|
||||
.map(|tag| TagCatalogEntry {
|
||||
id: tag.id,
|
||||
label: tag.label,
|
||||
color: tag.color,
|
||||
usage_count: *usage_map.get(&tag.id).unwrap_or(&0),
|
||||
})
|
||||
.collect();
|
||||
|
||||
ok_json(response)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/tags",
|
||||
request_body = CreateTagRequest,
|
||||
responses((status = 200, description = "Tag created", body = TagCatalogEntry)),
|
||||
tag = "Tags"
|
||||
)]
|
||||
pub async fn create_tag(
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
Json(payload): Json<CreateTagRequest>,
|
||||
) -> AppResult<JsonResponse<TagCatalogEntry>> {
|
||||
let label = normalize_name(&payload.label, || {
|
||||
AppError::bad_request("label must not be empty")
|
||||
})?;
|
||||
|
||||
let new_tag = NewTag {
|
||||
id: Uuid::new_v4(),
|
||||
label: label.clone(),
|
||||
color: payload.color,
|
||||
tenant_id,
|
||||
};
|
||||
|
||||
match diesel::insert_into(tags::table)
|
||||
.values(&new_tag)
|
||||
.execute(&mut conn)
|
||||
{
|
||||
Ok(_) => {}
|
||||
Err(diesel::result::Error::DatabaseError(
|
||||
diesel::result::DatabaseErrorKind::UniqueViolation,
|
||||
_,
|
||||
)) => {
|
||||
return Err(AppError::bad_request("tag label already exists"));
|
||||
}
|
||||
Err(err) => return Err(AppError::from(err)),
|
||||
}
|
||||
|
||||
let tag: Tag = tags::table
|
||||
.find(new_tag.id)
|
||||
.filter(tags::tenant_id.eq(tenant_id))
|
||||
.first(&mut conn)
|
||||
.into_app_result()?;
|
||||
|
||||
ok_json(TagCatalogEntry {
|
||||
id: tag.id,
|
||||
label: tag.label,
|
||||
color: tag.color,
|
||||
usage_count: 0,
|
||||
})
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
patch,
|
||||
path = "/api/tags/{id}",
|
||||
params(("id" = Uuid, Path, description = "Tag ID")),
|
||||
request_body = UpdateTagRequest,
|
||||
responses((status = 200, description = "Tag updated", body = TagCatalogEntry)),
|
||||
tag = "Tags"
|
||||
)]
|
||||
pub async fn update_tag(
|
||||
Path(tag_id): Path<Uuid>,
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
Json(payload): Json<UpdateTagRequest>,
|
||||
) -> AppResult<JsonResponse<TagCatalogEntry>> {
|
||||
let existing: Tag = tags::table
|
||||
.find(tag_id)
|
||||
.filter(tags::tenant_id.eq(tenant_id))
|
||||
.first(&mut conn)
|
||||
.into_app_result()?;
|
||||
let UpdateTagRequest { label, color } = payload;
|
||||
|
||||
if label.is_none() && color.is_none() {
|
||||
let usage_count: i64 = document_tags::table
|
||||
.filter(document_tags::tag_id.eq(tag_id))
|
||||
.select(count_star())
|
||||
.first(&mut conn)?;
|
||||
return ok_json(TagCatalogEntry {
|
||||
id: existing.id,
|
||||
label: existing.label.clone(),
|
||||
color: existing.color.clone(),
|
||||
usage_count,
|
||||
});
|
||||
}
|
||||
|
||||
let mut new_label: Option<String> = None;
|
||||
let mut label_changed = false;
|
||||
match label {
|
||||
None => {}
|
||||
Some(None) => {
|
||||
return Err(AppError::bad_request("label cannot be null"));
|
||||
}
|
||||
Some(Some(value)) => {
|
||||
let normalized =
|
||||
normalize_name(&value, || AppError::bad_request("label must not be empty"))?;
|
||||
if normalized != existing.label {
|
||||
ensure_name_available(
|
||||
|| {
|
||||
tags::table
|
||||
.filter(tags::label.eq(&normalized))
|
||||
.filter(tags::id.ne(tag_id))
|
||||
.filter(tags::tenant_id.eq(tenant_id))
|
||||
.first::<Tag>(&mut conn)
|
||||
.optional()
|
||||
},
|
||||
|| AppError::bad_request("tag label already exists"),
|
||||
)?;
|
||||
new_label = Some(normalized);
|
||||
label_changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut color_change: Option<Option<String>> = None;
|
||||
let mut color_changed = false;
|
||||
match color {
|
||||
None => {}
|
||||
Some(None) => {
|
||||
color_change = Some(None);
|
||||
color_changed = true;
|
||||
}
|
||||
Some(Some(value)) => {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(AppError::bad_request("color must not be empty"));
|
||||
}
|
||||
if existing.color.as_deref() != Some(trimmed) {
|
||||
color_change = Some(Some(trimmed.to_string()));
|
||||
color_changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !label_changed && !color_changed {
|
||||
let usage_count: i64 = document_tags::table
|
||||
.filter(document_tags::tag_id.eq(tag_id))
|
||||
.filter(document_tags::tenant_id.eq(tenant_id))
|
||||
.select(count_star())
|
||||
.first(&mut conn)?;
|
||||
return ok_json(TagCatalogEntry {
|
||||
id: existing.id,
|
||||
label: existing.label.clone(),
|
||||
color: existing.color.clone(),
|
||||
usage_count,
|
||||
});
|
||||
}
|
||||
|
||||
let changeset = UpdateTagChangeset {
|
||||
label: new_label.as_deref(),
|
||||
color: color_change
|
||||
.as_ref()
|
||||
.map(|opt| opt.as_ref().map(|value| value.as_str())),
|
||||
};
|
||||
|
||||
diesel::update(
|
||||
tags::table
|
||||
.find(tag_id)
|
||||
.filter(tags::tenant_id.eq(tenant_id)),
|
||||
)
|
||||
.set(&changeset)
|
||||
.execute(&mut conn)
|
||||
.into_app_result()?
|
||||
.or_not_found()?;
|
||||
|
||||
let updated: Tag = tags::table
|
||||
.find(tag_id)
|
||||
.filter(tags::tenant_id.eq(tenant_id))
|
||||
.first(&mut conn)
|
||||
.into_app_result()?;
|
||||
let usage_count: i64 = document_tags::table
|
||||
.filter(document_tags::tag_id.eq(tag_id))
|
||||
.filter(document_tags::tenant_id.eq(tenant_id))
|
||||
.select(count_star())
|
||||
.first(&mut conn)?;
|
||||
|
||||
ok_json(TagCatalogEntry {
|
||||
id: updated.id,
|
||||
label: updated.label,
|
||||
color: updated.color,
|
||||
usage_count,
|
||||
})
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/tags/{id}",
|
||||
params(("id" = Uuid, Path, description = "Tag ID")),
|
||||
responses((status = 204, description = "Tag deleted")),
|
||||
tag = "Tags"
|
||||
)]
|
||||
pub async fn delete_tag(
|
||||
Path(tag_id): Path<Uuid>,
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
) -> AppResult<StatusCode> {
|
||||
let usage: i64 = document_tags::table
|
||||
.filter(document_tags::tag_id.eq(tag_id))
|
||||
.filter(document_tags::tenant_id.eq(tenant_id))
|
||||
.select(count_star())
|
||||
.first(&mut conn)?;
|
||||
|
||||
if usage > 0 {
|
||||
return Err(AppError::bad_request(
|
||||
"cannot delete tag that is still assigned to documents",
|
||||
));
|
||||
}
|
||||
|
||||
diesel::delete(
|
||||
tags::table
|
||||
.find(tag_id)
|
||||
.filter(tags::tenant_id.eq(tenant_id)),
|
||||
)
|
||||
.execute(&mut conn)
|
||||
.into_app_result()?
|
||||
.or_not_found()?;
|
||||
|
||||
no_content()
|
||||
}
|
||||
|
||||
#[derive(utoipa::OpenApi)]
|
||||
#[openapi(
|
||||
paths(
|
||||
crate::routes::tags::list_tags,
|
||||
crate::routes::tags::create_tag,
|
||||
crate::routes::tags::update_tag,
|
||||
crate::routes::tags::delete_tag
|
||||
),
|
||||
components(schemas(
|
||||
crate::routes::tags::CreateTagRequest,
|
||||
crate::routes::tags::TagCatalogEntry,
|
||||
crate::routes::tags::UpdateTagRequest
|
||||
))
|
||||
)]
|
||||
pub struct TagsApiDoc;
|
||||
@@ -0,0 +1,154 @@
|
||||
use axum::extract::{Path, State};
|
||||
use axum::{http::StatusCode, Json};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::{AuthenticatedUser, TenantMembershipUser};
|
||||
use crate::error::AppResult;
|
||||
use crate::http::responders::JsonResponse;
|
||||
use crate::services::auth::{AuthService, TenantSnippet};
|
||||
use crate::services::tenants::{
|
||||
TenantApiService, TenantUserListResponse, TenantUserSummary, UpdateTenantRequest,
|
||||
UpdateTenantUserRequest,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/tenants",
|
||||
responses((status = 200, body = [TenantSnippet], description = "Tenant memberships for the current user")),
|
||||
tag = "Tenants"
|
||||
)]
|
||||
pub async fn list_tenants(
|
||||
State(state): State<AppState>,
|
||||
user: TenantMembershipUser,
|
||||
) -> AppResult<Json<Vec<TenantSnippet>>> {
|
||||
let response = AuthService::new(&state).list_tenants(user.user_id)?;
|
||||
Ok(Json(response.into_inner().tenants))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/tenants/{tenant_id}",
|
||||
params(("tenant_id" = Uuid, Path, description = "Tenant identifier")),
|
||||
responses((status = 200, body = TenantSnippet, description = "Tenant details")),
|
||||
tag = "Tenants"
|
||||
)]
|
||||
pub async fn get_tenant(
|
||||
State(state): State<AppState>,
|
||||
Path(tenant_id): Path<Uuid>,
|
||||
user: TenantMembershipUser,
|
||||
) -> AppResult<JsonResponse<TenantSnippet>> {
|
||||
AuthService::new(&state).get_tenant(user.user_id, tenant_id)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
patch,
|
||||
path = "/api/tenants/{tenant_id}",
|
||||
params(("tenant_id" = Uuid, Path, description = "Tenant identifier")),
|
||||
request_body = UpdateTenantRequest,
|
||||
responses((status = 200, body = TenantSnippet, description = "Updated tenant")),
|
||||
tag = "Tenants"
|
||||
)]
|
||||
pub async fn update_tenant(
|
||||
State(state): State<AppState>,
|
||||
Path(tenant_id): Path<Uuid>,
|
||||
user: AuthenticatedUser,
|
||||
Json(payload): Json<UpdateTenantRequest>,
|
||||
) -> AppResult<JsonResponse<TenantSnippet>> {
|
||||
TenantApiService::new(&state).update_name(user, tenant_id, payload)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/tenants/{tenant_id}/users",
|
||||
params(("tenant_id" = Uuid, Path, description = "Tenant ID")),
|
||||
responses((status = 200, body = [TenantUserSummary], description = "All users for the tenant")),
|
||||
tag = "Tenants"
|
||||
)]
|
||||
pub async fn list_tenant_users(
|
||||
State(state): State<AppState>,
|
||||
Path(tenant_id): Path<Uuid>,
|
||||
user: AuthenticatedUser,
|
||||
) -> AppResult<Json<Vec<TenantUserSummary>>> {
|
||||
let response = TenantApiService::new(&state).list_users(&user, tenant_id)?;
|
||||
Ok(Json(response.into_inner().users))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/tenants/{tenant_id}/users/{user_id}",
|
||||
params(
|
||||
("tenant_id" = Uuid, Path, description = "Tenant ID"),
|
||||
("user_id" = Uuid, Path, description = "User ID")
|
||||
),
|
||||
responses((status = 200, body = TenantUserSummary, description = "Tenant user details")),
|
||||
tag = "Tenants"
|
||||
)]
|
||||
pub async fn get_tenant_user(
|
||||
State(state): State<AppState>,
|
||||
Path((tenant_id, target_user_id)): Path<(Uuid, Uuid)>,
|
||||
user: AuthenticatedUser,
|
||||
) -> AppResult<JsonResponse<TenantUserSummary>> {
|
||||
TenantApiService::new(&state).get_user(&user, tenant_id, target_user_id)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
patch,
|
||||
path = "/api/tenants/{tenant_id}/users/{user_id}",
|
||||
params(
|
||||
("tenant_id" = Uuid, Path, description = "Tenant ID"),
|
||||
("user_id" = Uuid, Path, description = "User ID")
|
||||
),
|
||||
request_body = UpdateTenantUserRequest,
|
||||
responses((status = 200, body = TenantUserSummary, description = "Updated tenant user")),
|
||||
tag = "Tenants"
|
||||
)]
|
||||
pub async fn update_tenant_user(
|
||||
State(state): State<AppState>,
|
||||
Path((tenant_id, target_user_id)): Path<(Uuid, Uuid)>,
|
||||
user: AuthenticatedUser,
|
||||
Json(payload): Json<UpdateTenantUserRequest>,
|
||||
) -> AppResult<JsonResponse<TenantUserSummary>> {
|
||||
TenantApiService::new(&state).update_user(&user, tenant_id, target_user_id, payload)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/tenants/{tenant_id}/users/{user_id}",
|
||||
params(
|
||||
("tenant_id" = Uuid, Path, description = "Tenant ID"),
|
||||
("user_id" = Uuid, Path, description = "User ID")
|
||||
),
|
||||
responses((status = 204, description = "Membership removed")),
|
||||
tag = "Tenants"
|
||||
)]
|
||||
pub async fn delete_tenant_user(
|
||||
State(state): State<AppState>,
|
||||
Path((tenant_id, target_user_id)): Path<(Uuid, Uuid)>,
|
||||
user: AuthenticatedUser,
|
||||
) -> AppResult<StatusCode> {
|
||||
TenantApiService::new(&state).remove_user(&user, tenant_id, target_user_id)?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[derive(utoipa::OpenApi)]
|
||||
#[openapi(
|
||||
paths(
|
||||
list_tenants,
|
||||
get_tenant,
|
||||
update_tenant,
|
||||
list_tenant_users,
|
||||
get_tenant_user,
|
||||
update_tenant_user,
|
||||
delete_tenant_user,
|
||||
),
|
||||
components(schemas(
|
||||
crate::services::auth::TenantListResponse,
|
||||
crate::services::auth::TenantSnippet,
|
||||
UpdateTenantRequest,
|
||||
UpdateTenantUserRequest,
|
||||
TenantUserListResponse,
|
||||
TenantUserSummary,
|
||||
))
|
||||
)]
|
||||
pub struct TenantsApiDoc;
|
||||
@@ -0,0 +1,846 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::extract::State;
|
||||
use axum::http::{header, HeaderMap, Method, StatusCode};
|
||||
use axum::response::Response;
|
||||
use axum::Router;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||
use base64::Engine;
|
||||
use diesel::prelude::*;
|
||||
use diesel::OptionalExtension;
|
||||
use diesel::PgConnection;
|
||||
use futures_util::StreamExt;
|
||||
use percent_encoding::{percent_decode_str, utf8_percent_encode, NON_ALPHANUMERIC};
|
||||
use quick_xml::events::{BytesDecl, BytesEnd, BytesStart, BytesText, Event};
|
||||
use quick_xml::Writer;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::{
|
||||
api_tokens::{find_active_token_by_secret, touch_api_token},
|
||||
ensure_active_tenant_with_conn,
|
||||
};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::models::{ApiCapability, Document, DocumentVersion, Folder, User};
|
||||
use crate::schema::{
|
||||
document_versions::dsl as document_versions_dsl, documents::dsl as documents_dsl,
|
||||
folders::dsl as folders_dsl, user_memberships::dsl as memberships_dsl, users::dsl as users_dsl,
|
||||
};
|
||||
use crate::state::{AppState, PgPooledConnection};
|
||||
use crate::tenants::{apply_tenant_guc, apply_user_guc, clear_user_guc};
|
||||
use crate::utils::{error::StorageResultExt, http::inline_content_disposition, time::to_http_date};
|
||||
|
||||
const REALM: &str = "Papercrate WebDAV";
|
||||
const DOWNLOAD_URL_TTL_SECONDS: u64 = 300;
|
||||
|
||||
struct WebDavContext {
|
||||
tenant_id: Uuid,
|
||||
_user_id: Uuid,
|
||||
_username: String,
|
||||
conn: PgPooledConnection,
|
||||
}
|
||||
|
||||
pub fn create_router() -> Router<AppState> {
|
||||
Router::new().fallback(webdav_entrypoint)
|
||||
}
|
||||
|
||||
async fn webdav_entrypoint(
|
||||
State(state): State<AppState>,
|
||||
req: axum::http::Request<axum::body::Body>,
|
||||
) -> Result<Response, AppError> {
|
||||
let method = req.method().clone();
|
||||
let headers = req.headers().clone();
|
||||
let path = req.uri().path().trim_start_matches('/').to_string();
|
||||
|
||||
tracing::debug!(method = %method, %path, "webdav entrypoint" );
|
||||
|
||||
match method {
|
||||
ref m if m == Method::OPTIONS => Ok(handle_options()),
|
||||
ref m if m == Method::GET => handle_get_or_head(&state, &path, headers, Method::GET).await,
|
||||
ref m if m == Method::HEAD => {
|
||||
handle_get_or_head(&state, &path, headers, Method::HEAD).await
|
||||
}
|
||||
_ => {
|
||||
if method.as_str() == "PROPFIND" {
|
||||
handle_propfind(&state, &path, headers).await
|
||||
} else {
|
||||
Ok(method_not_allowed())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_propfind(
|
||||
state: &AppState,
|
||||
path: &str,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Response, AppError> {
|
||||
let mut context = match authenticate(state, &headers)? {
|
||||
Some(user) => user,
|
||||
None => return Ok(unauthorized_response()),
|
||||
};
|
||||
|
||||
let depth = match parse_depth(&headers) {
|
||||
Ok(value) => value,
|
||||
Err(response) => return Ok(response),
|
||||
};
|
||||
|
||||
let segments = parse_segments(path)?;
|
||||
|
||||
let tenant_id = context.tenant_id;
|
||||
|
||||
let resources = if segments.is_empty() {
|
||||
let contents = fetch_folder_contents(&mut context.conn, tenant_id, None)?;
|
||||
build_resources_for_folder(None, &[], &contents, depth)
|
||||
} else {
|
||||
let resolution = match resolve_path(&mut context.conn, tenant_id, &segments)? {
|
||||
Some(resolved) => resolved,
|
||||
None => return Ok(not_found_response()),
|
||||
};
|
||||
|
||||
match resolution {
|
||||
ResolvedPath::Folder { folder, chain } => {
|
||||
let contents =
|
||||
fetch_folder_contents(&mut context.conn, tenant_id, Some(folder.id))?;
|
||||
build_resources_for_folder(Some(&folder), &chain, &contents, depth)
|
||||
}
|
||||
ResolvedPath::Document {
|
||||
document,
|
||||
version,
|
||||
chain,
|
||||
} => build_resources_for_document(&chain, &document, &version),
|
||||
}
|
||||
};
|
||||
|
||||
let body = render_multistatus(&resources).map_err(|err| {
|
||||
tracing::error!(error = ?err, "failed to render WebDAV response");
|
||||
AppError::internal("failed to render WebDAV response")
|
||||
})?;
|
||||
|
||||
let response = Response::builder()
|
||||
.status(multi_status())
|
||||
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
|
||||
.body(Body::from(body))
|
||||
.expect("valid response");
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn handle_get_or_head(
|
||||
state: &AppState,
|
||||
path: &str,
|
||||
headers: HeaderMap,
|
||||
method: Method,
|
||||
) -> Result<Response, AppError> {
|
||||
let mut context = match authenticate(state, &headers)? {
|
||||
Some(user) => user,
|
||||
None => return Ok(unauthorized_response()),
|
||||
};
|
||||
|
||||
let tenant_id = context.tenant_id;
|
||||
let segments = parse_segments(path)?;
|
||||
if segments.is_empty() {
|
||||
return Ok(method_not_allowed());
|
||||
}
|
||||
|
||||
let resolution = match resolve_path(&mut context.conn, tenant_id, &segments)? {
|
||||
Some(resolved) => resolved,
|
||||
None => return Ok(not_found_response()),
|
||||
};
|
||||
|
||||
let (document, version, chain) = match resolution {
|
||||
ResolvedPath::Document {
|
||||
document,
|
||||
version,
|
||||
chain,
|
||||
} => (document, version, chain),
|
||||
_ => return Ok(method_not_allowed()),
|
||||
};
|
||||
|
||||
stream_document(state, &document, &version, &chain, headers, method).await
|
||||
}
|
||||
|
||||
fn handle_options() -> Response {
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("DAV", "1,2")
|
||||
.header(header::ALLOW, "OPTIONS, PROPFIND, GET, HEAD")
|
||||
.header("Accept-Ranges", "bytes")
|
||||
.body(Body::empty())
|
||||
.expect("valid OPTIONS response")
|
||||
}
|
||||
|
||||
fn method_not_allowed() -> Response {
|
||||
Response::builder()
|
||||
.status(StatusCode::METHOD_NOT_ALLOWED)
|
||||
.body(Body::empty())
|
||||
.expect("valid response")
|
||||
}
|
||||
|
||||
fn not_found_response() -> Response {
|
||||
Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(Body::empty())
|
||||
.expect("valid response")
|
||||
}
|
||||
|
||||
fn unauthorized_response() -> Response {
|
||||
Response::builder()
|
||||
.status(StatusCode::UNAUTHORIZED)
|
||||
.header(
|
||||
header::WWW_AUTHENTICATE,
|
||||
format!("Basic realm=\"{REALM}\", charset=\"UTF-8\""),
|
||||
)
|
||||
.body(Body::empty())
|
||||
.expect("valid response")
|
||||
}
|
||||
|
||||
fn multi_status() -> StatusCode {
|
||||
StatusCode::from_u16(207).expect("valid multi-status")
|
||||
}
|
||||
|
||||
fn parse_depth(headers: &HeaderMap) -> Result<u8, Response> {
|
||||
match headers.get("Depth") {
|
||||
None => Ok(1),
|
||||
Some(value) => match value.to_str() {
|
||||
Ok("0") => Ok(0),
|
||||
Ok("1") => Ok(1),
|
||||
Ok("infinity") => Err(Response::builder()
|
||||
.status(StatusCode::FORBIDDEN)
|
||||
.body(Body::empty())
|
||||
.expect("valid response")),
|
||||
_ => Err(Response::builder()
|
||||
.status(StatusCode::BAD_REQUEST)
|
||||
.body(Body::empty())
|
||||
.expect("valid response")),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_segments(path: &str) -> AppResult<Vec<String>> {
|
||||
if path.trim_matches('/').is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let segments = path
|
||||
.split('/')
|
||||
.filter(|segment| !segment.is_empty())
|
||||
.map(|segment| {
|
||||
percent_decode_str(segment)
|
||||
.decode_utf8()
|
||||
.map(|cow| cow.into_owned())
|
||||
.map_err(|_| AppError::bad_request("invalid UTF-8 in path"))
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(segments)
|
||||
}
|
||||
|
||||
fn fetch_folder_contents(
|
||||
conn: &mut PgPooledConnection,
|
||||
tenant_id: Uuid,
|
||||
folder_id: Option<Uuid>,
|
||||
) -> AppResult<WebDavFolderContents> {
|
||||
let folder = match folder_id {
|
||||
Some(id) => Some(
|
||||
folders_dsl::folders
|
||||
.filter(folders_dsl::tenant_id.eq(tenant_id))
|
||||
.find(id)
|
||||
.first::<Folder>(conn)?,
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let subfolders: Vec<Folder> = match folder_id {
|
||||
Some(id) => folders_dsl::folders
|
||||
.filter(folders_dsl::tenant_id.eq(tenant_id))
|
||||
.filter(folders_dsl::parent_id.eq(Some(id)))
|
||||
.order(folders_dsl::name.asc())
|
||||
.load(conn)?,
|
||||
None => folders_dsl::folders
|
||||
.filter(folders_dsl::tenant_id.eq(tenant_id))
|
||||
.filter(folders_dsl::parent_id.is_null())
|
||||
.order(folders_dsl::name.asc())
|
||||
.load(conn)?,
|
||||
};
|
||||
|
||||
let mut docs_query = documents_dsl::documents
|
||||
.filter(documents_dsl::deleted_at.is_null())
|
||||
.filter(documents_dsl::tenant_id.eq(tenant_id))
|
||||
.into_boxed();
|
||||
|
||||
docs_query = match folder_id {
|
||||
Some(id) => docs_query.filter(documents_dsl::folder_id.eq(Some(id))),
|
||||
None => docs_query.filter(documents_dsl::folder_id.is_null()),
|
||||
};
|
||||
|
||||
let documents: Vec<Document> = docs_query
|
||||
.order(documents_dsl::created_at.desc())
|
||||
.load(conn)?;
|
||||
|
||||
let version_ids: Vec<Uuid> = documents.iter().map(|doc| doc.current_version_id).collect();
|
||||
let versions: Vec<DocumentVersion> = if version_ids.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
document_versions_dsl::document_versions
|
||||
.filter(document_versions_dsl::id.eq_any(&version_ids))
|
||||
.load(conn)?
|
||||
};
|
||||
|
||||
let mut version_map = versions
|
||||
.into_iter()
|
||||
.map(|version| (version.id, version))
|
||||
.collect::<std::collections::HashMap<_, _>>();
|
||||
|
||||
let mut entries = Vec::with_capacity(documents.len());
|
||||
for document in documents {
|
||||
if let Some(version) = version_map.remove(&document.current_version_id) {
|
||||
entries.push(DocumentEntry { document, version });
|
||||
}
|
||||
}
|
||||
|
||||
Ok(WebDavFolderContents {
|
||||
_folder: folder,
|
||||
subfolders,
|
||||
documents: entries,
|
||||
})
|
||||
}
|
||||
|
||||
async fn stream_document(
|
||||
state: &AppState,
|
||||
document: &Document,
|
||||
version: &DocumentVersion,
|
||||
_chain: &[String],
|
||||
headers: HeaderMap,
|
||||
method: Method,
|
||||
) -> Result<Response, AppError> {
|
||||
let range_header = headers.get(header::RANGE).cloned();
|
||||
|
||||
let storage = state.storage_for_tenant(document.tenant_id)?;
|
||||
|
||||
let url = storage
|
||||
.presign_get_object(
|
||||
&version.s3_key,
|
||||
Duration::from_secs(DOWNLOAD_URL_TTL_SECONDS),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.storage_context("failed to presign document download")?;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let mut request = client.request(method.clone(), url.clone());
|
||||
|
||||
if let Some(range) = range_header.clone() {
|
||||
request = request.header(header::RANGE, range.clone());
|
||||
}
|
||||
|
||||
let upstream = request.send().await.map_err(|err| {
|
||||
tracing::error!(error = ?err, "failed to fetch document stream");
|
||||
AppError::internal("failed to fetch document stream")
|
||||
})?;
|
||||
|
||||
let status =
|
||||
StatusCode::from_u16(upstream.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
|
||||
|
||||
if !(status.is_success() || status == StatusCode::PARTIAL_CONTENT) {
|
||||
tracing::error!(status = %status, "upstream download returned error status");
|
||||
return Err(AppError::internal("failed to fetch document stream"));
|
||||
}
|
||||
|
||||
let mut builder = Response::builder().status(status);
|
||||
|
||||
if let Some(content_type) = upstream.headers().get(header::CONTENT_TYPE) {
|
||||
builder = builder.header(header::CONTENT_TYPE, content_type);
|
||||
} else if let Some(ref typ) = document.mime_type {
|
||||
builder = builder.header(header::CONTENT_TYPE, typ);
|
||||
}
|
||||
|
||||
if let Some(content_length) = upstream.headers().get(header::CONTENT_LENGTH) {
|
||||
builder = builder.header(header::CONTENT_LENGTH, content_length);
|
||||
}
|
||||
|
||||
if let Some(range) = upstream.headers().get(header::CONTENT_RANGE) {
|
||||
builder = builder.header(header::CONTENT_RANGE, range);
|
||||
}
|
||||
|
||||
builder = builder.header("Accept-Ranges", "bytes");
|
||||
|
||||
if let Some(disposition) = inline_content_disposition(&document.filename) {
|
||||
builder = builder.header(header::CONTENT_DISPOSITION, disposition);
|
||||
}
|
||||
|
||||
builder = builder.header(header::ETAG, format!("\"{}\"", version.id));
|
||||
|
||||
if method == Method::HEAD {
|
||||
return builder.body(Body::empty()).map_err(|err| {
|
||||
tracing::error!(error = ?err, "failed to build WebDAV response");
|
||||
AppError::internal("failed to build WebDAV response")
|
||||
});
|
||||
}
|
||||
|
||||
let stream = upstream
|
||||
.bytes_stream()
|
||||
.map(|chunk| chunk.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err)));
|
||||
let body = Body::from_stream(stream);
|
||||
|
||||
builder.body(body).map_err(|err| {
|
||||
tracing::error!(error = ?err, "failed to build WebDAV response");
|
||||
AppError::internal("failed to build WebDAV response")
|
||||
})
|
||||
}
|
||||
|
||||
fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavContext>, AppError> {
|
||||
tracing::debug!("webdav authenticate invoked");
|
||||
let authorization = match headers.get(header::AUTHORIZATION) {
|
||||
Some(value) => match value.to_str() {
|
||||
Ok(header) if header.starts_with("Basic ") => {
|
||||
tracing::debug!("authorization header present");
|
||||
&header[6..]
|
||||
}
|
||||
Ok(other) => {
|
||||
tracing::warn!(header = %other, "non-basic authorization header");
|
||||
return Ok(None);
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(error = %err, "invalid authorization header");
|
||||
return Ok(None);
|
||||
}
|
||||
},
|
||||
None => {
|
||||
tracing::debug!("no authorization header");
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
|
||||
let decoded = match BASE64.decode(authorization) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(err) => {
|
||||
tracing::warn!(error = %err, "failed to decode basic credentials");
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
|
||||
let credential_str = match String::from_utf8(decoded) {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
tracing::warn!(error = %err, "invalid utf-8 basic credentials");
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
|
||||
let (presented_username, secret) = match credential_str.split_once(':') {
|
||||
Some((username, secret)) if !username.is_empty() => (username, secret),
|
||||
_ => return Ok(None),
|
||||
};
|
||||
|
||||
tracing::debug!(presented_username = %presented_username, "attempting webdav login");
|
||||
let mut conn = state.db_unscoped()?;
|
||||
|
||||
let token = match find_active_token_by_secret(
|
||||
&mut conn,
|
||||
None,
|
||||
secret,
|
||||
Some(ApiCapability::WebdavRead),
|
||||
)? {
|
||||
Some(token) => token,
|
||||
None => {
|
||||
tracing::warn!(presented_username = %presented_username, "webdav token invalid or expired");
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
|
||||
let user: User = match users_dsl::users.find(token.user_id).first(&mut conn) {
|
||||
Ok(user) => user,
|
||||
Err(diesel::result::Error::NotFound) => {
|
||||
tracing::warn!(
|
||||
presented_username = %presented_username,
|
||||
user_id = %token.user_id,
|
||||
"webdav token user missing"
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
Err(err) => return Err(AppError::from(err)),
|
||||
};
|
||||
|
||||
apply_user_guc(&mut conn, user.id)?;
|
||||
|
||||
let membership_exists = memberships_dsl::user_memberships
|
||||
.filter(memberships_dsl::user_id.eq(user.id))
|
||||
.filter(memberships_dsl::tenant_id.eq(token.tenant_id))
|
||||
.select(memberships_dsl::tenant_id)
|
||||
.first::<Uuid>(&mut conn)
|
||||
.optional()?;
|
||||
|
||||
clear_user_guc(&mut conn)?;
|
||||
|
||||
let tenant_id = match membership_exists {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
tracing::warn!(
|
||||
presented_username = %presented_username,
|
||||
username = %user.username,
|
||||
tenant_id = %token.tenant_id,
|
||||
"webdav token tenant membership missing"
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(err) = ensure_active_tenant_with_conn(&mut conn, tenant_id) {
|
||||
tracing::warn!(tenant_id = %tenant_id, error = ?err, "webdav tenant not active");
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
apply_tenant_guc(&mut conn, tenant_id)?;
|
||||
touch_api_token(&mut conn, token.id)?;
|
||||
|
||||
tracing::debug!(
|
||||
presented_username = %presented_username,
|
||||
username = %user.username,
|
||||
tenant_id = %tenant_id,
|
||||
token_id = %token.id,
|
||||
"webdav token login success"
|
||||
);
|
||||
Ok(Some(WebDavContext {
|
||||
tenant_id,
|
||||
_user_id: user.id,
|
||||
_username: user.username,
|
||||
conn,
|
||||
}))
|
||||
}
|
||||
|
||||
fn build_resources_for_folder(
|
||||
folder: Option<&Folder>,
|
||||
chain: &[String],
|
||||
contents: &WebDavFolderContents,
|
||||
depth: u8,
|
||||
) -> Vec<DavResource> {
|
||||
let mut resources = Vec::new();
|
||||
|
||||
let display_name = folder
|
||||
.map(|folder| folder.name.clone())
|
||||
.unwrap_or_else(|| chain.last().cloned().unwrap_or_else(|| "/".to_string()));
|
||||
|
||||
let href = build_href(chain, true);
|
||||
let last_modified = folder.map(|folder| to_http_date(folder.updated_at));
|
||||
|
||||
resources.push(DavResource {
|
||||
href,
|
||||
display_name,
|
||||
is_collection: true,
|
||||
content_length: None,
|
||||
mime_type: None,
|
||||
last_modified,
|
||||
});
|
||||
|
||||
if depth == 0 {
|
||||
return resources;
|
||||
}
|
||||
|
||||
for subfolder in &contents.subfolders {
|
||||
let mut child_chain = chain.to_vec();
|
||||
child_chain.push(subfolder.name.clone());
|
||||
resources.push(DavResource {
|
||||
href: build_href(&child_chain, true),
|
||||
display_name: subfolder.name.clone(),
|
||||
is_collection: true,
|
||||
content_length: None,
|
||||
mime_type: None,
|
||||
last_modified: Some(to_http_date(subfolder.updated_at)),
|
||||
});
|
||||
}
|
||||
|
||||
for entry in &contents.documents {
|
||||
let mut child_chain = chain.to_vec();
|
||||
child_chain.push(entry.document.filename.clone());
|
||||
resources.push(document_to_resource(
|
||||
&child_chain,
|
||||
&entry.document,
|
||||
&entry.version,
|
||||
));
|
||||
}
|
||||
|
||||
resources
|
||||
}
|
||||
|
||||
fn build_resources_for_document(
|
||||
chain: &[String],
|
||||
document: &Document,
|
||||
version: &DocumentVersion,
|
||||
) -> Vec<DavResource> {
|
||||
vec![document_to_resource(chain, document, version)]
|
||||
}
|
||||
|
||||
fn document_to_resource(
|
||||
chain: &[String],
|
||||
document: &Document,
|
||||
version: &DocumentVersion,
|
||||
) -> DavResource {
|
||||
let href = build_href(chain, false);
|
||||
|
||||
DavResource {
|
||||
href,
|
||||
display_name: document.title.clone(),
|
||||
is_collection: false,
|
||||
content_length: Some(version.size_bytes),
|
||||
mime_type: document.mime_type.clone(),
|
||||
last_modified: Some(to_http_date(document.updated_at)),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_href(names: &[String], is_collection: bool) -> String {
|
||||
if names.is_empty() {
|
||||
return "/".to_string();
|
||||
}
|
||||
|
||||
let encoded = names
|
||||
.iter()
|
||||
.map(|name| utf8_percent_encode(name, NON_ALPHANUMERIC).to_string())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut path = format!("/{}", encoded.join("/"));
|
||||
if is_collection && !path.ends_with('/') {
|
||||
path.push('/');
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
fn render_multistatus(resources: &[DavResource]) -> Result<Vec<u8>, quick_xml::Error> {
|
||||
let mut writer = Writer::new(Vec::new());
|
||||
writer.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))?;
|
||||
|
||||
let mut multistatus = BytesStart::new("D:multistatus");
|
||||
multistatus.push_attribute(("xmlns:D", "DAV:"));
|
||||
writer.write_event(Event::Start(multistatus))?;
|
||||
|
||||
for resource in resources {
|
||||
writer.write_event(Event::Start(BytesStart::new("D:response")))?;
|
||||
|
||||
writer.write_event(Event::Start(BytesStart::new("D:href")))?;
|
||||
writer.write_event(Event::Text(BytesText::new(&resource.href)))?;
|
||||
writer.write_event(Event::End(BytesEnd::new("D:href")))?;
|
||||
|
||||
writer.write_event(Event::Start(BytesStart::new("D:propstat")))?;
|
||||
writer.write_event(Event::Start(BytesStart::new("D:prop")))?;
|
||||
|
||||
writer.write_event(Event::Start(BytesStart::new("D:displayname")))?;
|
||||
writer.write_event(Event::Text(BytesText::new(&resource.display_name)))?;
|
||||
writer.write_event(Event::End(BytesEnd::new("D:displayname")))?;
|
||||
|
||||
writer.write_event(Event::Start(BytesStart::new("D:resourcetype")))?;
|
||||
if resource.is_collection {
|
||||
writer.write_event(Event::Empty(BytesStart::new("D:collection")))?;
|
||||
}
|
||||
writer.write_event(Event::End(BytesEnd::new("D:resourcetype")))?;
|
||||
|
||||
if let Some(length) = resource.content_length {
|
||||
writer.write_event(Event::Start(BytesStart::new("D:getcontentlength")))?;
|
||||
writer.write_event(Event::Text(BytesText::new(&length.to_string())))?;
|
||||
writer.write_event(Event::End(BytesEnd::new("D:getcontentlength")))?;
|
||||
}
|
||||
|
||||
if let Some(content_type) = &resource.mime_type {
|
||||
writer.write_event(Event::Start(BytesStart::new("D:getcontenttype")))?;
|
||||
writer.write_event(Event::Text(BytesText::new(content_type)))?;
|
||||
writer.write_event(Event::End(BytesEnd::new("D:getcontenttype")))?;
|
||||
}
|
||||
|
||||
if let Some(last_modified) = &resource.last_modified {
|
||||
writer.write_event(Event::Start(BytesStart::new("D:getlastmodified")))?;
|
||||
writer.write_event(Event::Text(BytesText::new(last_modified)))?;
|
||||
writer.write_event(Event::End(BytesEnd::new("D:getlastmodified")))?;
|
||||
}
|
||||
|
||||
writer.write_event(Event::End(BytesEnd::new("D:prop")))?;
|
||||
|
||||
writer.write_event(Event::Start(BytesStart::new("D:status")))?;
|
||||
writer.write_event(Event::Text(BytesText::new("HTTP/1.1 200 OK")))?;
|
||||
writer.write_event(Event::End(BytesEnd::new("D:status")))?;
|
||||
|
||||
writer.write_event(Event::End(BytesEnd::new("D:propstat")))?;
|
||||
writer.write_event(Event::End(BytesEnd::new("D:response")))?;
|
||||
}
|
||||
|
||||
writer.write_event(Event::End(BytesEnd::new("D:multistatus")))?;
|
||||
Ok(writer.into_inner())
|
||||
}
|
||||
|
||||
struct WebDavFolderContents {
|
||||
_folder: Option<Folder>,
|
||||
subfolders: Vec<Folder>,
|
||||
documents: Vec<DocumentEntry>,
|
||||
}
|
||||
|
||||
struct DocumentEntry {
|
||||
document: Document,
|
||||
version: DocumentVersion,
|
||||
}
|
||||
|
||||
struct DavResource {
|
||||
href: String,
|
||||
display_name: String,
|
||||
is_collection: bool,
|
||||
content_length: Option<i64>,
|
||||
mime_type: Option<String>,
|
||||
last_modified: Option<String>,
|
||||
}
|
||||
enum ResolvedPath {
|
||||
Folder {
|
||||
folder: Folder,
|
||||
chain: Vec<String>,
|
||||
},
|
||||
Document {
|
||||
document: Document,
|
||||
version: DocumentVersion,
|
||||
chain: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
fn resolve_path(
|
||||
conn: &mut PgPooledConnection,
|
||||
tenant_id: Uuid,
|
||||
segments: &[String],
|
||||
) -> AppResult<Option<ResolvedPath>> {
|
||||
let mut parent_id: Option<Uuid> = None;
|
||||
let mut chain: Vec<String> = Vec::new();
|
||||
let mut current_folder: Option<Folder> = None;
|
||||
|
||||
for (index, segment) in segments.iter().enumerate() {
|
||||
let is_last = index == segments.len() - 1;
|
||||
|
||||
if let Some(folder) = find_folder_by_name(conn, tenant_id, parent_id, segment)? {
|
||||
chain.push(folder.name.clone());
|
||||
if is_last {
|
||||
return Ok(Some(ResolvedPath::Folder { folder, chain }));
|
||||
}
|
||||
parent_id = Some(folder.id);
|
||||
current_folder = Some(folder);
|
||||
continue;
|
||||
}
|
||||
|
||||
if is_last {
|
||||
if let Some((document, version)) =
|
||||
find_document_by_filename(conn, tenant_id, parent_id, segment)?
|
||||
{
|
||||
chain.push(document.filename.clone());
|
||||
return Ok(Some(ResolvedPath::Document {
|
||||
document,
|
||||
version,
|
||||
chain,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(uuid) = Uuid::parse_str(segment) {
|
||||
if let Some(folder) = find_folder_by_id(conn, tenant_id, uuid)? {
|
||||
if folder.parent_id != parent_id {
|
||||
return Ok(None);
|
||||
}
|
||||
chain.push(folder.name.clone());
|
||||
if is_last {
|
||||
return Ok(Some(ResolvedPath::Folder { folder, chain }));
|
||||
}
|
||||
parent_id = Some(folder.id);
|
||||
current_folder = Some(folder);
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some((document, version)) = find_document_by_id(conn, tenant_id, uuid)? {
|
||||
if document.folder_id != parent_id {
|
||||
return Ok(None);
|
||||
}
|
||||
chain.push(document.filename.clone());
|
||||
return Ok(Some(ResolvedPath::Document {
|
||||
document,
|
||||
version,
|
||||
chain,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(current_folder.map(|folder| ResolvedPath::Folder { folder, chain }))
|
||||
}
|
||||
|
||||
fn find_folder_by_name(
|
||||
conn: &mut PgConnection,
|
||||
tenant_id: Uuid,
|
||||
parent_id: Option<Uuid>,
|
||||
name: &str,
|
||||
) -> AppResult<Option<Folder>> {
|
||||
let mut query = folders_dsl::folders
|
||||
.filter(folders_dsl::tenant_id.eq(tenant_id))
|
||||
.into_boxed();
|
||||
|
||||
query = match parent_id {
|
||||
Some(parent) => query.filter(folders_dsl::parent_id.eq(Some(parent))),
|
||||
None => query.filter(folders_dsl::parent_id.is_null()),
|
||||
};
|
||||
|
||||
Ok(query
|
||||
.filter(folders_dsl::name.eq(name))
|
||||
.first::<Folder>(conn)
|
||||
.optional()?)
|
||||
}
|
||||
|
||||
fn find_folder_by_id(
|
||||
conn: &mut PgConnection,
|
||||
tenant_id: Uuid,
|
||||
folder_id: Uuid,
|
||||
) -> AppResult<Option<Folder>> {
|
||||
Ok(folders_dsl::folders
|
||||
.filter(folders_dsl::tenant_id.eq(tenant_id))
|
||||
.find(folder_id)
|
||||
.first::<Folder>(conn)
|
||||
.optional()?)
|
||||
}
|
||||
|
||||
fn find_document_by_filename(
|
||||
conn: &mut PgConnection,
|
||||
tenant_id: Uuid,
|
||||
parent_id: Option<Uuid>,
|
||||
filename: &str,
|
||||
) -> AppResult<Option<(Document, DocumentVersion)>> {
|
||||
let mut query = documents_dsl::documents
|
||||
.filter(documents_dsl::deleted_at.is_null())
|
||||
.filter(documents_dsl::tenant_id.eq(tenant_id))
|
||||
.filter(documents_dsl::filename.eq(filename))
|
||||
.into_boxed();
|
||||
|
||||
query = match parent_id {
|
||||
Some(parent) => query.filter(documents_dsl::folder_id.eq(Some(parent))),
|
||||
None => query.filter(documents_dsl::folder_id.is_null()),
|
||||
};
|
||||
|
||||
if let Some(document) = query.first::<Document>(conn).optional()? {
|
||||
let version = document_versions_dsl::document_versions
|
||||
.find(document.current_version_id)
|
||||
.first::<DocumentVersion>(conn)?;
|
||||
return Ok(Some((document, version)));
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn find_document_by_id(
|
||||
conn: &mut PgConnection,
|
||||
tenant_id: Uuid,
|
||||
document_id: Uuid,
|
||||
) -> AppResult<Option<(Document, DocumentVersion)>> {
|
||||
if let Some(document) = documents_dsl::documents
|
||||
.filter(documents_dsl::deleted_at.is_null())
|
||||
.filter(documents_dsl::tenant_id.eq(tenant_id))
|
||||
.find(document_id)
|
||||
.first::<Document>(conn)
|
||||
.optional()?
|
||||
{
|
||||
let version = document_versions_dsl::document_versions
|
||||
.find(document.current_version_id)
|
||||
.first::<DocumentVersion>(conn)?;
|
||||
return Ok(Some((document, version)));
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use s3::{bucket::Bucket, creds::Credentials, region::Region};
|
||||
|
||||
use crate::config::AppConfig;
|
||||
|
||||
pub fn build_bucket(config: &AppConfig) -> Result<Bucket> {
|
||||
let region = if let Some(endpoint) = &config.aws_endpoint_url {
|
||||
Region::Custom {
|
||||
region: config.aws_region.clone(),
|
||||
endpoint: endpoint.clone(),
|
||||
}
|
||||
} else {
|
||||
config
|
||||
.aws_region
|
||||
.parse::<Region>()
|
||||
.context("invalid AWS region")?
|
||||
};
|
||||
|
||||
let credentials = if let (Some(access_key), Some(secret_key)) = (
|
||||
config.aws_access_key_id.as_deref(),
|
||||
config.aws_secret_access_key.as_deref(),
|
||||
) {
|
||||
Credentials::new(Some(access_key), Some(secret_key), None, None, None)
|
||||
.context("failed to create static AWS credentials")?
|
||||
} else {
|
||||
Credentials::default().context("failed to load AWS credentials")?
|
||||
};
|
||||
|
||||
let bucket = Bucket::new(&config.s3_bucket, region, credentials)
|
||||
.map_err(|err| anyhow!("failed to create S3 bucket client: {err}"))?;
|
||||
let bucket = bucket.with_path_style();
|
||||
|
||||
Ok(*bucket)
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
// @generated automatically by Diesel CLI.
|
||||
|
||||
pub mod sql_types {
|
||||
#[derive(diesel::query_builder::QueryId, Clone, diesel::sql_types::SqlType)]
|
||||
#[diesel(postgres_type(name = "magic_token_kind"))]
|
||||
pub struct MagicTokenKind;
|
||||
|
||||
#[derive(diesel::query_builder::QueryId, Clone, diesel::sql_types::SqlType)]
|
||||
#[diesel(postgres_type(name = "tenant_status"))]
|
||||
pub struct TenantStatus;
|
||||
|
||||
#[derive(diesel::query_builder::QueryId, Clone, diesel::sql_types::SqlType)]
|
||||
#[diesel(postgres_type(name = "api_capability"))]
|
||||
pub struct ApiCapability;
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
correspondents (id) {
|
||||
id -> Uuid,
|
||||
#[max_length = 255]
|
||||
name -> Varchar,
|
||||
metadata -> Jsonb,
|
||||
created_at -> Timestamptz,
|
||||
updated_at -> Timestamptz,
|
||||
tenant_id -> Uuid,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
document_assets (id) {
|
||||
id -> Uuid,
|
||||
document_version_id -> Uuid,
|
||||
asset_type -> Text,
|
||||
mime_type -> Text,
|
||||
metadata -> Jsonb,
|
||||
created_at -> Timestamptz,
|
||||
s3_key -> Text,
|
||||
tenant_id -> Uuid,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
document_correspondents (document_id, correspondent_id) {
|
||||
document_id -> Uuid,
|
||||
correspondent_id -> Uuid,
|
||||
assigned_at -> Timestamptz,
|
||||
assigned_by -> Nullable<Uuid>,
|
||||
tenant_id -> Uuid,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
document_tags (document_id, tag_id) {
|
||||
document_id -> Uuid,
|
||||
tag_id -> Uuid,
|
||||
assigned_at -> Timestamptz,
|
||||
assigned_by -> Nullable<Uuid>,
|
||||
tenant_id -> Uuid,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
document_versions (id) {
|
||||
id -> Uuid,
|
||||
document_id -> Uuid,
|
||||
version_number -> Int4,
|
||||
#[max_length = 500]
|
||||
s3_key -> Varchar,
|
||||
size_bytes -> Int8,
|
||||
#[max_length = 64]
|
||||
checksum -> Varchar,
|
||||
created_at -> Timestamptz,
|
||||
metadata -> Jsonb,
|
||||
tenant_id -> Uuid,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
documents (id) {
|
||||
id -> Uuid,
|
||||
#[max_length = 255]
|
||||
filename -> Varchar,
|
||||
#[max_length = 255]
|
||||
original_name -> Varchar,
|
||||
#[max_length = 100]
|
||||
mime_type -> Nullable<Varchar>,
|
||||
folder_id -> Nullable<Uuid>,
|
||||
created_at -> Timestamptz,
|
||||
updated_at -> Timestamptz,
|
||||
deleted_at -> Nullable<Timestamptz>,
|
||||
metadata -> Jsonb,
|
||||
issued_at -> Nullable<Timestamptz>,
|
||||
#[max_length = 255]
|
||||
title -> Varchar,
|
||||
current_version_id -> Uuid,
|
||||
tenant_id -> Uuid,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
folders (id) {
|
||||
id -> Uuid,
|
||||
#[max_length = 255]
|
||||
name -> Varchar,
|
||||
parent_id -> Nullable<Uuid>,
|
||||
created_at -> Timestamptz,
|
||||
updated_at -> Timestamptz,
|
||||
tenant_id -> Uuid,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
jobs (id) {
|
||||
id -> Uuid,
|
||||
job_type -> Text,
|
||||
payload -> Jsonb,
|
||||
status -> Text,
|
||||
attempts -> Int4,
|
||||
run_after -> Timestamptz,
|
||||
last_error -> Nullable<Text>,
|
||||
created_at -> Timestamptz,
|
||||
updated_at -> Timestamptz,
|
||||
tenant_id -> Nullable<Uuid>,
|
||||
result -> Nullable<Jsonb>,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
use diesel::sql_types::*;
|
||||
use super::sql_types::MagicTokenKind;
|
||||
|
||||
magic_tokens (id) {
|
||||
id -> Uuid,
|
||||
user_id -> Uuid,
|
||||
kind -> MagicTokenKind,
|
||||
token_hash -> Varchar,
|
||||
metadata -> Jsonb,
|
||||
expires_at -> Timestamptz,
|
||||
max_uses -> Nullable<Int4>,
|
||||
used_count -> Int4,
|
||||
created_at -> Timestamptz,
|
||||
created_by -> Nullable<Uuid>,
|
||||
last_used_at -> Nullable<Timestamptz>,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
user_sessions (id) {
|
||||
id -> Uuid,
|
||||
user_id -> Uuid,
|
||||
token_hash -> Text,
|
||||
issued_at -> Timestamptz,
|
||||
expires_at -> Timestamptz,
|
||||
revoked_at -> Nullable<Timestamptz>,
|
||||
created_at -> Timestamptz,
|
||||
updated_at -> Timestamptz,
|
||||
tenant_id -> Uuid,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
tags (id) {
|
||||
id -> Uuid,
|
||||
#[max_length = 100]
|
||||
label -> Varchar,
|
||||
#[max_length = 7]
|
||||
color -> Nullable<Varchar>,
|
||||
created_at -> Timestamptz,
|
||||
tenant_id -> Uuid,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
use diesel::sql_types::*;
|
||||
use super::sql_types::TenantStatus;
|
||||
|
||||
tenants (id) {
|
||||
id -> Uuid,
|
||||
name -> Text,
|
||||
storage_root -> Nullable<Text>,
|
||||
quickwit_index -> Nullable<Text>,
|
||||
config -> Jsonb,
|
||||
created_at -> Timestamptz,
|
||||
updated_at -> Timestamptz,
|
||||
status -> TenantStatus,
|
||||
created_by -> Nullable<Uuid>,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
user_memberships (id) {
|
||||
id -> Uuid,
|
||||
user_id -> Uuid,
|
||||
tenant_id -> Uuid,
|
||||
created_at -> Timestamptz,
|
||||
updated_at -> Timestamptz,
|
||||
capability_set_id -> Nullable<Uuid>,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
user_passkeys (id) {
|
||||
id -> Uuid,
|
||||
user_id -> Uuid,
|
||||
credential_id -> Bytea,
|
||||
public_key -> Bytea,
|
||||
credential -> Jsonb,
|
||||
sign_count -> Int8,
|
||||
transports -> Array<Nullable<Text>>,
|
||||
aaguid -> Nullable<Uuid>,
|
||||
nickname -> Nullable<Text>,
|
||||
created_at -> Timestamptz,
|
||||
updated_at -> Timestamptz,
|
||||
last_used_at -> Nullable<Timestamptz>,
|
||||
revoked_at -> Nullable<Timestamptz>,
|
||||
revoked_by -> Nullable<Uuid>,
|
||||
revoked_reason -> Nullable<Text>,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
users (id) {
|
||||
id -> Uuid,
|
||||
#[max_length = 100]
|
||||
username -> Varchar,
|
||||
created_at -> Timestamptz,
|
||||
updated_at -> Timestamptz,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
webauthn_challenges (id) {
|
||||
id -> Uuid,
|
||||
user_id -> Nullable<Uuid>,
|
||||
purpose -> Text,
|
||||
challenge -> Bytea,
|
||||
state -> Bytea,
|
||||
created_at -> Timestamptz,
|
||||
expires_at -> Timestamptz,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
use diesel::sql_types::*;
|
||||
|
||||
capability_sets (id) {
|
||||
id -> Uuid,
|
||||
tenant_id -> Uuid,
|
||||
slug -> Text,
|
||||
cap_version -> Int4,
|
||||
is_system -> Bool,
|
||||
created_at -> Timestamptz,
|
||||
updated_at -> Timestamptz,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
use diesel::sql_types::*;
|
||||
use super::sql_types::ApiCapability;
|
||||
|
||||
capability_set_capabilities (capability_set_id, capability) {
|
||||
capability_set_id -> Uuid,
|
||||
capability -> ApiCapability,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
api_tokens (id) {
|
||||
id -> Uuid,
|
||||
user_id -> Uuid,
|
||||
tenant_id -> Uuid,
|
||||
token_prefix -> Text,
|
||||
token_hash -> Text,
|
||||
label -> Nullable<Text>,
|
||||
created_at -> Timestamptz,
|
||||
last_used_at -> Nullable<Timestamptz>,
|
||||
expires_at -> Nullable<Timestamptz>,
|
||||
revoked_at -> Nullable<Timestamptz>,
|
||||
capability_set_id -> Uuid,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::joinable!(correspondents -> tenants (tenant_id));
|
||||
diesel::joinable!(capability_set_capabilities -> capability_sets (capability_set_id));
|
||||
diesel::joinable!(capability_sets -> tenants (tenant_id));
|
||||
diesel::joinable!(document_assets -> document_versions (document_version_id));
|
||||
diesel::joinable!(document_assets -> tenants (tenant_id));
|
||||
diesel::joinable!(document_correspondents -> correspondents (correspondent_id));
|
||||
diesel::joinable!(document_correspondents -> documents (document_id));
|
||||
diesel::joinable!(document_correspondents -> tenants (tenant_id));
|
||||
diesel::joinable!(document_correspondents -> users (assigned_by));
|
||||
diesel::joinable!(document_tags -> documents (document_id));
|
||||
diesel::joinable!(document_tags -> tags (tag_id));
|
||||
diesel::joinable!(document_tags -> tenants (tenant_id));
|
||||
diesel::joinable!(document_tags -> users (assigned_by));
|
||||
diesel::joinable!(document_versions -> tenants (tenant_id));
|
||||
diesel::joinable!(documents -> folders (folder_id));
|
||||
diesel::joinable!(documents -> tenants (tenant_id));
|
||||
diesel::joinable!(folders -> tenants (tenant_id));
|
||||
diesel::joinable!(jobs -> tenants (tenant_id));
|
||||
diesel::joinable!(magic_tokens -> users (user_id));
|
||||
diesel::joinable!(user_sessions -> tenants (tenant_id));
|
||||
diesel::joinable!(user_sessions -> users (user_id));
|
||||
diesel::joinable!(tags -> tenants (tenant_id));
|
||||
diesel::joinable!(user_memberships -> capability_sets (capability_set_id));
|
||||
diesel::joinable!(user_memberships -> tenants (tenant_id));
|
||||
diesel::joinable!(user_memberships -> users (user_id));
|
||||
diesel::joinable!(user_passkeys -> users (user_id));
|
||||
diesel::joinable!(webauthn_challenges -> users (user_id));
|
||||
diesel::joinable!(api_tokens -> tenants (tenant_id));
|
||||
diesel::joinable!(api_tokens -> capability_sets (capability_set_id));
|
||||
diesel::joinable!(api_tokens -> users (user_id));
|
||||
|
||||
diesel::allow_tables_to_appear_in_same_query!(
|
||||
api_tokens,
|
||||
correspondents,
|
||||
capability_set_capabilities,
|
||||
capability_sets,
|
||||
document_assets,
|
||||
document_correspondents,
|
||||
document_tags,
|
||||
document_versions,
|
||||
documents,
|
||||
folders,
|
||||
jobs,
|
||||
magic_tokens,
|
||||
user_sessions,
|
||||
tags,
|
||||
tenants,
|
||||
user_memberships,
|
||||
user_passkeys,
|
||||
users,
|
||||
webauthn_challenges,
|
||||
);
|
||||
@@ -0,0 +1,884 @@
|
||||
use axum::http::{header::SET_COOKIE, HeaderMap, HeaderValue, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use chrono::{Duration as ChronoDuration, TimeZone, Utc};
|
||||
use diesel::{pg::PgConnection, prelude::*, Connection, OptionalExtension};
|
||||
use rand::{rngs::OsRng, TryRngCore};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
use webauthn_rs::prelude::RegisterPublicKeyCredential;
|
||||
|
||||
use crate::auth::{
|
||||
api_tokens::{find_active_token_by_secret, touch_api_token},
|
||||
capability_sets::load_capability_set,
|
||||
jwt::{AccessTokenContext, PrincipalKind},
|
||||
passkeys::{
|
||||
AuthenticationChallengeResponse, PasskeyLoginFinishPayload,
|
||||
PasskeyRegistrationFinishPayload, PasskeySummary, RegistrationChallengeResponse,
|
||||
},
|
||||
AuthenticatedUser,
|
||||
};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::http::responders::{ok_json, JsonResponse};
|
||||
use crate::models::{
|
||||
MagicToken, MagicTokenKind, NewUser, NewUserSession, TenantStatus, User, UserMembership,
|
||||
UserSession,
|
||||
};
|
||||
use crate::schema::{
|
||||
magic_tokens::dsl as magic_dsl,
|
||||
tenants::dsl as tenant_dsl,
|
||||
user_memberships::dsl as memberships_dsl,
|
||||
user_passkeys::dsl as passkey_dsl,
|
||||
user_sessions::{self, dsl as session_dsl},
|
||||
users::dsl,
|
||||
};
|
||||
use crate::state::{AppState, PgPooledConnection};
|
||||
use crate::tenants::{
|
||||
apply_tenant_guc, apply_user_guc, apply_user_session_hash, clear_user_guc,
|
||||
clear_user_session_hash,
|
||||
};
|
||||
use crate::utils::text::normalize_identifier;
|
||||
|
||||
pub const SESSION_COOKIE_NAME: &str = "refresh_token";
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct LoginRequest {
|
||||
#[serde(default)]
|
||||
pub username: String,
|
||||
#[serde(default)]
|
||||
#[schema(nullable)]
|
||||
pub password: Option<String>,
|
||||
#[serde(default)]
|
||||
#[schema(nullable)]
|
||||
pub magic_token: Option<String>,
|
||||
#[serde(default)]
|
||||
#[schema(nullable)]
|
||||
pub preferred_tenant_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct ApiTokenExchangeRequest {
|
||||
pub api_token: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema, Clone)]
|
||||
pub struct LoginResponse {
|
||||
pub access_token: String,
|
||||
pub token_type: String,
|
||||
pub expires_in: i64,
|
||||
pub tenant: TenantSnippet,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema, Clone)]
|
||||
pub struct TenantSnippet {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema, Clone)]
|
||||
pub struct TenantSelectionResponse {
|
||||
pub access_token: String,
|
||||
pub tenants: Vec<TenantSnippet>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema, Clone)]
|
||||
pub struct TenantListResponse {
|
||||
pub tenants: Vec<TenantSnippet>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct TenantSelectionRequest {
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct SignupStartRequest {
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct SignupStartResponse {
|
||||
pub signup_token: String,
|
||||
pub challenge: RegistrationChallengeResponse,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct SignupFinishRequest {
|
||||
pub signup_token: String,
|
||||
#[schema(value_type = Object)]
|
||||
pub credential: RegisterPublicKeyCredential,
|
||||
#[schema(nullable)]
|
||||
pub nickname: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
#[serde(untagged)]
|
||||
pub enum LoginResponseVariants {
|
||||
Token(LoginResponse),
|
||||
Selection(TenantSelectionResponse),
|
||||
}
|
||||
|
||||
pub struct AuthService<'a> {
|
||||
state: &'a AppState,
|
||||
}
|
||||
|
||||
impl<'a> AuthService<'a> {
|
||||
pub fn new(state: &'a AppState) -> Self {
|
||||
Self { state }
|
||||
}
|
||||
|
||||
pub fn login(&self, payload: LoginRequest) -> AppResult<Response> {
|
||||
let magic_token = payload
|
||||
.magic_token
|
||||
.as_ref()
|
||||
.map(|value| value.trim())
|
||||
.filter(|value| !value.is_empty());
|
||||
|
||||
if magic_token.is_none() {
|
||||
if payload.password.is_some() {
|
||||
return Err(AppError::bad_request(
|
||||
"password authentication is no longer supported",
|
||||
));
|
||||
}
|
||||
|
||||
return Err(AppError::bad_request(
|
||||
"magic_token is required for passwordless login",
|
||||
));
|
||||
}
|
||||
|
||||
let token_value = magic_token.unwrap();
|
||||
|
||||
let mut conn = self.state.db_unscoped()?;
|
||||
let username_hint = payload.username.trim();
|
||||
let preferred_tenant_id = payload.preferred_tenant_id;
|
||||
|
||||
self.magic_token_login(
|
||||
&mut conn,
|
||||
token_value,
|
||||
(!username_hint.is_empty()).then_some(username_hint),
|
||||
preferred_tenant_id,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn exchange_api_token(
|
||||
&self,
|
||||
payload: ApiTokenExchangeRequest,
|
||||
) -> AppResult<JsonResponse<LoginResponse>> {
|
||||
let secret = payload.api_token.trim();
|
||||
if secret.is_empty() {
|
||||
return Err(AppError::bad_request("api_token must not be empty"));
|
||||
}
|
||||
|
||||
let mut conn = self.state.db_unscoped()?;
|
||||
|
||||
let token = find_active_token_by_secret(&mut conn, None, secret, None)?
|
||||
.ok_or_else(AppError::unauthorized)?;
|
||||
|
||||
let user: User = dsl::users.find(token.user_id).first(&mut conn)?;
|
||||
|
||||
apply_user_guc(&mut conn, user.id)?;
|
||||
let membership = memberships_dsl::user_memberships
|
||||
.filter(memberships_dsl::user_id.eq(user.id))
|
||||
.filter(memberships_dsl::tenant_id.eq(token.tenant_id))
|
||||
.first::<UserMembership>(&mut conn)
|
||||
.optional()?;
|
||||
clear_user_guc(&mut conn)?;
|
||||
|
||||
let membership = membership.ok_or_else(AppError::unauthorized)?;
|
||||
|
||||
let membership_capability_set = membership.capability_set_id.ok_or_else(|| {
|
||||
AppError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"membership has no capability set assigned",
|
||||
)
|
||||
})?;
|
||||
|
||||
let token_capability_set = load_capability_set(&mut conn, token.capability_set_id)?;
|
||||
let _membership_set = load_capability_set(&mut conn, membership_capability_set)?;
|
||||
|
||||
apply_tenant_guc(&mut conn, token.tenant_id)?;
|
||||
touch_api_token(&mut conn, token.id)?;
|
||||
|
||||
let access_token = self
|
||||
.state
|
||||
.jwt
|
||||
.generate_token(AccessTokenContext {
|
||||
user_id: user.id,
|
||||
tenant_id: token.tenant_id,
|
||||
username: user.username.clone(),
|
||||
principal_kind: PrincipalKind::ApiToken,
|
||||
principal_id: token.id,
|
||||
capability_set_id: token_capability_set.id,
|
||||
cap_version: token_capability_set.cap_version,
|
||||
})
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
let tenant_name: String = tenant_dsl::tenants
|
||||
.find(token.tenant_id)
|
||||
.select(tenant_dsl::name)
|
||||
.first(&mut conn)
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
ok_json(LoginResponse {
|
||||
access_token,
|
||||
token_type: "Bearer".to_string(),
|
||||
expires_in: self.state.config.jwt_expiry_minutes * 60,
|
||||
tenant: TenantSnippet {
|
||||
id: token.tenant_id,
|
||||
name: tenant_name,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
pub fn signup_start(
|
||||
&self,
|
||||
payload: SignupStartRequest,
|
||||
) -> AppResult<JsonResponse<SignupStartResponse>> {
|
||||
let username = normalize_username(&payload.username)?;
|
||||
|
||||
let mut conn = self.state.db_unscoped()?;
|
||||
let exists: bool = dsl::users
|
||||
.filter(dsl::username.eq(&username))
|
||||
.first::<User>(&mut conn)
|
||||
.optional()?
|
||||
.is_some();
|
||||
if exists {
|
||||
return Err(AppError::conflict("username already exists"));
|
||||
}
|
||||
|
||||
let user_id = Uuid::new_v4();
|
||||
let challenge = self
|
||||
.state
|
||||
.passkeys
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?
|
||||
.start_signup_registration(&mut conn, user_id, username.as_str())?;
|
||||
|
||||
let signup_token = self
|
||||
.state
|
||||
.jwt
|
||||
.generate_signup_token(user_id, challenge.challenge_id, username.clone())
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
ok_json(SignupStartResponse {
|
||||
signup_token,
|
||||
challenge,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn signup_finish(&self, payload: SignupFinishRequest) -> AppResult<Response> {
|
||||
let claims = self
|
||||
.state
|
||||
.jwt
|
||||
.verify_signup_token(&payload.signup_token)
|
||||
.map_err(|_| AppError::unauthorized())?;
|
||||
|
||||
let mut conn = self.state.db_unscoped()?;
|
||||
|
||||
let exists: bool = dsl::users
|
||||
.filter(dsl::username.eq(&claims.username))
|
||||
.first::<User>(&mut conn)
|
||||
.optional()?
|
||||
.is_some();
|
||||
if exists {
|
||||
return Err(AppError::conflict("username already exists"));
|
||||
}
|
||||
|
||||
let service = self
|
||||
.state
|
||||
.passkeys
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
|
||||
|
||||
let prepared_passkey = service.consume_signup_challenge(
|
||||
&mut conn,
|
||||
claims.challenge_id,
|
||||
&payload.credential,
|
||||
)?;
|
||||
|
||||
let state_clone = self.state.clone();
|
||||
let response = conn.transaction::<Response, AppError, _>(|conn| {
|
||||
insert_user(conn, claims.sub, &claims.username)?;
|
||||
|
||||
let tenant = state_clone.tenants.create_tenant_with_conn(
|
||||
conn,
|
||||
&claims.username,
|
||||
None,
|
||||
None,
|
||||
TenantStatus::Creating,
|
||||
&[claims.sub],
|
||||
Some(claims.sub),
|
||||
)?;
|
||||
|
||||
let passkey_insert =
|
||||
prepared_passkey.into_new_user_passkey(claims.sub, payload.nickname.clone());
|
||||
|
||||
diesel::insert_into(passkey_dsl::user_passkeys)
|
||||
.values(&passkey_insert)
|
||||
.execute(conn)
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
let user: User = dsl::users.find(claims.sub).first(conn)?;
|
||||
self.issue_session(conn, &user, tenant.id)
|
||||
})?;
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub fn refresh(&self, refresh_value: &str) -> AppResult<Response> {
|
||||
let hashed = hash_session_token(refresh_value);
|
||||
let mut conn = self.state.db_unscoped()?;
|
||||
let now = Utc::now();
|
||||
let now_naive = now.naive_utc();
|
||||
|
||||
apply_user_session_hash(&mut conn, &hashed)?;
|
||||
let token = match session_dsl::user_sessions
|
||||
.filter(session_dsl::token_hash.eq(&hashed))
|
||||
.filter(session_dsl::revoked_at.is_null())
|
||||
.filter(session_dsl::expires_at.gt(now_naive))
|
||||
.first::<UserSession>(&mut conn)
|
||||
{
|
||||
Ok(token) => token,
|
||||
Err(diesel::result::Error::NotFound) => return Err(AppError::unauthorized()),
|
||||
Err(err) => return Err(AppError::from(err)),
|
||||
};
|
||||
|
||||
clear_user_session_hash(&mut conn)?;
|
||||
apply_tenant_guc(&mut conn, token.tenant_id)?;
|
||||
clear_user_guc(&mut conn)?;
|
||||
|
||||
diesel::update(session_dsl::user_sessions.filter(session_dsl::id.eq(token.id)))
|
||||
.set((
|
||||
session_dsl::revoked_at.eq(now_naive),
|
||||
session_dsl::updated_at.eq(now_naive),
|
||||
))
|
||||
.execute(&mut conn)?;
|
||||
|
||||
let user: User = dsl::users
|
||||
.find(token.user_id)
|
||||
.first(&mut conn)
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
self.issue_session(&mut conn, &user, token.tenant_id)
|
||||
}
|
||||
|
||||
pub fn select_tenant(&self, token: &str, tenant_id: Uuid) -> AppResult<Response> {
|
||||
let user_id = match self.state.jwt.verify_tenant_selector_token(token) {
|
||||
Ok(claims) => claims.sub,
|
||||
Err(_) => self
|
||||
.state
|
||||
.jwt
|
||||
.verify_token(token)
|
||||
.map(|claims| claims.sub)
|
||||
.map_err(|_| AppError::unauthorized())?,
|
||||
};
|
||||
|
||||
let mut conn = self.state.db_unscoped()?;
|
||||
apply_user_guc(&mut conn, user_id)?;
|
||||
|
||||
let membership_exists = memberships_dsl::user_memberships
|
||||
.filter(memberships_dsl::user_id.eq(user_id))
|
||||
.filter(memberships_dsl::tenant_id.eq(tenant_id))
|
||||
.select(memberships_dsl::tenant_id)
|
||||
.first::<Uuid>(&mut conn)
|
||||
.optional()?;
|
||||
|
||||
clear_user_guc(&mut conn)?;
|
||||
|
||||
if membership_exists.is_none() {
|
||||
return Err(AppError::unauthorized());
|
||||
}
|
||||
|
||||
let user: User = dsl::users
|
||||
.find(user_id)
|
||||
.first(&mut conn)
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
self.issue_session(&mut conn, &user, tenant_id)
|
||||
}
|
||||
|
||||
pub fn logout(
|
||||
&self,
|
||||
conn: &mut PgPooledConnection,
|
||||
user: &AuthenticatedUser,
|
||||
refresh_cookie: Option<&str>,
|
||||
) -> AppResult<(HeaderMap, StatusCode)> {
|
||||
let now = Utc::now().naive_utc();
|
||||
|
||||
let revoked = if let Some(value) = refresh_cookie {
|
||||
let hashed = hash_session_token(value);
|
||||
diesel::update(
|
||||
session_dsl::user_sessions
|
||||
.filter(session_dsl::token_hash.eq(hashed))
|
||||
.filter(session_dsl::user_id.eq(user.user_id))
|
||||
.filter(session_dsl::revoked_at.is_null()),
|
||||
)
|
||||
.set((
|
||||
session_dsl::revoked_at.eq(now),
|
||||
session_dsl::updated_at.eq(now),
|
||||
))
|
||||
.execute(conn)?
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
if revoked == 0 {
|
||||
diesel::update(
|
||||
session_dsl::user_sessions
|
||||
.filter(session_dsl::user_id.eq(user.user_id))
|
||||
.filter(session_dsl::tenant_id.eq(user.tenant_id))
|
||||
.filter(session_dsl::revoked_at.is_null()),
|
||||
)
|
||||
.set((
|
||||
session_dsl::revoked_at.eq(now),
|
||||
session_dsl::updated_at.eq(now),
|
||||
))
|
||||
.execute(conn)?;
|
||||
}
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(SET_COOKIE, build_clear_session_cookie(self.state));
|
||||
Ok((headers, StatusCode::NO_CONTENT))
|
||||
}
|
||||
|
||||
pub fn list_tenants(&self, user_id: Uuid) -> AppResult<JsonResponse<TenantListResponse>> {
|
||||
let mut conn = self.state.db_unscoped()?;
|
||||
apply_user_guc(&mut conn, user_id)?;
|
||||
|
||||
let tenant_ids: Vec<Uuid> = memberships_dsl::user_memberships
|
||||
.filter(memberships_dsl::user_id.eq(user_id))
|
||||
.select(memberships_dsl::tenant_id)
|
||||
.load(&mut conn)?;
|
||||
|
||||
clear_user_guc(&mut conn)?;
|
||||
|
||||
let mut tenants = Vec::with_capacity(tenant_ids.len());
|
||||
for tenant_id in tenant_ids {
|
||||
let (name, status): (String, TenantStatus) = tenant_dsl::tenants
|
||||
.find(tenant_id)
|
||||
.select((tenant_dsl::name, tenant_dsl::status))
|
||||
.first(&mut conn)
|
||||
.map_err(AppError::from)?;
|
||||
if status != TenantStatus::Active {
|
||||
continue;
|
||||
}
|
||||
tenants.push(TenantSnippet {
|
||||
id: tenant_id,
|
||||
name,
|
||||
});
|
||||
}
|
||||
|
||||
ok_json(TenantListResponse { tenants })
|
||||
}
|
||||
|
||||
pub fn get_tenant(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
) -> AppResult<JsonResponse<TenantSnippet>> {
|
||||
let mut conn = self.state.db_unscoped()?;
|
||||
apply_user_guc(&mut conn, user_id)?;
|
||||
|
||||
let is_member: bool = diesel::select(diesel::dsl::exists(
|
||||
memberships_dsl::user_memberships
|
||||
.filter(memberships_dsl::user_id.eq(user_id))
|
||||
.filter(memberships_dsl::tenant_id.eq(tenant_id)),
|
||||
))
|
||||
.get_result(&mut conn)?;
|
||||
|
||||
clear_user_guc(&mut conn)?;
|
||||
|
||||
if !is_member {
|
||||
return Err(AppError::not_found());
|
||||
}
|
||||
|
||||
let (name, status): (String, TenantStatus) = tenant_dsl::tenants
|
||||
.find(tenant_id)
|
||||
.select((tenant_dsl::name, tenant_dsl::status))
|
||||
.first(&mut conn)
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
if status != TenantStatus::Active {
|
||||
return Err(AppError::not_found());
|
||||
}
|
||||
|
||||
ok_json(TenantSnippet {
|
||||
id: tenant_id,
|
||||
name,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn passkey_register_start(
|
||||
&self,
|
||||
user: AuthenticatedUser,
|
||||
) -> AppResult<JsonResponse<RegistrationChallengeResponse>> {
|
||||
let service = self
|
||||
.state
|
||||
.passkeys
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
|
||||
|
||||
let mut conn = self.state.db_unscoped()?;
|
||||
let current_user: User = dsl::users.find(user.user_id).first(&mut conn)?;
|
||||
let challenge = service.start_registration(&mut conn, ¤t_user)?;
|
||||
ok_json(challenge)
|
||||
}
|
||||
|
||||
pub fn passkey_register_finish(
|
||||
&self,
|
||||
user: AuthenticatedUser,
|
||||
payload: PasskeyRegistrationFinishPayload,
|
||||
) -> AppResult<JsonResponse<PasskeySummary>> {
|
||||
let service = self
|
||||
.state
|
||||
.passkeys
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
|
||||
|
||||
let mut conn = self.state.db_unscoped()?;
|
||||
let current_user: User = dsl::users.find(user.user_id).first(&mut conn)?;
|
||||
|
||||
let PasskeyRegistrationFinishPayload {
|
||||
challenge_id,
|
||||
credential,
|
||||
nickname,
|
||||
} = payload;
|
||||
|
||||
let passkey = service.finish_registration(
|
||||
&mut conn,
|
||||
¤t_user,
|
||||
challenge_id,
|
||||
credential,
|
||||
nickname,
|
||||
)?;
|
||||
|
||||
ok_json(PasskeySummary::from(passkey))
|
||||
}
|
||||
|
||||
pub fn passkey_login_start(
|
||||
&self,
|
||||
username: &str,
|
||||
) -> AppResult<JsonResponse<AuthenticationChallengeResponse>> {
|
||||
let username = normalize_username(username)?;
|
||||
|
||||
let service = self
|
||||
.state
|
||||
.passkeys
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
|
||||
|
||||
let mut conn = self.state.db_unscoped()?;
|
||||
let user: User = dsl::users
|
||||
.filter(dsl::username.eq(&username))
|
||||
.first(&mut conn)?;
|
||||
|
||||
let challenge = service.start_authentication(&mut conn, &user)?;
|
||||
ok_json(challenge)
|
||||
}
|
||||
|
||||
pub fn passkey_login_finish(&self, payload: PasskeyLoginFinishPayload) -> AppResult<Response> {
|
||||
let service = self
|
||||
.state
|
||||
.passkeys
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
|
||||
|
||||
let mut conn = self.state.db_unscoped()?;
|
||||
let (user, _passkey, auth_result) =
|
||||
service.finish_authentication(&mut conn, payload.challenge_id, payload.credential)?;
|
||||
|
||||
if !auth_result.user_verified() {
|
||||
return Err(AppError::unauthorized());
|
||||
}
|
||||
|
||||
self.complete_login(&mut conn, &user, None)
|
||||
}
|
||||
|
||||
fn complete_login(
|
||||
&self,
|
||||
conn: &mut PgConnection,
|
||||
user: &User,
|
||||
preferred_tenant_id: Option<Uuid>,
|
||||
) -> AppResult<Response> {
|
||||
apply_user_guc(conn, user.id)?;
|
||||
let tenant_ids: Vec<Uuid> = memberships_dsl::user_memberships
|
||||
.filter(memberships_dsl::user_id.eq(user.id))
|
||||
.select(memberships_dsl::tenant_id)
|
||||
.load(conn)?;
|
||||
clear_user_guc(conn)?;
|
||||
|
||||
if tenant_ids.is_empty() {
|
||||
return Err(AppError::unauthorized());
|
||||
}
|
||||
|
||||
let mut active_tenants = Vec::new();
|
||||
for tenant_id in tenant_ids {
|
||||
let (name, status): (String, TenantStatus) = tenant_dsl::tenants
|
||||
.find(tenant_id)
|
||||
.select((tenant_dsl::name, tenant_dsl::status))
|
||||
.first(conn)
|
||||
.map_err(AppError::from)?;
|
||||
if status == TenantStatus::Active {
|
||||
active_tenants.push((tenant_id, name));
|
||||
}
|
||||
}
|
||||
|
||||
if active_tenants.is_empty() {
|
||||
return Err(AppError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"no active tenants available",
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(preferred_id) = preferred_tenant_id {
|
||||
if active_tenants.iter().any(|(id, _)| *id == preferred_id) {
|
||||
return self.issue_session(conn, user, preferred_id);
|
||||
}
|
||||
}
|
||||
|
||||
if active_tenants.len() == 1 {
|
||||
return self.issue_session(conn, user, active_tenants[0].0);
|
||||
}
|
||||
|
||||
let selection_token = self
|
||||
.state
|
||||
.jwt
|
||||
.generate_tenant_selector_token(user.id)
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
let tenants = active_tenants
|
||||
.into_iter()
|
||||
.map(|(id, name)| TenantSnippet { id, name })
|
||||
.collect();
|
||||
|
||||
let response = ok_json(LoginResponseVariants::Selection(TenantSelectionResponse {
|
||||
access_token: selection_token,
|
||||
tenants,
|
||||
}))?;
|
||||
|
||||
Ok(response.into_response())
|
||||
}
|
||||
|
||||
fn magic_token_login(
|
||||
&self,
|
||||
conn: &mut PgConnection,
|
||||
token_value: &str,
|
||||
username_hint: Option<&str>,
|
||||
preferred_tenant_id: Option<Uuid>,
|
||||
) -> AppResult<Response> {
|
||||
if token_value.is_empty() {
|
||||
return Err(AppError::bad_request("magic_token must not be empty"));
|
||||
}
|
||||
|
||||
let token_hash = hash_magic_token(token_value);
|
||||
let now = Utc::now();
|
||||
let now_naive = now.naive_utc();
|
||||
|
||||
conn.transaction::<Response, AppError, _>(|conn| {
|
||||
let magic = magic_dsl::magic_tokens
|
||||
.filter(magic_dsl::token_hash.eq(&token_hash))
|
||||
.filter(magic_dsl::expires_at.gt(now_naive))
|
||||
.first::<MagicToken>(conn)
|
||||
.map_err(|err| match err {
|
||||
diesel::result::Error::NotFound => AppError::unauthorized(),
|
||||
_ => AppError::from(err),
|
||||
})?;
|
||||
|
||||
if let Some(limit) = magic.max_uses {
|
||||
if magic.used_count >= limit {
|
||||
return Err(AppError::unauthorized());
|
||||
}
|
||||
}
|
||||
|
||||
match magic.kind {
|
||||
MagicTokenKind::EmailLogin | MagicTokenKind::DemoLogin => {}
|
||||
}
|
||||
|
||||
let user: User = dsl::users
|
||||
.find(magic.user_id)
|
||||
.first(conn)
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
if let Some(expected) = username_hint {
|
||||
if expected != user.username {
|
||||
return Err(AppError::unauthorized());
|
||||
}
|
||||
}
|
||||
|
||||
diesel::update(magic_dsl::magic_tokens.filter(magic_dsl::id.eq(magic.id)))
|
||||
.set((
|
||||
magic_dsl::used_count.eq(magic.used_count + 1),
|
||||
magic_dsl::last_used_at.eq(Some(now_naive)),
|
||||
))
|
||||
.execute(conn)?;
|
||||
|
||||
self.complete_login(conn, &user, preferred_tenant_id)
|
||||
})
|
||||
}
|
||||
|
||||
fn issue_session(
|
||||
&self,
|
||||
conn: &mut PgConnection,
|
||||
user: &User,
|
||||
tenant_id: Uuid,
|
||||
) -> AppResult<Response> {
|
||||
crate::auth::ensure_active_tenant_with_conn(conn, tenant_id)?;
|
||||
apply_tenant_guc(conn, tenant_id)?;
|
||||
clear_user_guc(conn)?;
|
||||
clear_user_session_hash(conn)?;
|
||||
|
||||
let membership: UserMembership = memberships_dsl::user_memberships
|
||||
.filter(memberships_dsl::user_id.eq(user.id))
|
||||
.filter(memberships_dsl::tenant_id.eq(tenant_id))
|
||||
.first(conn)?;
|
||||
|
||||
let capability_set_id = membership.capability_set_id.ok_or_else(|| {
|
||||
AppError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"membership has no capability set assigned",
|
||||
)
|
||||
})?;
|
||||
|
||||
let capability_set = load_capability_set(conn, capability_set_id)?;
|
||||
|
||||
let now = Utc::now();
|
||||
let session_id = Uuid::new_v4();
|
||||
let access_token = self
|
||||
.state
|
||||
.jwt
|
||||
.generate_token(AccessTokenContext {
|
||||
user_id: user.id,
|
||||
tenant_id,
|
||||
username: user.username.clone(),
|
||||
principal_kind: PrincipalKind::UserSession,
|
||||
principal_id: session_id,
|
||||
capability_set_id,
|
||||
cap_version: capability_set.cap_version,
|
||||
})
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
let tenant_name: String = tenant_dsl::tenants
|
||||
.find(tenant_id)
|
||||
.select(tenant_dsl::name)
|
||||
.first(conn)
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
let session_value = generate_session_token();
|
||||
let session_hash = hash_session_token(&session_value);
|
||||
let refresh_expires_at =
|
||||
now + ChronoDuration::days(self.state.config.refresh_token_expiry_days);
|
||||
|
||||
let new_session = NewUserSession {
|
||||
id: session_id,
|
||||
user_id: user.id,
|
||||
token_hash: session_hash,
|
||||
issued_at: now.naive_utc(),
|
||||
expires_at: refresh_expires_at.naive_utc(),
|
||||
tenant_id,
|
||||
};
|
||||
|
||||
diesel::insert_into(user_sessions::table)
|
||||
.values(&new_session)
|
||||
.execute(conn)?;
|
||||
|
||||
let json = ok_json(LoginResponseVariants::Token(LoginResponse {
|
||||
access_token,
|
||||
token_type: "Bearer".to_string(),
|
||||
expires_in: self.state.config.jwt_expiry_minutes * 60,
|
||||
tenant: TenantSnippet {
|
||||
id: tenant_id,
|
||||
name: tenant_name,
|
||||
},
|
||||
}))?;
|
||||
|
||||
let mut response = json.into_response();
|
||||
|
||||
response.headers_mut().insert(
|
||||
SET_COOKIE,
|
||||
build_session_cookie(self.state, &session_value, refresh_expires_at),
|
||||
);
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_user(conn: &mut PgConnection, id: Uuid, username: &str) -> AppResult<()> {
|
||||
let new_user = NewUser {
|
||||
id,
|
||||
username: username.to_string(),
|
||||
};
|
||||
|
||||
diesel::insert_into(dsl::users)
|
||||
.values(&new_user)
|
||||
.execute(conn)
|
||||
.map(|_| ())
|
||||
.map_err(AppError::from)
|
||||
}
|
||||
|
||||
fn hash_session_token(token: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(token.as_bytes());
|
||||
hex::encode(hasher.finalize())
|
||||
}
|
||||
|
||||
fn hash_magic_token(token: &str) -> String {
|
||||
hash_session_token(token)
|
||||
}
|
||||
|
||||
fn generate_session_token() -> String {
|
||||
let mut bytes = [0u8; 32];
|
||||
OsRng
|
||||
.try_fill_bytes(&mut bytes)
|
||||
.expect("failed to read random bytes");
|
||||
hex::encode(bytes)
|
||||
}
|
||||
|
||||
fn build_cookie(
|
||||
state: &AppState,
|
||||
token: Option<&str>,
|
||||
expires_at: Option<chrono::DateTime<Utc>>,
|
||||
max_age: i64,
|
||||
) -> HeaderValue {
|
||||
let mut parts = vec![format!("{}={}", SESSION_COOKIE_NAME, token.unwrap_or(""))];
|
||||
parts.push("Path=/".into());
|
||||
parts.push("HttpOnly".into());
|
||||
parts.push("SameSite=Strict".into());
|
||||
parts.push(format!("Max-Age={}", max_age));
|
||||
if let Some(expires) = expires_at {
|
||||
parts.push(format!("Expires={}", expires.to_rfc2822()));
|
||||
}
|
||||
if state.config.refresh_cookie_secure {
|
||||
parts.push("Secure".into());
|
||||
}
|
||||
if let Some(domain) = &state.config.refresh_cookie_domain {
|
||||
parts.push(format!("Domain={}", domain));
|
||||
}
|
||||
|
||||
HeaderValue::from_str(&parts.join("; ")).expect("valid session cookie")
|
||||
}
|
||||
|
||||
fn build_session_cookie(
|
||||
state: &AppState,
|
||||
token: &str,
|
||||
expires_at: chrono::DateTime<Utc>,
|
||||
) -> HeaderValue {
|
||||
let max_age = ChronoDuration::days(state.config.refresh_token_expiry_days).num_seconds();
|
||||
build_cookie(state, Some(token), Some(expires_at), max_age)
|
||||
}
|
||||
|
||||
fn build_clear_session_cookie(state: &AppState) -> HeaderValue {
|
||||
let epoch = Utc.timestamp_opt(0, 0).single().unwrap();
|
||||
build_cookie(state, None, Some(epoch), 0)
|
||||
}
|
||||
|
||||
fn normalize_username(value: &str) -> AppResult<String> {
|
||||
normalize_identifier(
|
||||
value,
|
||||
100,
|
||||
"username must not be empty",
|
||||
"username must not exceed 100 characters",
|
||||
Some("username may only contain printable characters"),
|
||||
|ch| !ch.is_control(),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
use axum::http::StatusCode;
|
||||
use chrono::Utc;
|
||||
use diesel::{pg::PgConnection, prelude::*, Connection, OptionalExtension};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
auth::capability_sets::{
|
||||
compute_slug, create_capability_set as create_capability_set_record, is_system_slug,
|
||||
load_capabilities_for_set, normalize_capabilities, refresh_capability_set,
|
||||
},
|
||||
error::{AppError, AppResult},
|
||||
http::responders::{
|
||||
created_json, no_content, ok_json, IntoAppResult, JsonResponse, RowsAffectedExt,
|
||||
},
|
||||
models::{ApiCapability, CapabilitySet},
|
||||
schema::{
|
||||
api_tokens,
|
||||
capability_sets::{self, dsl as cs_dsl},
|
||||
user_memberships,
|
||||
},
|
||||
utils::text::normalize_identifier,
|
||||
};
|
||||
|
||||
#[derive(Serialize, utoipa::ToSchema)]
|
||||
pub struct CapabilitySetResponse {
|
||||
pub id: Uuid,
|
||||
pub slug: String,
|
||||
pub is_system: bool,
|
||||
pub cap_version: i32,
|
||||
pub capabilities: Vec<ApiCapability>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, utoipa::ToSchema)]
|
||||
pub struct CreateCapabilitySetRequest {
|
||||
#[serde(default)]
|
||||
#[serde(rename = "slug")]
|
||||
pub slug: Option<String>,
|
||||
pub capabilities: Vec<ApiCapability>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, utoipa::ToSchema)]
|
||||
pub struct UpdateCapabilitySetRequest {
|
||||
#[serde(default)]
|
||||
#[serde(rename = "slug")]
|
||||
pub slug: Option<String>,
|
||||
#[serde(default)]
|
||||
pub capabilities: Option<Vec<ApiCapability>>,
|
||||
}
|
||||
|
||||
pub struct CapabilitySetService;
|
||||
|
||||
impl CapabilitySetService {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
pub fn list(
|
||||
&self,
|
||||
conn: &mut PgConnection,
|
||||
tenant_id: Uuid,
|
||||
) -> AppResult<JsonResponse<Vec<CapabilitySetResponse>>> {
|
||||
let sets = cs_dsl::capability_sets
|
||||
.filter(cs_dsl::tenant_id.eq(tenant_id))
|
||||
.order(cs_dsl::slug.asc())
|
||||
.load::<CapabilitySet>(conn)?;
|
||||
|
||||
let mut responses = Vec::with_capacity(sets.len());
|
||||
for set in sets {
|
||||
let capabilities = load_capabilities_for_set(conn, set.id)?;
|
||||
responses.push(to_response(set, capabilities));
|
||||
}
|
||||
|
||||
ok_json(responses)
|
||||
}
|
||||
|
||||
pub fn list_capabilities(&self) -> AppResult<JsonResponse<Vec<ApiCapability>>> {
|
||||
let capabilities = ApiCapability::variants()
|
||||
.iter()
|
||||
.map(|value| value.parse::<ApiCapability>().expect("valid capability"))
|
||||
.collect();
|
||||
ok_json(capabilities)
|
||||
}
|
||||
|
||||
pub fn get(
|
||||
&self,
|
||||
conn: &mut PgConnection,
|
||||
tenant_id: Uuid,
|
||||
id: Uuid,
|
||||
) -> AppResult<JsonResponse<CapabilitySetResponse>> {
|
||||
let set = cs_dsl::capability_sets
|
||||
.filter(cs_dsl::tenant_id.eq(tenant_id))
|
||||
.find(id)
|
||||
.first::<CapabilitySet>(conn)
|
||||
.into_app_result()?;
|
||||
|
||||
let capabilities = load_capabilities_for_set(conn, set.id)?;
|
||||
ok_json(to_response(set, capabilities))
|
||||
}
|
||||
|
||||
pub fn create(
|
||||
&self,
|
||||
conn: &mut PgConnection,
|
||||
tenant_id: Uuid,
|
||||
payload: CreateCapabilitySetRequest,
|
||||
) -> AppResult<JsonResponse<CapabilitySetResponse>> {
|
||||
let original_caps = payload.capabilities;
|
||||
let normalized_caps = normalize_capabilities(original_caps.clone())?;
|
||||
if normalized_caps.is_empty() {
|
||||
return Err(AppError::bad_request("at least one capability is required"));
|
||||
}
|
||||
|
||||
let slug = if let Some(raw) = payload.slug {
|
||||
let normalized = normalize_slug(&raw)?;
|
||||
if is_system_slug(&normalized) {
|
||||
return Err(AppError::conflict("slug is reserved"));
|
||||
}
|
||||
normalized
|
||||
} else {
|
||||
let generated = compute_slug(&normalized_caps);
|
||||
if is_system_slug(&generated) {
|
||||
return Err(AppError::conflict(
|
||||
"capabilities match a reserved system capability set",
|
||||
));
|
||||
}
|
||||
generated
|
||||
};
|
||||
|
||||
let set = create_capability_set_record(conn, tenant_id, &slug, original_caps)?;
|
||||
let response = to_response(set, normalized_caps);
|
||||
|
||||
created_json(response)
|
||||
}
|
||||
|
||||
pub fn update(
|
||||
&self,
|
||||
conn: &mut PgConnection,
|
||||
tenant_id: Uuid,
|
||||
id: Uuid,
|
||||
payload: UpdateCapabilitySetRequest,
|
||||
) -> AppResult<JsonResponse<CapabilitySetResponse>> {
|
||||
let set = cs_dsl::capability_sets
|
||||
.filter(cs_dsl::tenant_id.eq(tenant_id))
|
||||
.find(id)
|
||||
.first::<CapabilitySet>(conn)
|
||||
.into_app_result()?;
|
||||
|
||||
if set.is_system {
|
||||
if payload.slug.is_some() || payload.capabilities.is_some() {
|
||||
return Err(AppError::conflict(
|
||||
"system capability sets cannot be modified",
|
||||
));
|
||||
}
|
||||
let capabilities = load_capabilities_for_set(conn, set.id)?;
|
||||
return ok_json(to_response(set, capabilities));
|
||||
}
|
||||
|
||||
let set = conn.transaction::<CapabilitySet, AppError, _>(|conn| {
|
||||
let mut working = set.clone();
|
||||
|
||||
if let Some(slug) = &payload.slug {
|
||||
let normalized = normalize_slug(slug)?;
|
||||
if is_system_slug(&normalized) {
|
||||
return Err(AppError::conflict("slug is reserved"));
|
||||
}
|
||||
|
||||
if cs_dsl::capability_sets
|
||||
.filter(cs_dsl::tenant_id.eq(tenant_id))
|
||||
.filter(cs_dsl::slug.eq(&normalized))
|
||||
.filter(cs_dsl::id.ne(working.id))
|
||||
.first::<CapabilitySet>(conn)
|
||||
.optional()
|
||||
.into_app_result()?
|
||||
.is_some()
|
||||
{
|
||||
return Err(AppError::conflict("slug already exists"));
|
||||
}
|
||||
|
||||
diesel::update(cs_dsl::capability_sets.find(working.id))
|
||||
.set((
|
||||
cs_dsl::slug.eq(&normalized),
|
||||
cs_dsl::updated_at.eq(Utc::now().naive_utc()),
|
||||
))
|
||||
.execute(conn)
|
||||
.into_app_result()?;
|
||||
|
||||
working.slug = normalized;
|
||||
}
|
||||
|
||||
if let Some(capabilities) = &payload.capabilities {
|
||||
let normalized = normalize_capabilities(capabilities.clone())?;
|
||||
if normalized.is_empty() {
|
||||
return Err(AppError::bad_request("at least one capability is required"));
|
||||
}
|
||||
|
||||
let updated = refresh_capability_set(conn, &working, &normalized)?;
|
||||
working = updated;
|
||||
}
|
||||
|
||||
capability_sets::table
|
||||
.find(working.id)
|
||||
.first::<CapabilitySet>(conn)
|
||||
.into_app_result()
|
||||
})?;
|
||||
|
||||
let capabilities = load_capabilities_for_set(conn, set.id)?;
|
||||
ok_json(to_response(set, capabilities))
|
||||
}
|
||||
|
||||
pub fn delete(
|
||||
&self,
|
||||
conn: &mut PgConnection,
|
||||
tenant_id: Uuid,
|
||||
id: Uuid,
|
||||
) -> AppResult<StatusCode> {
|
||||
let set = cs_dsl::capability_sets
|
||||
.filter(cs_dsl::tenant_id.eq(tenant_id))
|
||||
.find(id)
|
||||
.first::<CapabilitySet>(conn)
|
||||
.into_app_result()?;
|
||||
|
||||
if set.is_system {
|
||||
return Err(AppError::conflict(
|
||||
"system capability sets cannot be deleted",
|
||||
));
|
||||
}
|
||||
|
||||
let in_use_memberships: i64 = user_memberships::table
|
||||
.filter(user_memberships::capability_set_id.eq(Some(set.id)))
|
||||
.count()
|
||||
.get_result(conn)?;
|
||||
|
||||
if in_use_memberships > 0 {
|
||||
return Err(AppError::conflict(
|
||||
"capability set is assigned to user memberships",
|
||||
));
|
||||
}
|
||||
|
||||
let in_use_tokens: i64 = api_tokens::table
|
||||
.filter(api_tokens::capability_set_id.eq(set.id))
|
||||
.count()
|
||||
.get_result(conn)?;
|
||||
|
||||
if in_use_tokens > 0 {
|
||||
return Err(AppError::conflict(
|
||||
"capability set is assigned to API tokens",
|
||||
));
|
||||
}
|
||||
|
||||
diesel::delete(cs_dsl::capability_sets.find(set.id))
|
||||
.execute(conn)
|
||||
.into_app_result()?
|
||||
.or_not_found()?;
|
||||
|
||||
no_content()
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_slug(value: &str) -> AppResult<String> {
|
||||
let base = normalize_identifier(
|
||||
value,
|
||||
64,
|
||||
"slug must not be empty",
|
||||
"slug must not exceed 64 characters",
|
||||
Some("slug may only contain alphanumeric characters, hyphen, underscore, or whitespace"),
|
||||
|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch.is_whitespace(),
|
||||
)?;
|
||||
|
||||
let mut normalized = String::with_capacity(base.len());
|
||||
for ch in base.chars() {
|
||||
if ch.is_whitespace() {
|
||||
normalized.push('-');
|
||||
} else {
|
||||
normalized.push(ch.to_ascii_lowercase());
|
||||
}
|
||||
}
|
||||
|
||||
if normalized.is_empty() {
|
||||
return Err(AppError::bad_request("slug must not be empty"));
|
||||
}
|
||||
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
fn to_response(set: CapabilitySet, capabilities: Vec<ApiCapability>) -> CapabilitySetResponse {
|
||||
CapabilitySetResponse {
|
||||
id: set.id,
|
||||
slug: set.slug,
|
||||
is_system: set.is_system,
|
||||
cap_version: set.cap_version,
|
||||
capabilities,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
use chrono::Utc;
|
||||
use diesel::{dsl::not, prelude::*, Connection};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::documents::correspondents::{
|
||||
insert_document_correspondents, normalize_correspondent_ids,
|
||||
};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::schema::{document_correspondents, documents};
|
||||
use crate::services::helpers::load_active_document;
|
||||
use crate::state::{AppState, PgPooledConnection};
|
||||
use crate::utils::db::validate_bulk_ids;
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct BulkCorrespondentResponse {
|
||||
pub assigned: usize,
|
||||
pub removed: usize,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct CorrespondentAssignmentInput {
|
||||
pub correspondent_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct AssignCorrespondentsRequest {
|
||||
pub assignments: Vec<CorrespondentAssignmentInput>,
|
||||
#[serde(default)]
|
||||
#[schema(default = false)]
|
||||
pub replace: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Copy, Clone, PartialEq, Eq, ToSchema)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum BulkCorrespondentAction {
|
||||
Add,
|
||||
Remove,
|
||||
}
|
||||
|
||||
fn default_bulk_correspondent_action() -> BulkCorrespondentAction {
|
||||
BulkCorrespondentAction::Add
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct BulkCorrespondentsRequest {
|
||||
pub document_ids: Vec<Uuid>,
|
||||
pub assignments: Vec<CorrespondentAssignmentInput>,
|
||||
#[serde(default = "default_bulk_correspondent_action")]
|
||||
pub action: BulkCorrespondentAction,
|
||||
}
|
||||
|
||||
pub struct CorrespondentsService<'a> {
|
||||
_state: &'a AppState,
|
||||
}
|
||||
|
||||
impl<'a> CorrespondentsService<'a> {
|
||||
pub fn new(state: &'a AppState) -> Self {
|
||||
Self { _state: state }
|
||||
}
|
||||
|
||||
pub fn assign_to_document(
|
||||
&self,
|
||||
conn: &mut PgPooledConnection,
|
||||
tenant_id: Uuid,
|
||||
user_id: Uuid,
|
||||
document_id: Uuid,
|
||||
request: &AssignCorrespondentsRequest,
|
||||
) -> AppResult<()> {
|
||||
if request.assignments.is_empty() {
|
||||
return Err(AppError::bad_request("assignments must not be empty"));
|
||||
}
|
||||
|
||||
let raw_ids: Vec<Uuid> = request
|
||||
.assignments
|
||||
.iter()
|
||||
.map(|assignment| assignment.correspondent_id)
|
||||
.collect();
|
||||
let correspondent_ids = normalize_correspondent_ids(&raw_ids)?;
|
||||
|
||||
conn.transaction::<_, AppError, _>(|conn| {
|
||||
let document = load_active_document(conn, tenant_id, document_id)?;
|
||||
|
||||
let mut updated = false;
|
||||
if request.replace {
|
||||
let base = document_correspondents::table
|
||||
.filter(document_correspondents::document_id.eq(document_id))
|
||||
.filter(document_correspondents::tenant_id.eq(tenant_id));
|
||||
|
||||
let removed = if correspondent_ids.is_empty() {
|
||||
diesel::delete(base).execute(conn)?
|
||||
} else {
|
||||
diesel::delete(base.filter(not(
|
||||
document_correspondents::correspondent_id.eq_any(&correspondent_ids),
|
||||
)))
|
||||
.execute(conn)?
|
||||
};
|
||||
|
||||
if removed > 0 {
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
let inserted = insert_document_correspondents(
|
||||
conn,
|
||||
tenant_id,
|
||||
document.id,
|
||||
user_id,
|
||||
&correspondent_ids,
|
||||
)?;
|
||||
|
||||
if inserted > 0 {
|
||||
updated = true;
|
||||
}
|
||||
|
||||
if updated && inserted == 0 {
|
||||
diesel::update(
|
||||
documents::table
|
||||
.find(document_id)
|
||||
.filter(documents::tenant_id.eq(tenant_id)),
|
||||
)
|
||||
.set(documents::updated_at.eq(Utc::now().naive_utc()))
|
||||
.execute(conn)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn bulk_update(
|
||||
&self,
|
||||
conn: &mut PgPooledConnection,
|
||||
tenant_id: Uuid,
|
||||
user_id: Uuid,
|
||||
mut payload: BulkCorrespondentsRequest,
|
||||
) -> AppResult<BulkCorrespondentResponse> {
|
||||
if payload.assignments.is_empty() {
|
||||
return Err(AppError::bad_request("assignments must not be empty"));
|
||||
}
|
||||
|
||||
validate_bulk_ids(&mut payload.document_ids, "document_ids")?;
|
||||
|
||||
let raw_ids: Vec<Uuid> = payload
|
||||
.assignments
|
||||
.iter()
|
||||
.map(|assignment| assignment.correspondent_id)
|
||||
.collect();
|
||||
let correspondent_ids = normalize_correspondent_ids(&raw_ids)?;
|
||||
|
||||
let action = payload.action;
|
||||
|
||||
conn.transaction::<_, AppError, _>(|conn| {
|
||||
let docs: Vec<(Uuid, Option<chrono::NaiveDateTime>)> = documents::table
|
||||
.filter(documents::id.eq_any(&payload.document_ids))
|
||||
.filter(documents::tenant_id.eq(tenant_id))
|
||||
.select((documents::id, documents::deleted_at))
|
||||
.load(conn)?;
|
||||
|
||||
if docs.len() != payload.document_ids.len() {
|
||||
return Err(AppError::bad_request(
|
||||
"one or more documents do not exist or are inaccessible",
|
||||
));
|
||||
}
|
||||
|
||||
if docs.iter().any(|(_, deleted)| deleted.is_some()) {
|
||||
return Err(AppError::bad_request(
|
||||
"cannot assign correspondents to deleted documents",
|
||||
));
|
||||
}
|
||||
|
||||
match action {
|
||||
BulkCorrespondentAction::Add => {
|
||||
let mut assigned_total = 0;
|
||||
for (doc_id, _) in &docs {
|
||||
assigned_total += insert_document_correspondents(
|
||||
conn,
|
||||
tenant_id,
|
||||
*doc_id,
|
||||
user_id,
|
||||
&correspondent_ids,
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(BulkCorrespondentResponse {
|
||||
assigned: assigned_total,
|
||||
removed: 0,
|
||||
})
|
||||
}
|
||||
BulkCorrespondentAction::Remove => {
|
||||
if correspondent_ids.is_empty() {
|
||||
return Ok(BulkCorrespondentResponse {
|
||||
assigned: 0,
|
||||
removed: 0,
|
||||
});
|
||||
}
|
||||
|
||||
let removed = diesel::delete(
|
||||
document_correspondents::table
|
||||
.filter(
|
||||
document_correspondents::document_id.eq_any(&payload.document_ids),
|
||||
)
|
||||
.filter(document_correspondents::tenant_id.eq(tenant_id))
|
||||
.filter(
|
||||
document_correspondents::correspondent_id
|
||||
.eq_any(&correspondent_ids),
|
||||
),
|
||||
)
|
||||
.execute(conn)?;
|
||||
|
||||
if removed > 0 {
|
||||
diesel::update(
|
||||
documents::table
|
||||
.filter(documents::id.eq_any(&payload.document_ids))
|
||||
.filter(documents::tenant_id.eq(tenant_id)),
|
||||
)
|
||||
.set(documents::updated_at.eq(Utc::now().naive_utc()))
|
||||
.execute(conn)?;
|
||||
}
|
||||
|
||||
Ok(BulkCorrespondentResponse {
|
||||
assigned: 0,
|
||||
removed,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn remove_from_document(
|
||||
&self,
|
||||
conn: &mut PgPooledConnection,
|
||||
tenant_id: Uuid,
|
||||
document_id: Uuid,
|
||||
correspondent_id: Uuid,
|
||||
) -> AppResult<()> {
|
||||
load_active_document(conn, tenant_id, document_id)?;
|
||||
|
||||
let deleted = diesel::delete(
|
||||
document_correspondents::table
|
||||
.filter(document_correspondents::document_id.eq(document_id))
|
||||
.filter(document_correspondents::tenant_id.eq(tenant_id))
|
||||
.filter(document_correspondents::correspondent_id.eq(correspondent_id)),
|
||||
)
|
||||
.execute(conn)?;
|
||||
|
||||
if deleted == 0 {
|
||||
return Err(AppError::not_found());
|
||||
}
|
||||
|
||||
diesel::update(
|
||||
documents::table
|
||||
.find(document_id)
|
||||
.filter(documents::tenant_id.eq(tenant_id)),
|
||||
)
|
||||
.set(documents::updated_at.eq(Utc::now().naive_utc()))
|
||||
.execute(conn)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,635 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use diesel::pg::PgConnection;
|
||||
use diesel::{
|
||||
dsl::{exists, sql},
|
||||
prelude::*,
|
||||
sql_types::Text,
|
||||
Connection,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::documents::ordering::{ordering_clauses, DocumentSortField, SortDirection};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::http::responders::{IntoAppResult, RowsAffectedExt};
|
||||
use crate::models::{Document, Folder, NewFolder};
|
||||
use crate::schema::{documents, folders};
|
||||
use crate::services::documents::{DocumentResponse, DocumentsService};
|
||||
use crate::state::{AppState, PgPooledConnection};
|
||||
use crate::utils::{json::deserialize_patch_field, text::normalize_identifier, time::to_iso};
|
||||
|
||||
const MAX_FOLDER_NAME_LEN: usize = 255;
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct CreateFolderRequest {
|
||||
pub name: String,
|
||||
#[schema(nullable)]
|
||||
pub parent_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct EnsureFolderPathRequest {
|
||||
#[schema(nullable)]
|
||||
pub parent_id: Option<Uuid>,
|
||||
pub segments: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema, Clone, Debug)]
|
||||
pub struct FolderInfo {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
#[schema(nullable)]
|
||||
pub parent_id: Option<Uuid>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, ToSchema)]
|
||||
pub struct FolderTreeNode {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
#[schema(nullable)]
|
||||
pub parent_id: Option<Uuid>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
#[serde(default)]
|
||||
pub children: Vec<FolderTreeNode>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, IntoParams, ToSchema)]
|
||||
#[into_params(parameter_in = Query)]
|
||||
pub struct FolderContentsQuery {
|
||||
#[serde(default = "default_include_documents")]
|
||||
#[schema(default = true)]
|
||||
pub include_documents: bool,
|
||||
#[serde(default)]
|
||||
#[schema(default = "title")]
|
||||
pub sort: DocumentSortField,
|
||||
#[serde(default)]
|
||||
#[schema(default = "asc")]
|
||||
pub dir: SortDirection,
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize, ToSchema)]
|
||||
pub struct UpdateFolderRequest {
|
||||
#[serde(default, deserialize_with = "deserialize_patch_field")]
|
||||
#[schema(nullable, value_type = Option<Uuid>)]
|
||||
pub parent_id: Option<Option<Uuid>>,
|
||||
#[serde(default, deserialize_with = "deserialize_patch_field")]
|
||||
#[schema(nullable)]
|
||||
pub name: Option<Option<String>>,
|
||||
}
|
||||
|
||||
pub struct FolderContentsData {
|
||||
pub folder: Option<FolderInfo>,
|
||||
pub subfolders: Vec<FolderInfo>,
|
||||
pub documents: Vec<Document>,
|
||||
}
|
||||
|
||||
pub struct FolderService<'a> {
|
||||
state: &'a AppState,
|
||||
}
|
||||
|
||||
impl<'a> FolderService<'a> {
|
||||
pub fn new(state: &'a AppState) -> Self {
|
||||
Self { state }
|
||||
}
|
||||
|
||||
pub fn get_folder(
|
||||
&self,
|
||||
conn: &mut PgPooledConnection,
|
||||
tenant_id: Uuid,
|
||||
folder_id: Uuid,
|
||||
) -> AppResult<FolderInfo> {
|
||||
let folder: Folder = folders::table
|
||||
.find(folder_id)
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.first(conn)?;
|
||||
Ok(folder_to_info(folder))
|
||||
}
|
||||
|
||||
pub fn ensure_folder_path(
|
||||
&self,
|
||||
conn: &mut PgPooledConnection,
|
||||
tenant_id: Uuid,
|
||||
payload: EnsureFolderPathRequest,
|
||||
) -> AppResult<FolderInfo> {
|
||||
if payload.segments.is_empty() {
|
||||
return Err(AppError::bad_request("segments must not be empty"));
|
||||
}
|
||||
|
||||
let folder = conn.transaction::<Folder, AppError, _>(|conn| {
|
||||
let mut current_parent = payload.parent_id;
|
||||
let mut last_folder: Option<Folder> = None;
|
||||
|
||||
for raw_name in &payload.segments {
|
||||
let name = normalize_folder_name(raw_name, "folder names must not be empty")?;
|
||||
|
||||
let existing: Option<Folder> = if let Some(parent_id) = current_parent {
|
||||
folders::table
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.filter(folders::parent_id.eq(Some(parent_id)))
|
||||
.filter(folders::name.eq(&name))
|
||||
.first(conn)
|
||||
.optional()?
|
||||
} else {
|
||||
folders::table
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.filter(folders::parent_id.is_null())
|
||||
.filter(folders::name.eq(&name))
|
||||
.first(conn)
|
||||
.optional()?
|
||||
};
|
||||
|
||||
let folder = if let Some(folder) = existing {
|
||||
folder
|
||||
} else {
|
||||
let new_folder = NewFolder {
|
||||
id: Uuid::new_v4(),
|
||||
name: name.clone(),
|
||||
parent_id: current_parent,
|
||||
tenant_id,
|
||||
};
|
||||
|
||||
let inserted_id: Option<Uuid> = diesel::insert_into(folders::table)
|
||||
.values(&new_folder)
|
||||
.on_conflict_do_nothing()
|
||||
.returning(folders::id)
|
||||
.get_result(conn)
|
||||
.optional()?;
|
||||
|
||||
if let Some(id) = inserted_id {
|
||||
folders::table
|
||||
.find(id)
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.first(conn)?
|
||||
} else if let Some(parent_id) = current_parent {
|
||||
folders::table
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.filter(folders::parent_id.eq(Some(parent_id)))
|
||||
.filter(folders::name.eq(&name))
|
||||
.first(conn)?
|
||||
} else {
|
||||
folders::table
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.filter(folders::parent_id.is_null())
|
||||
.filter(folders::name.eq(&name))
|
||||
.first(conn)?
|
||||
}
|
||||
};
|
||||
|
||||
current_parent = Some(folder.id);
|
||||
last_folder = Some(folder);
|
||||
}
|
||||
|
||||
last_folder.ok_or_else(|| AppError::internal("failed to resolve folder path"))
|
||||
})?;
|
||||
|
||||
Ok(folder_to_info(folder))
|
||||
}
|
||||
|
||||
pub fn create_folder(
|
||||
&self,
|
||||
conn: &mut PgPooledConnection,
|
||||
tenant_id: Uuid,
|
||||
payload: CreateFolderRequest,
|
||||
) -> AppResult<(FolderInfo, bool)> {
|
||||
let name = normalize_folder_name(&payload.name, "name must not be empty")?;
|
||||
|
||||
let existing: Option<Folder> = if let Some(parent_id) = payload.parent_id {
|
||||
folders::table
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.filter(folders::parent_id.eq(Some(parent_id)))
|
||||
.filter(folders::name.eq(&name))
|
||||
.first(conn)
|
||||
.optional()?
|
||||
} else {
|
||||
folders::table
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.filter(folders::parent_id.is_null())
|
||||
.filter(folders::name.eq(&name))
|
||||
.first(conn)
|
||||
.optional()?
|
||||
};
|
||||
|
||||
let (folder, created) = if let Some(folder) = existing {
|
||||
(folder, false)
|
||||
} else {
|
||||
let new_folder = NewFolder {
|
||||
id: Uuid::new_v4(),
|
||||
name: name.clone(),
|
||||
parent_id: payload.parent_id,
|
||||
tenant_id,
|
||||
};
|
||||
|
||||
let inserted_id: Option<Uuid> = diesel::insert_into(folders::table)
|
||||
.values(&new_folder)
|
||||
.on_conflict_do_nothing()
|
||||
.returning(folders::id)
|
||||
.get_result(conn)
|
||||
.optional()?;
|
||||
|
||||
if let Some(id) = inserted_id {
|
||||
(
|
||||
folders::table
|
||||
.find(id)
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.first(conn)?,
|
||||
true,
|
||||
)
|
||||
} else if let Some(parent_id) = payload.parent_id {
|
||||
(
|
||||
folders::table
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.filter(folders::parent_id.eq(Some(parent_id)))
|
||||
.filter(folders::name.eq(&name))
|
||||
.first(conn)?,
|
||||
false,
|
||||
)
|
||||
} else {
|
||||
(
|
||||
folders::table
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.filter(folders::parent_id.is_null())
|
||||
.filter(folders::name.eq(&name))
|
||||
.first(conn)?,
|
||||
false,
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
Ok((folder_to_info(folder), created))
|
||||
}
|
||||
|
||||
pub fn list_folder_contents(
|
||||
&self,
|
||||
conn: &mut PgPooledConnection,
|
||||
tenant_id: Uuid,
|
||||
folder_id: Option<Uuid>,
|
||||
sort: DocumentSortField,
|
||||
dir: SortDirection,
|
||||
include_documents: bool,
|
||||
) -> AppResult<FolderContentsData> {
|
||||
let folder = match folder_id {
|
||||
Some(id) => Some(folder_to_info(
|
||||
folders::table
|
||||
.find(id)
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.first::<Folder>(conn)?,
|
||||
)),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let child_folders: Vec<Folder> = if let Some(parent_id) = folder_id {
|
||||
folders::table
|
||||
.filter(folders::parent_id.eq(parent_id))
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.order(sql::<Text>("name COLLATE \"unicode_ci\" ASC"))
|
||||
.load(conn)?
|
||||
} else {
|
||||
folders::table
|
||||
.filter(folders::parent_id.is_null())
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.order(sql::<Text>("name COLLATE \"unicode_ci\" ASC"))
|
||||
.load(conn)?
|
||||
};
|
||||
|
||||
let subfolders = child_folders.into_iter().map(folder_to_info).collect();
|
||||
|
||||
let documents = if include_documents {
|
||||
let mut docs_query = documents::table
|
||||
.filter(documents::deleted_at.is_null())
|
||||
.filter(documents::tenant_id.eq(tenant_id))
|
||||
.into_boxed();
|
||||
|
||||
let (primary_sql, secondary_sql) = ordering_clauses(sort, dir);
|
||||
docs_query = docs_query.order(sql::<Text>(primary_sql));
|
||||
if let Some(second) = secondary_sql {
|
||||
docs_query = docs_query.then_order_by(sql::<Text>(second));
|
||||
}
|
||||
|
||||
if let Some(current_folder) = folder_id {
|
||||
docs_query
|
||||
.filter(documents::folder_id.eq(current_folder))
|
||||
.load::<Document>(conn)?
|
||||
} else {
|
||||
docs_query
|
||||
.filter(documents::folder_id.is_null())
|
||||
.load::<Document>(conn)?
|
||||
}
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
Ok(FolderContentsData {
|
||||
folder,
|
||||
subfolders,
|
||||
documents,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_folder_tree(
|
||||
&self,
|
||||
conn: &mut PgPooledConnection,
|
||||
tenant_id: Uuid,
|
||||
) -> AppResult<Vec<FolderTreeNode>> {
|
||||
let folders: Vec<Folder> = folders::table
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.order(sql::<Text>("name COLLATE \"unicode_ci\" ASC"))
|
||||
.load(conn)?;
|
||||
|
||||
let mut node_map: HashMap<Uuid, FolderTreeNode> = HashMap::with_capacity(folders.len());
|
||||
let mut children_map: HashMap<Uuid, Vec<Uuid>> = HashMap::new();
|
||||
let mut roots: Vec<Uuid> = Vec::new();
|
||||
|
||||
for folder in folders {
|
||||
let id = folder.id;
|
||||
let parent_id = folder.parent_id;
|
||||
let node = FolderTreeNode {
|
||||
id,
|
||||
name: folder.name,
|
||||
parent_id,
|
||||
created_at: to_iso(folder.created_at),
|
||||
updated_at: to_iso(folder.updated_at),
|
||||
children: Vec::new(),
|
||||
};
|
||||
|
||||
if let Some(parent) = parent_id {
|
||||
children_map.entry(parent).or_default().push(id);
|
||||
} else {
|
||||
roots.push(id);
|
||||
}
|
||||
|
||||
node_map.insert(id, node);
|
||||
}
|
||||
|
||||
fn build_node(
|
||||
id: Uuid,
|
||||
nodes: &HashMap<Uuid, FolderTreeNode>,
|
||||
child_map: &HashMap<Uuid, Vec<Uuid>>,
|
||||
) -> FolderTreeNode {
|
||||
let mut node = nodes.get(&id).cloned().expect("folder node must exist");
|
||||
|
||||
if let Some(children) = child_map.get(&id) {
|
||||
node.children = children
|
||||
.iter()
|
||||
.map(|child_id| build_node(*child_id, nodes, child_map))
|
||||
.collect();
|
||||
}
|
||||
|
||||
node
|
||||
}
|
||||
|
||||
let tree = roots
|
||||
.iter()
|
||||
.map(|root_id| build_node(*root_id, &node_map, &children_map))
|
||||
.collect();
|
||||
|
||||
Ok(tree)
|
||||
}
|
||||
|
||||
pub fn delete_folder(
|
||||
&self,
|
||||
conn: &mut PgPooledConnection,
|
||||
tenant_id: Uuid,
|
||||
folder_id: Uuid,
|
||||
) -> AppResult<()> {
|
||||
conn.transaction::<_, AppError, _>(|conn| {
|
||||
folders::table
|
||||
.find(folder_id)
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.first::<Folder>(conn)?;
|
||||
|
||||
let has_child_folders: bool = diesel::select(exists(
|
||||
folders::table
|
||||
.filter(folders::parent_id.eq(Some(folder_id)))
|
||||
.filter(folders::tenant_id.eq(tenant_id)),
|
||||
))
|
||||
.get_result(conn)?;
|
||||
|
||||
if has_child_folders {
|
||||
return Err(AppError::bad_request(
|
||||
"folder must be empty before deletion",
|
||||
));
|
||||
}
|
||||
|
||||
let has_documents: bool = diesel::select(exists(
|
||||
documents::table
|
||||
.filter(documents::folder_id.eq(Some(folder_id)))
|
||||
.filter(documents::tenant_id.eq(tenant_id))
|
||||
.filter(documents::deleted_at.is_null()),
|
||||
))
|
||||
.get_result(conn)?;
|
||||
|
||||
if has_documents {
|
||||
return Err(AppError::bad_request(
|
||||
"folder must be empty before deletion",
|
||||
));
|
||||
}
|
||||
|
||||
diesel::delete(
|
||||
folders::table
|
||||
.filter(folders::id.eq(folder_id))
|
||||
.filter(folders::tenant_id.eq(tenant_id)),
|
||||
)
|
||||
.execute(conn)
|
||||
.into_app_result()?
|
||||
.or_not_found()?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn update_folder(
|
||||
&self,
|
||||
conn: &mut PgPooledConnection,
|
||||
tenant_id: Uuid,
|
||||
folder_id: Uuid,
|
||||
payload: UpdateFolderRequest,
|
||||
) -> AppResult<()> {
|
||||
conn.transaction::<_, AppError, _>(|conn| {
|
||||
let folder: Folder = folders::table
|
||||
.find(folder_id)
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.first(conn)?;
|
||||
|
||||
let mut next_parent = folder.parent_id;
|
||||
let mut parent_changed = false;
|
||||
match payload.parent_id {
|
||||
None => {}
|
||||
Some(None) => {
|
||||
if folder.parent_id.is_some() {
|
||||
parent_changed = true;
|
||||
}
|
||||
next_parent = None;
|
||||
}
|
||||
Some(Some(parent_id)) => {
|
||||
if parent_id == folder_id {
|
||||
return Err(AppError::bad_request("folder cannot be its own parent"));
|
||||
}
|
||||
|
||||
folders::table
|
||||
.find(parent_id)
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.first::<Folder>(conn)?;
|
||||
|
||||
if folder.parent_id != Some(parent_id) {
|
||||
let descendant_ids =
|
||||
gather_descendant_folder_ids(conn, tenant_id, folder_id)?;
|
||||
if descendant_ids.contains(&parent_id) {
|
||||
return Err(AppError::bad_request(
|
||||
"cannot move folder into itself or a descendant",
|
||||
));
|
||||
}
|
||||
parent_changed = true;
|
||||
}
|
||||
|
||||
next_parent = Some(parent_id);
|
||||
}
|
||||
}
|
||||
|
||||
let mut new_name = folder.name.clone();
|
||||
let mut name_changed = false;
|
||||
match payload.name {
|
||||
None => {}
|
||||
Some(None) => {
|
||||
return Err(AppError::bad_request("name cannot be null"));
|
||||
}
|
||||
Some(Some(value)) => {
|
||||
let normalized = normalize_folder_name(&value, "name must not be empty")?;
|
||||
|
||||
if normalized != folder.name {
|
||||
new_name = normalized;
|
||||
name_changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !parent_changed && !name_changed {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let conflict = if let Some(parent_id) = next_parent {
|
||||
folders::table
|
||||
.filter(folders::parent_id.eq(Some(parent_id)))
|
||||
.filter(folders::name.eq(&new_name))
|
||||
.filter(folders::id.ne(folder_id))
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.first::<Folder>(conn)
|
||||
.optional()?
|
||||
} else {
|
||||
folders::table
|
||||
.filter(folders::parent_id.is_null())
|
||||
.filter(folders::name.eq(&new_name))
|
||||
.filter(folders::id.ne(folder_id))
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.first::<Folder>(conn)
|
||||
.optional()?
|
||||
};
|
||||
|
||||
if conflict.is_some() {
|
||||
return Err(AppError::bad_request(
|
||||
"a folder with the same name already exists in the target",
|
||||
));
|
||||
}
|
||||
|
||||
diesel::update(
|
||||
folders::table
|
||||
.find(folder_id)
|
||||
.filter(folders::tenant_id.eq(tenant_id)),
|
||||
)
|
||||
.set((
|
||||
folders::parent_id.eq(next_parent),
|
||||
folders::name.eq(&new_name),
|
||||
))
|
||||
.execute(conn)
|
||||
.into_app_result()?
|
||||
.or_not_found()?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn hydrate_documents(
|
||||
&self,
|
||||
conn: &mut PgPooledConnection,
|
||||
tenant_id: Uuid,
|
||||
user_id: Uuid,
|
||||
docs: Vec<Document>,
|
||||
) -> AppResult<Vec<DocumentResponse>> {
|
||||
DocumentsService::new(self.state).hydrate_documents(conn, tenant_id, user_id, docs)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn gather_descendant_folder_ids(
|
||||
conn: &mut PgConnection,
|
||||
tenant_id: Uuid,
|
||||
folder_id: Uuid,
|
||||
) -> AppResult<Vec<Uuid>> {
|
||||
let mut ids = vec![folder_id];
|
||||
let mut queue = vec![folder_id];
|
||||
|
||||
while let Some(current) = queue.pop() {
|
||||
let child_ids: Vec<Uuid> = folders::table
|
||||
.filter(folders::parent_id.eq(Some(current)))
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.select(folders::id)
|
||||
.load(conn)?;
|
||||
queue.extend(child_ids.iter().copied());
|
||||
ids.extend(child_ids);
|
||||
}
|
||||
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
fn folder_to_info(folder: Folder) -> FolderInfo {
|
||||
FolderInfo {
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
parent_id: folder.parent_id,
|
||||
created_at: to_iso(folder.created_at),
|
||||
updated_at: to_iso(folder.updated_at),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_folder_name(value: &str, empty_message: &str) -> AppResult<String> {
|
||||
normalize_identifier(
|
||||
value,
|
||||
MAX_FOLDER_NAME_LEN,
|
||||
empty_message,
|
||||
"folder name must not exceed 255 characters",
|
||||
Some("folder name may only contain printable characters"),
|
||||
|ch| !ch.is_control(),
|
||||
)
|
||||
}
|
||||
|
||||
const fn default_include_documents() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::UpdateFolderRequest;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn update_folder_request_deserializes_null_parent() {
|
||||
let req: UpdateFolderRequest =
|
||||
serde_json::from_value(json!({ "parent_id": null })).unwrap();
|
||||
assert!(matches!(req.parent_id, Some(None)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_folder_request_deserializes_absent_parent() {
|
||||
let req: UpdateFolderRequest = serde_json::from_value(json!({})).unwrap();
|
||||
assert!(req.parent_id.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_folder_request_deserializes_null_name() {
|
||||
let req: UpdateFolderRequest = serde_json::from_value(json!({ "name": null })).unwrap();
|
||||
assert!(matches!(req.name, Some(None)));
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user