Archify Sync server, Dockerfile and Helm chart
Rust server (axum, yrs, sqlx/PostgreSQL): live shared models over the y-websocket protocol, versions, browser sign-in with PKCE, invites, single sign-on through OIDC, metrics. Docker image on distroless, compose file, and a Helm chart with one pod, optional bundled PostgreSQL and secrets from existing Secrets.
This commit is contained in:
commit
5c43b90ef8
67 files changed
+8362
No files matched your search
@@ -0,0 +1,3 @@
|
|||||||
|
[env]
|
||||||
|
# ts-rs writes the protocol types here on `cargo test -p archify-sync-protocol`.
|
||||||
|
TS_RS_EXPORT_DIR = { value = "packages/protocol/src/generated", relative = true }
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
target
|
||||||
|
node_modules
|
||||||
|
packages
|
||||||
|
charts
|
||||||
|
.git
|
||||||
|
.moon/cache
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/target
|
||||||
|
node_modules/
|
||||||
|
.moon/cache/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
$schema: 'https://moonrepo.dev/schemas/toolchain.json'
|
||||||
|
rust:
|
||||||
|
version: '1.99.1'
|
||||||
|
components: [clippy, rustfmt]
|
||||||
|
node:
|
||||||
|
version: '24.13.1'
|
||||||
|
packageManager: pnpm
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# moon governs the repository: one task graph over the Rust crates and the
|
||||||
|
# TypeScript package. `moon run :check` and `moon ci` run what a change affects.
|
||||||
|
$schema: 'https://moonrepo.dev/schemas/workspace.json'
|
||||||
|
projects:
|
||||||
|
- 'crates/*'
|
||||||
|
- 'packages/*'
|
||||||
|
vcs:
|
||||||
|
manager: git
|
||||||
|
defaultBranch: main
|
||||||
Generated
+3275
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,9 @@
|
|||||||
|
[workspace]
|
||||||
|
resolver = "3"
|
||||||
|
members = ["crates/protocol", "crates/store", "crates/server"]
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
license = "MIT"
|
||||||
|
repository = "https://gitea.lego-cloud.eu/vscode-extensions/archify-vscode-ext-sync"
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
# Archify Sync server: one static-ish binary on distroless, running as non-root.
|
||||||
|
# docker build -t archify-sync .
|
||||||
|
|
||||||
|
FROM rust:1.99-slim-trixie AS build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY Cargo.toml Cargo.lock ./
|
||||||
|
COPY .cargo .cargo
|
||||||
|
COPY crates crates
|
||||||
|
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||||
|
--mount=type=cache,target=/src/target \
|
||||||
|
cargo build --release --locked -p archify-sync-server \
|
||||||
|
&& cp target/release/archify-sync-server /archify-sync-server
|
||||||
|
|
||||||
|
FROM gcr.io/distroless/cc-debian13:nonroot
|
||||||
|
LABEL org.opencontainers.image.title="Archify Sync" \
|
||||||
|
org.opencontainers.image.description="Live, shared Archify models" \
|
||||||
|
org.opencontainers.image.source="https://gitea.lego-cloud.eu/vscode-extensions/archify-vscode-ext-sync" \
|
||||||
|
org.opencontainers.image.licenses="MIT"
|
||||||
|
COPY --from=build /archify-sync-server /usr/local/bin/archify-sync-server
|
||||||
|
ENV ARCHIFY_SYNC_LISTEN=0.0.0.0:8080 \
|
||||||
|
ARCHIFY_SYNC_LOG_JSON=true
|
||||||
|
EXPOSE 8080
|
||||||
|
USER nonroot
|
||||||
|
HEALTHCHECK --interval=10s --timeout=5s --start-period=10s --retries=3 \
|
||||||
|
CMD ["/usr/local/bin/archify-sync-server", "healthcheck"]
|
||||||
|
ENTRYPOINT ["/usr/local/bin/archify-sync-server"]
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 VSCode Extentions (gitea.lego-cloud.eu/vscode-extensions)
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
vendor/archify/ contains the Archify renderer, Copyright (c) 2026 tt-a1i
|
||||||
|
(Archify), MIT License. See vendor/archify/LICENSE and
|
||||||
|
vendor/archify/THIRD_PARTY_NOTICES.md. schemas/ is derived from the same
|
||||||
|
project.
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
# Archify Sync
|
||||||
|
|
||||||
|
The server behind live, shared models in [Archify Diagram Viewer](https://marketplace.visualstudio.com/items?itemName=root-at-skic.archify-vscode-ext). People edit one model at the same time and see each other type. Every edit is kept, versions are taken on their own, and anyone can publish one by hand.
|
||||||
|
|
||||||
|
One Rust process and PostgreSQL. The design, with its decisions, is the Archify model in `ws-storm/2026-10-30-archify-sync-backend/`.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
- **A space is one shared model.** Its content is a Yjs document with one `Y.Text` per model file, in the root map `files`, keyed by the path relative to the model root.
|
||||||
|
- **Live editing.** Each editor keeps one WebSocket per space and speaks the y-websocket protocol: sync step 1 and 2, updates and awareness. Every update is appended to PostgreSQL before anyone else sees it.
|
||||||
|
- **Offline.** Editors keep their own copy. On reconnect, both sides exchange state vectors and send what the other is missing.
|
||||||
|
- **Versions.**
|
||||||
|
- **Snapshots (0.1, 1.2, …):** taken after a pause in editing and when the last editor leaves.
|
||||||
|
- **Publish (1.0, 2.0, …):** marked by hand, with a name.
|
||||||
|
- **Restore:** brings an earlier version back as a new edit and a new version; history is never rewritten.
|
||||||
|
- **Update log:** taking a version compacts it.
|
||||||
|
- **Sign-in.** The editor opens `/authorize` in the browser and gets a one-time code back through its URI handler. It exchanges the code with its PKCE verifier for an access token (JWT, 15 minutes) and a refresh token. The refresh token is single use: reusing one signs out the whole session.
|
||||||
|
- **First start.** A server without accounts prints a first-start code in its log. The first person to use it at `/setup` becomes the admin.
|
||||||
|
- **Invites.** An invite link (`/join/<token>`) opens the extension. Someone new makes an account on the sign-in page, so no mail server is needed.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
| Path | What |
|
||||||
|
|---|---|
|
||||||
|
| `crates/protocol` | Types shared with the extension; exported to TypeScript with ts-rs |
|
||||||
|
| `crates/store` | PostgreSQL through sqlx; migrations in `crates/store/migrations` |
|
||||||
|
| `crates/server` | axum: the HTTP API, the pages, the live hub (`hub.rs`), the CRDT helpers (`doc.rs`) |
|
||||||
|
| `packages/protocol` | `@archify/sync-protocol`, the generated types for the extension |
|
||||||
|
|
||||||
|
moon runs the tasks: `moon run :check`, `moon run :test`, `moon run protocol:types`. Plain `cargo` works too.
|
||||||
|
|
||||||
|
## Run it locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d --name archify-pg -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=archify -p 5432:5432 postgres:17-alpine
|
||||||
|
ARCHIFY_SYNC_DATABASE_URL=postgres://postgres:dev@127.0.0.1:5432/archify cargo run -p archify-sync-server
|
||||||
|
```
|
||||||
|
|
||||||
|
The log shows the first-start code. Open `http://localhost:8080/setup` and enter it.
|
||||||
|
|
||||||
|
Or the server and its database in containers:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
docker compose logs server | grep "with code"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Kubernetes
|
||||||
|
|
||||||
|
The homelab deployment is in [archify-vscode-ext-sync-gitops](https://gitea.lego-cloud.eu/vscode-extensions/archify-vscode-ext-sync-gitops) (rendered from `archify-vscode-ext-sync-gitops-tmpl`); Argo CD takes this chart from here.
|
||||||
|
|
||||||
|
The chart is in `charts/archify-sync`. It always runs one pod with the `Recreate` strategy: one server holds the live copy of each space, and the extension works offline while the pod is replaced. Probes use `/healthz` and `/readyz`; on `SIGTERM` the server stores a snapshot of every open space.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
helm install sync charts/archify-sync -n archify-sync --create-namespace \
|
||||||
|
--set publicUrl=https://archify-sync.example.com \
|
||||||
|
--set database.existingSecret=archify-sync-db # a Secret with the URL under "url"
|
||||||
|
```
|
||||||
|
|
||||||
|
`postgresql.enabled=true` adds a single PostgreSQL to the release, for trying out. Behind ingress-nginx, raise `proxy-read-timeout` and `proxy-send-timeout` (see `values.yaml`): edits travel over a WebSocket that stays open.
|
||||||
|
|
||||||
|
On a local cluster (OrbStack, kind, k3d) with the image from `docker build -t archify-sync:local .`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
helm install sync charts/archify-sync -n archify-sync --create-namespace -f charts/archify-sync/ci/local-values.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
The server is then at `http://localhost:18081`.
|
||||||
|
|
||||||
|
## Single sign-on (OIDC)
|
||||||
|
|
||||||
|
With an OpenID Connect provider (Keycloak, Authentik, Entra ID, …) the sign-in page shows **Sign in with Keycloak** above the password form. Nothing changes in the extension: it opens the same page, and the provider sends the person back to `<public URL>/oidc/callback`. People are matched by the provider's issuer and subject, never by name; a new person gets an account named after `preferred_username` (`ana`, or `ana-2` when `ana` is taken).
|
||||||
|
|
||||||
|
Who gets in:
|
||||||
|
|
||||||
|
- **Allowed groups set** (`ARCHIFY_SYNC_OIDC_ALLOWED_GROUPS=archify-users`): members of those groups get an account on their first sign-in and join spaces through invites; everyone else is refused, also later, when they leave the group.
|
||||||
|
- **No allowed groups**: anyone the provider signs in, but a new person needs an invite link the first time.
|
||||||
|
- **Admin groups** (`ARCHIFY_SYNC_OIDC_ADMIN_GROUPS=archify-admins`): their members are admins of the server.
|
||||||
|
|
||||||
|
The first admin is still set up with a password on `/setup`; keep that account as the way in when the provider is down.
|
||||||
|
|
||||||
|
### The client at the provider
|
||||||
|
|
||||||
|
One client serves every place the server runs: list each place's callback. For Keycloak, in the realm the people sign in to:
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| Client ID | `archify-sync` |
|
||||||
|
| Client authentication | on (confidential), authenticator *client secret* |
|
||||||
|
| Flows | standard flow only (no implicit, no direct access grants, no service account) |
|
||||||
|
| PKCE | S256 (the server always sends it) |
|
||||||
|
| Valid redirect URIs | `https://<your host>/oidc/callback`, plus `http://localhost:8080/oidc/callback` and `http://localhost:18081/oidc/callback` for local runs |
|
||||||
|
| Mapper | *Group Membership*, claim `groups`, full path off, in the ID token |
|
||||||
|
|
||||||
|
Then give the server the realm's issuer (`https://<keycloak>/realms/<realm>`), the client id and the secret:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ARCHIFY_SYNC_OIDC_ISSUER=https://keycloak.example.com/realms/home
|
||||||
|
ARCHIFY_SYNC_OIDC_CLIENT_ID=archify-sync
|
||||||
|
ARCHIFY_SYNC_OIDC_CLIENT_SECRET=… # or ARCHIFY_SYNC_OIDC_CLIENT_SECRET_FILE=/run/secrets/oidc
|
||||||
|
ARCHIFY_SYNC_OIDC_LABEL=Keycloak
|
||||||
|
ARCHIFY_SYNC_OIDC_ALLOWED_GROUPS=archify-users
|
||||||
|
ARCHIFY_SYNC_OIDC_ADMIN_GROUPS=archify-admins
|
||||||
|
```
|
||||||
|
|
||||||
|
In the Helm chart: `oidc.enabled=true`, `oidc.issuer`, `oidc.clientId`, `oidc.existingSecret` (a Secret with the client secret under `client-secret`, for example from an ExternalSecret), `oidc.allowedGroups`, `oidc.adminGroups`. The server logs the callback to register when it starts.
|
||||||
|
|
||||||
|
The issuer must be `https`, except on this machine. A session lasts `ARCHIFY_SYNC_REFRESH_TTL_DAYS` after the last use: disabling someone at the provider stops new sign-ins at once, and an admin removes them from spaces to end an open session sooner.
|
||||||
|
|
||||||
|
## Sign-in protection
|
||||||
|
|
||||||
|
Ten wrong passwords for one name within 15 minutes lock that name for the rest of the window, even with the right password. The lock lives in the server's memory and is cleared on restart.
|
||||||
|
|
||||||
|
## Metrics
|
||||||
|
|
||||||
|
With `ARCHIFY_SYNC_METRICS_LISTEN` set, `/metrics` serves `archify_sync_rooms_open`, `connections_open`, `connections_total`, `connections_lagged_total`, `updates_total`, `update_bytes_total`, `update_store_seconds_total`, `snapshots_total`, `publishes_total`, `restores_total`, `sign_in_failures_total`, `sign_in_blocked_total` and `refresh_reuse_total`.
|
||||||
|
|
||||||
|
## Settings
|
||||||
|
|
||||||
|
All settings come from the environment.
|
||||||
|
|
||||||
|
| Variable | Default | |
|
||||||
|
|---|---|---|
|
||||||
|
| `ARCHIFY_SYNC_DATABASE_URL` | (required) | PostgreSQL 17 |
|
||||||
|
| `ARCHIFY_SYNC_LISTEN` | `0.0.0.0:8080` | Plain HTTP and WebSocket; TLS ends in front (ingress or Caddy) |
|
||||||
|
| `ARCHIFY_SYNC_METRICS_LISTEN` | off | Prometheus `/metrics` on its own address, e.g. `0.0.0.0:9090` |
|
||||||
|
| `ARCHIFY_SYNC_PUBLIC_URL` | `http://localhost:<port>` | Where people reach the server; invite links use it |
|
||||||
|
| `ARCHIFY_SYNC_NAME` | `Archify Sync` | Shown on the pages |
|
||||||
|
| `ARCHIFY_SYNC_SNAPSHOT_IDLE_SECS` | `30` | Quiet time before a snapshot |
|
||||||
|
| `ARCHIFY_SYNC_SNAPSHOT_MAX_SECS` | `600` | A space edited without a pause still gets a snapshot this often |
|
||||||
|
| `ARCHIFY_SYNC_SNAPSHOT_MAX_EDITS` | `2000` | …or after this many edits; each snapshot empties the update log |
|
||||||
|
| `ARCHIFY_SYNC_INVITE_HOURS` | `168` | How long an invite link works |
|
||||||
|
| `ARCHIFY_SYNC_MEMBERS_CAN_INVITE` | `false` | Whether editors may invite too, not only owners and admins |
|
||||||
|
| `ARCHIFY_SYNC_ACCESS_TTL_SECS` | `900` | Access token lifetime |
|
||||||
|
| `ARCHIFY_SYNC_REFRESH_TTL_DAYS` | `30` | Session lifetime |
|
||||||
|
| `ARCHIFY_SYNC_REFRESH_GRACE_SECS` | `30` | A refresh token used again within this time (two windows refreshing at once) still works; later, the whole session is signed out |
|
||||||
|
| `ARCHIFY_SYNC_REDIRECT_SCHEMES` | `vscode,vscode-insiders,vscodium,cursor,windsurf` | Editors that may receive a sign-in code |
|
||||||
|
| `ARCHIFY_SYNC_EXTENSION_ID` | `root-at-skic.archify-vscode-ext` | Authority of the extension's callback URI |
|
||||||
|
| `ARCHIFY_SYNC_MAX_MESSAGE_BYTES` | `8388608` | Largest WebSocket frame |
|
||||||
|
| `ARCHIFY_SYNC_LOG_JSON` | `false` | JSON logs |
|
||||||
|
| `RUST_LOG` | `info` | Log filter |
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
| Method and path | |
|
||||||
|
|---|---|
|
||||||
|
| `GET /v1/info` | Name, version, protocol, whether it is set up |
|
||||||
|
| `POST /v1/setup` | First admin with the first-start code |
|
||||||
|
| `POST /v1/auth/token` | `authorization_code` with PKCE, or `refresh_token` |
|
||||||
|
| `POST /v1/auth/logout` | Ends the session of a refresh token |
|
||||||
|
| `GET /v1/me` | The signed-in account |
|
||||||
|
| `GET, POST /v1/spaces` | Your spaces; create one (you own it) |
|
||||||
|
| `GET /v1/spaces/{id}` | One space |
|
||||||
|
| `GET /v1/spaces/{id}/members`, `DELETE …/members/{account}` | Members; leave, or remove someone (owner, admin) |
|
||||||
|
| `POST /v1/spaces/{id}/invites` | An invite link (owner, admin) |
|
||||||
|
| `POST /v1/invites/{token}/accept` | Join with a link while signed in |
|
||||||
|
| `GET, POST /v1/spaces/{id}/versions` | The history; publish the current state |
|
||||||
|
| `GET /v1/spaces/{id}/versions/{v}/files` | A version's files as text |
|
||||||
|
| `POST /v1/spaces/{id}/versions/{v}/restore` | Restore as a new version |
|
||||||
|
| `GET /v1/spaces/{id}/sync?v=1` | WebSocket, y-websocket protocol, `Authorization: Bearer` |
|
||||||
|
| `GET /healthz`, `GET /readyz` | Liveness; readiness checks PostgreSQL |
|
||||||
|
| `GET /setup`, `/authorize`, `/join/{token}` | The browser pages |
|
||||||
|
|
||||||
|
## Not yet
|
||||||
|
|
||||||
|
- **Model check.** The server does not validate files against the Archify schemas; each editor reports a file that two edits left broken in its Problems panel.
|
||||||
|
- **Structural compare.** The server returns a version's files, and the client compares them.
|
||||||
|
|
||||||
|
## Licence
|
||||||
|
|
||||||
|
MIT
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
apiVersion: v2
|
||||||
|
name: archify-sync
|
||||||
|
description: Archify Sync, live and shared Archify models for VS Code
|
||||||
|
type: application
|
||||||
|
version: 0.1.0
|
||||||
|
appVersion: "0.1.0"
|
||||||
|
annotations:
|
||||||
|
artifacthub.io/license: MIT
|
||||||
|
sources:
|
||||||
|
- https://gitea.lego-cloud.eu/vscode-extensions/archify-vscode-ext-sync
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Local Kubernetes (OrbStack, kind, k3d): the image built by `docker build`,
|
||||||
|
# the bundled PostgreSQL, reached with `kubectl port-forward`.
|
||||||
|
image:
|
||||||
|
repository: archify-sync
|
||||||
|
tag: local
|
||||||
|
pullPolicy: Never
|
||||||
|
publicUrl: http://localhost:18081
|
||||||
|
postgresql:
|
||||||
|
enabled: true
|
||||||
|
storage: 1Gi
|
||||||
|
config:
|
||||||
|
snapshotIdleSecs: 2
|
||||||
|
logJson: false
|
||||||
|
# OrbStack serves LoadBalancer services on localhost.
|
||||||
|
service:
|
||||||
|
type: LoadBalancer
|
||||||
|
port: 18081
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
Archify Sync is starting at {{ .Values.publicUrl }}
|
||||||
|
|
||||||
|
On the first start the server prints a one-time setup code:
|
||||||
|
|
||||||
|
kubectl -n {{ .Release.Namespace }} logs deploy/{{ include "archify-sync.fullname" . }} | grep "with code"
|
||||||
|
|
||||||
|
Open {{ .Values.publicUrl }}/setup, enter the code and create the first admin.
|
||||||
|
Then in VS Code: "Archify | Sign in to Sync Server" with {{ .Values.publicUrl }}.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
{{- define "archify-sync.fullname" -}}
|
||||||
|
{{- if contains .Chart.Name .Release.Name -}}
|
||||||
|
{{- .Release.Name | trunc 63 | trimSuffix "-" -}}
|
||||||
|
{{- else -}}
|
||||||
|
{{- printf "%s-%s" .Release.Name .Chart.Name | trunc 63 | trimSuffix "-" -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- define "archify-sync.labels" -}}
|
||||||
|
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version }}
|
||||||
|
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||||
|
app.kubernetes.io/part-of: archify-sync
|
||||||
|
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- define "archify-sync.selector" -}}
|
||||||
|
app.kubernetes.io/name: archify-sync
|
||||||
|
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- define "archify-sync.dbSelector" -}}
|
||||||
|
app.kubernetes.io/name: archify-sync-postgresql
|
||||||
|
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{/* The Secret and key that hold the database URL. */}}
|
||||||
|
{{- define "archify-sync.dbSecret" -}}
|
||||||
|
{{- if and .Values.postgresql.enabled .Values.postgresql.existingSecret -}}
|
||||||
|
{{ .Values.postgresql.existingSecret }}
|
||||||
|
{{- else if .Values.postgresql.enabled -}}
|
||||||
|
{{ include "archify-sync.fullname" . }}-postgresql
|
||||||
|
{{- else if .Values.database.existingSecret -}}
|
||||||
|
{{ .Values.database.existingSecret }}
|
||||||
|
{{- else -}}
|
||||||
|
{{ include "archify-sync.fullname" . }}-database
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- define "archify-sync.dbSecretKey" -}}
|
||||||
|
{{- if or .Values.postgresql.enabled (not .Values.database.existingSecret) -}}
|
||||||
|
url
|
||||||
|
{{- else -}}
|
||||||
|
{{ .Values.database.existingSecretKey }}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
{{- if not (or .Values.postgresql.enabled .Values.database.existingSecret .Values.database.url) }}
|
||||||
|
{{- fail "Set database.url, database.existingSecret or postgresql.enabled" }}
|
||||||
|
{{- end }}
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: {{ include "archify-sync.fullname" . }}
|
||||||
|
labels:
|
||||||
|
{{- include "archify-sync.labels" . | nindent 4 }}
|
||||||
|
{{- include "archify-sync.selector" . | nindent 4 }}
|
||||||
|
spec:
|
||||||
|
# One server holds the live copy of each space; see values.yaml.
|
||||||
|
replicas: 1
|
||||||
|
strategy:
|
||||||
|
type: Recreate
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
{{- include "archify-sync.selector" . | nindent 6 }}
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
{{- include "archify-sync.labels" . | nindent 8 }}
|
||||||
|
{{- include "archify-sync.selector" . | nindent 8 }}
|
||||||
|
{{- with .Values.podAnnotations }}
|
||||||
|
annotations:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
spec:
|
||||||
|
{{- with .Values.imagePullSecrets }}
|
||||||
|
imagePullSecrets:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
automountServiceAccountToken: false
|
||||||
|
# Leaving stores a snapshot of every open space.
|
||||||
|
terminationGracePeriodSeconds: 30
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 65532
|
||||||
|
runAsGroup: 65532
|
||||||
|
seccompProfile:
|
||||||
|
type: RuntimeDefault
|
||||||
|
containers:
|
||||||
|
- name: server
|
||||||
|
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||||
|
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
readOnlyRootFilesystem: true
|
||||||
|
capabilities:
|
||||||
|
drop: [ALL]
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
containerPort: 8080
|
||||||
|
{{- if .Values.metrics.enabled }}
|
||||||
|
- name: metrics
|
||||||
|
containerPort: {{ .Values.metrics.port }}
|
||||||
|
{{- end }}
|
||||||
|
env:
|
||||||
|
- name: ARCHIFY_SYNC_DATABASE_URL
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ include "archify-sync.dbSecret" . }}
|
||||||
|
key: {{ include "archify-sync.dbSecretKey" . }}
|
||||||
|
- name: ARCHIFY_SYNC_DATABASE_MAX_CONNECTIONS
|
||||||
|
value: {{ .Values.database.maxConnections | quote }}
|
||||||
|
- name: ARCHIFY_SYNC_LISTEN
|
||||||
|
value: "0.0.0.0:8080"
|
||||||
|
- name: ARCHIFY_SYNC_PUBLIC_URL
|
||||||
|
value: {{ .Values.publicUrl | quote }}
|
||||||
|
- name: ARCHIFY_SYNC_NAME
|
||||||
|
value: {{ .Values.config.name | quote }}
|
||||||
|
- name: ARCHIFY_SYNC_SNAPSHOT_IDLE_SECS
|
||||||
|
value: {{ .Values.config.snapshotIdleSecs | quote }}
|
||||||
|
- name: ARCHIFY_SYNC_SNAPSHOT_MAX_SECS
|
||||||
|
value: {{ .Values.config.snapshotMaxSecs | quote }}
|
||||||
|
- name: ARCHIFY_SYNC_SNAPSHOT_MAX_EDITS
|
||||||
|
value: {{ .Values.config.snapshotMaxEdits | quote }}
|
||||||
|
- name: ARCHIFY_SYNC_INVITE_HOURS
|
||||||
|
value: {{ .Values.config.inviteHours | quote }}
|
||||||
|
- name: ARCHIFY_SYNC_MEMBERS_CAN_INVITE
|
||||||
|
value: {{ .Values.config.membersCanInvite | quote }}
|
||||||
|
- name: ARCHIFY_SYNC_ACCESS_TTL_SECS
|
||||||
|
value: {{ .Values.config.accessTtlSecs | quote }}
|
||||||
|
- name: ARCHIFY_SYNC_REFRESH_TTL_DAYS
|
||||||
|
value: {{ .Values.config.refreshTtlDays | quote }}
|
||||||
|
- name: ARCHIFY_SYNC_REFRESH_GRACE_SECS
|
||||||
|
value: {{ .Values.config.refreshGraceSecs | quote }}
|
||||||
|
- name: ARCHIFY_SYNC_REDIRECT_SCHEMES
|
||||||
|
value: {{ .Values.config.redirectSchemes | quote }}
|
||||||
|
- name: ARCHIFY_SYNC_MAX_MESSAGE_BYTES
|
||||||
|
value: {{ .Values.config.maxMessageBytes | int64 | quote }}
|
||||||
|
- name: ARCHIFY_SYNC_LOG_JSON
|
||||||
|
value: {{ .Values.config.logJson | quote }}
|
||||||
|
{{- if .Values.metrics.enabled }}
|
||||||
|
- name: ARCHIFY_SYNC_METRICS_LISTEN
|
||||||
|
value: "0.0.0.0:{{ .Values.metrics.port }}"
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.oidc }}
|
||||||
|
{{- if .enabled }}
|
||||||
|
- name: ARCHIFY_SYNC_OIDC_ISSUER
|
||||||
|
value: {{ required "oidc.issuer is required when oidc.enabled" .issuer | quote }}
|
||||||
|
- name: ARCHIFY_SYNC_OIDC_CLIENT_ID
|
||||||
|
value: {{ .clientId | quote }}
|
||||||
|
{{- if .existingSecret }}
|
||||||
|
- name: ARCHIFY_SYNC_OIDC_CLIENT_SECRET
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ .existingSecret }}
|
||||||
|
key: {{ .existingSecretKey }}
|
||||||
|
{{- end }}
|
||||||
|
- name: ARCHIFY_SYNC_OIDC_LABEL
|
||||||
|
value: {{ .label | quote }}
|
||||||
|
- name: ARCHIFY_SYNC_OIDC_SCOPES
|
||||||
|
value: {{ .scopes | quote }}
|
||||||
|
- name: ARCHIFY_SYNC_OIDC_GROUPS_CLAIM
|
||||||
|
value: {{ .groupsClaim | quote }}
|
||||||
|
- name: ARCHIFY_SYNC_OIDC_ALLOWED_GROUPS
|
||||||
|
value: {{ join "," .allowedGroups | quote }}
|
||||||
|
- name: ARCHIFY_SYNC_OIDC_ADMIN_GROUPS
|
||||||
|
value: {{ join "," .adminGroups | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
- name: RUST_LOG
|
||||||
|
value: {{ .Values.config.logLevel | quote }}
|
||||||
|
{{- with .Values.extraEnv }}
|
||||||
|
{{- toYaml . | nindent 12 }}
|
||||||
|
{{- end }}
|
||||||
|
startupProbe:
|
||||||
|
httpGet: { path: /healthz, port: http }
|
||||||
|
periodSeconds: 2
|
||||||
|
failureThreshold: 60
|
||||||
|
livenessProbe:
|
||||||
|
httpGet: { path: /healthz, port: http }
|
||||||
|
periodSeconds: 10
|
||||||
|
readinessProbe:
|
||||||
|
httpGet: { path: /readyz, port: http }
|
||||||
|
periodSeconds: 5
|
||||||
|
resources:
|
||||||
|
{{- toYaml .Values.resources | nindent 12 }}
|
||||||
|
{{- with .Values.nodeSelector }}
|
||||||
|
nodeSelector:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.tolerations }}
|
||||||
|
tolerations:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.affinity }}
|
||||||
|
affinity:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{{- if .Values.ingress.enabled }}
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: {{ include "archify-sync.fullname" . }}
|
||||||
|
labels:
|
||||||
|
{{- include "archify-sync.labels" . | nindent 4 }}
|
||||||
|
{{- with .Values.ingress.annotations }}
|
||||||
|
annotations:
|
||||||
|
{{- toYaml . | nindent 4 }}
|
||||||
|
{{- end }}
|
||||||
|
spec:
|
||||||
|
{{- with .Values.ingress.className }}
|
||||||
|
ingressClassName: {{ . }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.ingress.tls }}
|
||||||
|
tls:
|
||||||
|
{{- toYaml . | nindent 4 }}
|
||||||
|
{{- end }}
|
||||||
|
rules:
|
||||||
|
- host: {{ .Values.ingress.host | quote }}
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: {{ include "archify-sync.fullname" . }}
|
||||||
|
port:
|
||||||
|
name: http
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{{- if .Values.metrics.enabled }}
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: {{ include "archify-sync.fullname" . }}-metrics
|
||||||
|
labels:
|
||||||
|
{{- include "archify-sync.labels" . | nindent 4 }}
|
||||||
|
app.kubernetes.io/component: metrics
|
||||||
|
spec:
|
||||||
|
type: ClusterIP
|
||||||
|
selector:
|
||||||
|
{{- include "archify-sync.selector" . | nindent 4 }}
|
||||||
|
ports:
|
||||||
|
- name: metrics
|
||||||
|
port: {{ .Values.metrics.port }}
|
||||||
|
targetPort: metrics
|
||||||
|
{{- if .Values.metrics.serviceMonitor.enabled }}
|
||||||
|
---
|
||||||
|
apiVersion: monitoring.coreos.com/v1
|
||||||
|
kind: ServiceMonitor
|
||||||
|
metadata:
|
||||||
|
name: {{ include "archify-sync.fullname" . }}
|
||||||
|
labels:
|
||||||
|
{{- include "archify-sync.labels" . | nindent 4 }}
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/component: metrics
|
||||||
|
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||||
|
endpoints:
|
||||||
|
- port: metrics
|
||||||
|
interval: {{ .Values.metrics.serviceMonitor.interval }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
{{- if .Values.postgresql.enabled }}
|
||||||
|
{{- $name := printf "%s-postgresql" (include "archify-sync.fullname" .) }}
|
||||||
|
{{- $secret := .Values.postgresql.existingSecret | default $name }}
|
||||||
|
{{- if not .Values.postgresql.existingSecret }}
|
||||||
|
{{- $password := .Values.postgresql.password }}
|
||||||
|
{{- if not $password }}
|
||||||
|
{{- $existing := lookup "v1" "Secret" .Release.Namespace $name }}
|
||||||
|
{{- $password = $existing | dig "data" "password" "" | b64dec | default (randAlphaNum 32) }}
|
||||||
|
{{- end }}
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: {{ $name }}
|
||||||
|
labels:
|
||||||
|
{{- include "archify-sync.labels" . | nindent 4 }}
|
||||||
|
annotations:
|
||||||
|
helm.sh/resource-policy: keep
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
password: {{ $password | quote }}
|
||||||
|
url: {{ printf "postgres://archify:%s@%s:5432/archify" $password $name | quote }}
|
||||||
|
---
|
||||||
|
{{- end }}
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: {{ $name }}
|
||||||
|
labels:
|
||||||
|
{{- include "archify-sync.labels" . | nindent 4 }}
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
{{- include "archify-sync.dbSelector" . | nindent 4 }}
|
||||||
|
ports:
|
||||||
|
- name: postgres
|
||||||
|
port: 5432
|
||||||
|
targetPort: postgres
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: StatefulSet
|
||||||
|
metadata:
|
||||||
|
name: {{ $name }}
|
||||||
|
labels:
|
||||||
|
{{- include "archify-sync.labels" . | nindent 4 }}
|
||||||
|
{{- include "archify-sync.dbSelector" . | nindent 4 }}
|
||||||
|
spec:
|
||||||
|
serviceName: {{ $name }}
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
{{- include "archify-sync.dbSelector" . | nindent 6 }}
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
{{- include "archify-sync.labels" . | nindent 8 }}
|
||||||
|
{{- include "archify-sync.dbSelector" . | nindent 8 }}
|
||||||
|
spec:
|
||||||
|
automountServiceAccountToken: false
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 70
|
||||||
|
fsGroup: 70
|
||||||
|
seccompProfile:
|
||||||
|
type: RuntimeDefault
|
||||||
|
containers:
|
||||||
|
- name: postgres
|
||||||
|
image: {{ .Values.postgresql.image }}
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
capabilities:
|
||||||
|
drop: [ALL]
|
||||||
|
env:
|
||||||
|
- name: POSTGRES_DB
|
||||||
|
value: archify
|
||||||
|
- name: POSTGRES_USER
|
||||||
|
value: archify
|
||||||
|
- name: POSTGRES_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ $secret }}
|
||||||
|
key: password
|
||||||
|
- name: PGDATA
|
||||||
|
value: /var/lib/postgresql/data/pgdata
|
||||||
|
ports:
|
||||||
|
- name: postgres
|
||||||
|
containerPort: 5432
|
||||||
|
readinessProbe:
|
||||||
|
exec:
|
||||||
|
command: [pg_isready, -U, archify, -d, archify]
|
||||||
|
periodSeconds: 5
|
||||||
|
volumeMounts:
|
||||||
|
- name: data
|
||||||
|
mountPath: /var/lib/postgresql/data
|
||||||
|
- name: run
|
||||||
|
mountPath: /var/run/postgresql
|
||||||
|
resources:
|
||||||
|
{{- toYaml .Values.postgresql.resources | nindent 12 }}
|
||||||
|
volumes:
|
||||||
|
- name: run
|
||||||
|
emptyDir: {}
|
||||||
|
volumeClaimTemplates:
|
||||||
|
- metadata:
|
||||||
|
name: data
|
||||||
|
spec:
|
||||||
|
accessModes: [ReadWriteOnce]
|
||||||
|
{{- with .Values.postgresql.storageClass }}
|
||||||
|
storageClassName: {{ . }}
|
||||||
|
{{- end }}
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: {{ .Values.postgresql.storage }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{{- if and (not .Values.postgresql.enabled) (not .Values.database.existingSecret) }}
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: {{ include "archify-sync.fullname" . }}-database
|
||||||
|
labels:
|
||||||
|
{{- include "archify-sync.labels" . | nindent 4 }}
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
url: {{ .Values.database.url | quote }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: {{ include "archify-sync.fullname" . }}
|
||||||
|
labels:
|
||||||
|
{{- include "archify-sync.labels" . | nindent 4 }}
|
||||||
|
spec:
|
||||||
|
type: {{ .Values.service.type }}
|
||||||
|
selector:
|
||||||
|
{{- include "archify-sync.selector" . | nindent 4 }}
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
port: {{ .Values.service.port }}
|
||||||
|
targetPort: http
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# Archify Sync runs as exactly one pod: the live copy of each space is held by
|
||||||
|
# one server (decision 0002). Updates are Recreate, so two never overlap; the
|
||||||
|
# extension works offline for the few seconds in between and catches up.
|
||||||
|
|
||||||
|
image:
|
||||||
|
repository: archify-sync
|
||||||
|
tag: "" # defaults to the chart's appVersion
|
||||||
|
pullPolicy: IfNotPresent
|
||||||
|
imagePullSecrets: []
|
||||||
|
|
||||||
|
# The address people open in the browser; invite links point here.
|
||||||
|
publicUrl: http://localhost:8080
|
||||||
|
|
||||||
|
database:
|
||||||
|
# postgres://user:password@host:5432/db. Ignored when existingSecret is set
|
||||||
|
# or the bundled postgresql is enabled.
|
||||||
|
url: ""
|
||||||
|
existingSecret: "" # a Secret with the URL under existingSecretKey
|
||||||
|
existingSecretKey: url
|
||||||
|
maxConnections: 10
|
||||||
|
|
||||||
|
# A single PostgreSQL in this release, for trying out and small installs.
|
||||||
|
# For anything you care about, point database at a managed or operator-run one.
|
||||||
|
postgresql:
|
||||||
|
enabled: false
|
||||||
|
image: postgres:17-alpine
|
||||||
|
password: "" # generated once and kept when empty (helm install/upgrade only)
|
||||||
|
# A Secret with `password` and `url` (postgres://archify:<password>@<release>-postgresql:5432/archify),
|
||||||
|
# e.g. from an ExternalSecret. Use it under Argo CD: it renders charts without
|
||||||
|
# cluster access, so a generated password would change on every sync.
|
||||||
|
existingSecret: ""
|
||||||
|
storage: 5Gi
|
||||||
|
storageClass: ""
|
||||||
|
resources: {}
|
||||||
|
|
||||||
|
config:
|
||||||
|
name: Archify Sync
|
||||||
|
snapshotIdleSecs: 30
|
||||||
|
snapshotMaxSecs: 600
|
||||||
|
snapshotMaxEdits: 2000
|
||||||
|
inviteHours: 168
|
||||||
|
membersCanInvite: false
|
||||||
|
accessTtlSecs: 900
|
||||||
|
refreshTtlDays: 30
|
||||||
|
refreshGraceSecs: 30
|
||||||
|
redirectSchemes: vscode,vscode-insiders,vscodium,cursor,windsurf
|
||||||
|
maxMessageBytes: 8388608
|
||||||
|
logJson: true
|
||||||
|
logLevel: info,tower_http=warn,sqlx=warn
|
||||||
|
|
||||||
|
# Single sign-on through an OpenID Connect provider (Keycloak, …). Register
|
||||||
|
# <publicUrl>/oidc/callback as a redirect URI of the provider's client; one
|
||||||
|
# client can list the callbacks of every place the server runs.
|
||||||
|
oidc:
|
||||||
|
enabled: false
|
||||||
|
issuer: "" # https://keycloak.example.com/realms/home
|
||||||
|
clientId: archify-sync
|
||||||
|
# The client secret, from a Secret (an ExternalSecret, for example).
|
||||||
|
existingSecret: ""
|
||||||
|
existingSecretKey: client-secret
|
||||||
|
label: Keycloak # "Sign in with Keycloak"
|
||||||
|
scopes: openid profile email
|
||||||
|
groupsClaim: groups
|
||||||
|
# Members of these groups get an account on first sign-in; others are
|
||||||
|
# refused. Empty: anyone the provider signs in, but new people need an invite.
|
||||||
|
allowedGroups: []
|
||||||
|
adminGroups: []
|
||||||
|
|
||||||
|
# Prometheus /metrics on a port of its own, never through the ingress.
|
||||||
|
metrics:
|
||||||
|
enabled: true
|
||||||
|
port: 9090
|
||||||
|
serviceMonitor:
|
||||||
|
enabled: false # needs the Prometheus Operator's CRDs
|
||||||
|
interval: 30s
|
||||||
|
|
||||||
|
extraEnv: []
|
||||||
|
|
||||||
|
service:
|
||||||
|
type: ClusterIP
|
||||||
|
port: 80
|
||||||
|
|
||||||
|
ingress:
|
||||||
|
enabled: false
|
||||||
|
className: ""
|
||||||
|
# Edits travel over a WebSocket that stays open for hours; raise the proxy's
|
||||||
|
# timeouts. For ingress-nginx:
|
||||||
|
# nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
|
||||||
|
# nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
|
||||||
|
# nginx.ingress.kubernetes.io/proxy-body-size: 8m
|
||||||
|
annotations: {}
|
||||||
|
host: archify-sync.example.com
|
||||||
|
tls: [] # [{ secretName: archify-sync-tls, hosts: [archify-sync.example.com] }]
|
||||||
|
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 50m
|
||||||
|
memory: 64Mi
|
||||||
|
limits:
|
||||||
|
memory: 512Mi
|
||||||
|
|
||||||
|
podAnnotations: {}
|
||||||
|
nodeSelector: {}
|
||||||
|
tolerations: []
|
||||||
|
affinity: {}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# A local Archify Sync with its database.
|
||||||
|
# docker compose up -d --build
|
||||||
|
# docker compose logs server | grep "with code" # the first-start code
|
||||||
|
# open http://localhost:8080/setup
|
||||||
|
|
||||||
|
name: archify-sync
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: archify
|
||||||
|
POSTGRES_USER: archify
|
||||||
|
POSTGRES_PASSWORD: ${ARCHIFY_DB_PASSWORD:-archify-local}
|
||||||
|
volumes:
|
||||||
|
- db:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U archify -d archify"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 20
|
||||||
|
|
||||||
|
server:
|
||||||
|
build: .
|
||||||
|
image: archify-sync:local
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
ARCHIFY_SYNC_DATABASE_URL: postgres://archify:${ARCHIFY_DB_PASSWORD:-archify-local}@db:5432/archify
|
||||||
|
ARCHIFY_SYNC_PUBLIC_URL: ${ARCHIFY_SYNC_PUBLIC_URL:-http://localhost:8080}
|
||||||
|
ARCHIFY_SYNC_SNAPSHOT_IDLE_SECS: ${ARCHIFY_SYNC_SNAPSHOT_IDLE_SECS:-30}
|
||||||
|
ARCHIFY_SYNC_LOG_JSON: "false"
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8080:8080"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
db:
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
[package]
|
||||||
|
name = "archify-sync-protocol"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
chrono = { version = "0.4.45", features = ["serde"] }
|
||||||
|
serde = { version = "1.0.229", features = ["derive"] }
|
||||||
|
ts-rs = { version = "12.0.1", features = ["uuid-impl", "chrono-impl"] }
|
||||||
|
uuid = { version = "1.29.0", features = ["serde", "v7"] }
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
$schema: 'https://moonrepo.dev/schemas/project.json'
|
||||||
|
language: rust
|
||||||
|
tasks:
|
||||||
|
check:
|
||||||
|
command: cargo clippy -p archify-sync-protocol --all-targets -- -D warnings
|
||||||
|
inputs: ['src/**/*', 'Cargo.toml']
|
||||||
|
test:
|
||||||
|
command: cargo test -p archify-sync-protocol
|
||||||
|
inputs: ['src/**/*', 'Cargo.toml']
|
||||||
|
format:
|
||||||
|
command: cargo fmt -p archify-sync-protocol --check
|
||||||
|
inputs: ['src/**/*']
|
||||||
|
# Writes the TypeScript types to packages/protocol/src/generated.
|
||||||
|
types:
|
||||||
|
command: cargo test -p archify-sync-protocol export_bindings
|
||||||
|
inputs: ['src/**/*']
|
||||||
|
outputs: ['/packages/protocol/src/generated/**/*']
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
//! What the sync server and the extension agree on: the shape of a space's
|
||||||
|
//! CRDT document and the HTTP API's JSON. The types are exported to
|
||||||
|
//! TypeScript (`packages/protocol`) with `cargo test -p archify-sync-protocol`.
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use ts_rs::TS;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Version of the wire protocol. A client sends it as `?v=` on the WebSocket
|
||||||
|
/// and the server refuses a version it does not speak.
|
||||||
|
pub const PROTOCOL_VERSION: u32 = 1;
|
||||||
|
|
||||||
|
/// Root map of a space's Yjs document: model-root-relative POSIX path → `Y.Text`.
|
||||||
|
pub const FILES_MAP: &str = "files";
|
||||||
|
|
||||||
|
/// Error body of every failed request.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub struct ApiError {
|
||||||
|
/// Stable code for clients (`unauthorized`, `not_found`, `invalid`, `conflict`, …).
|
||||||
|
pub error: String,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- accounts
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
#[ts(export)]
|
||||||
|
pub enum OrgRole {
|
||||||
|
Admin,
|
||||||
|
Member,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
#[ts(export)]
|
||||||
|
pub enum SpaceRole {
|
||||||
|
Owner,
|
||||||
|
Editor,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub struct Account {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub organisation_id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub role: OrgRole,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /v1/setup`: the first admin, with the code the server printed on its first start.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub struct SetupRequest {
|
||||||
|
pub code: String,
|
||||||
|
pub organisation: String,
|
||||||
|
pub name: String,
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /v1/auth/token`: the code from the browser sign-in, or a refresh token.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||||
|
#[serde(tag = "grant_type", rename_all = "snake_case")]
|
||||||
|
#[ts(export)]
|
||||||
|
pub enum TokenRequest {
|
||||||
|
AuthorizationCode {
|
||||||
|
code: String,
|
||||||
|
code_verifier: String,
|
||||||
|
redirect_uri: String,
|
||||||
|
},
|
||||||
|
RefreshToken {
|
||||||
|
refresh_token: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub struct TokenResponse {
|
||||||
|
pub access_token: String,
|
||||||
|
/// Seconds until the access token expires.
|
||||||
|
pub expires_in: u64,
|
||||||
|
/// Single use: every refresh returns a new one, and reusing an old one signs the session out.
|
||||||
|
pub refresh_token: String,
|
||||||
|
pub account: Account,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub struct LogoutRequest {
|
||||||
|
pub refresh_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ spaces
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub struct Space {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub role: SpaceRole,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub struct CreateSpaceRequest {
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub struct Member {
|
||||||
|
pub account_id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub role: SpaceRole,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub struct CreateInviteRequest {
|
||||||
|
/// Hours until the link stops working; the server's default when absent.
|
||||||
|
pub expires_in_hours: Option<u32>,
|
||||||
|
pub max_uses: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub struct Invite {
|
||||||
|
/// Shown once; the server keeps only its hash.
|
||||||
|
pub token: String,
|
||||||
|
/// The link to send: opens the server's join page.
|
||||||
|
pub url: String,
|
||||||
|
pub expires_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub struct JoinResponse {
|
||||||
|
pub space: Space,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- versions
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
#[ts(export)]
|
||||||
|
pub enum VersionKind {
|
||||||
|
/// Taken by the server after a pause in editing or when the last editor leaves.
|
||||||
|
Snapshot,
|
||||||
|
/// Marked by a person as an official version.
|
||||||
|
Publish,
|
||||||
|
/// A restore of an earlier version; history is never rewritten.
|
||||||
|
Restore,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub struct Version {
|
||||||
|
pub id: i64,
|
||||||
|
pub major: i32,
|
||||||
|
pub minor: i32,
|
||||||
|
pub kind: VersionKind,
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub created_by: Option<Uuid>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
/// For a restore: the version it restored.
|
||||||
|
pub restored_from: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub struct PublishRequest {
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The model files of a version as text, for comparing and showing.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub struct VersionFiles {
|
||||||
|
pub version: Version,
|
||||||
|
pub files: Vec<VersionFile>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub struct VersionFile {
|
||||||
|
pub path: String,
|
||||||
|
pub text: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /v1/info`: what a client needs before signing in.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub struct ServerInfo {
|
||||||
|
pub name: String,
|
||||||
|
pub version: String,
|
||||||
|
pub protocol: u32,
|
||||||
|
/// False until the first admin has used the first-start code.
|
||||||
|
pub set_up: bool,
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
[package]
|
||||||
|
name = "archify-sync-server"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1.0.104"
|
||||||
|
archify-sync-protocol = { version = "0.1.0", path = "../protocol" }
|
||||||
|
archify-sync-store = { version = "0.1.0", path = "../store" }
|
||||||
|
argon2 = "0.6.0"
|
||||||
|
axum = { version = "0.8.9", features = ["ws", "macros"] }
|
||||||
|
base64 = "0.23.1"
|
||||||
|
bytes = "1.12.1"
|
||||||
|
chrono = { version = "0.4.45", features = ["serde"] }
|
||||||
|
futures-util = "0.3.34"
|
||||||
|
jsonwebtoken = { version = "11.1.0", default-features = false, features = ["rust_crypto"] }
|
||||||
|
rand = "0.10.3"
|
||||||
|
reqwest = { version = "0.13.5", default-features = false, features = ["rustls-no-provider", "json", "form", "query"] }
|
||||||
|
rustls = { version = "0.23.46", default-features = false, features = ["ring", "std"] }
|
||||||
|
serde = { version = "1.0.229", features = ["derive"] }
|
||||||
|
serde_json = "1.0.154"
|
||||||
|
sha2 = "0.11.1"
|
||||||
|
thiserror = "2.0.21"
|
||||||
|
tokio = { version = "1.54.1", features = ["full"] }
|
||||||
|
tower-http = { version = "0.7.1", features = ["trace", "limit"] }
|
||||||
|
tracing = "0.1.44"
|
||||||
|
tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json"] }
|
||||||
|
url = "2.5.8"
|
||||||
|
uuid = { version = "1.29.0", features = ["serde", "v7"] }
|
||||||
|
yrs = { version = "0.28.1", features = ["sync"] }
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
$schema: 'https://moonrepo.dev/schemas/project.json'
|
||||||
|
language: rust
|
||||||
|
tasks:
|
||||||
|
check:
|
||||||
|
command: cargo clippy -p archify-sync-server --all-targets -- -D warnings
|
||||||
|
inputs: ['src/**/*', 'Cargo.toml']
|
||||||
|
test:
|
||||||
|
command: cargo test -p archify-sync-server
|
||||||
|
inputs: ['src/**/*', 'Cargo.toml']
|
||||||
|
format:
|
||||||
|
command: cargo fmt -p archify-sync-server --check
|
||||||
|
inputs: ['src/**/*']
|
||||||
|
dependsOn: [protocol, store]
|
||||||
@@ -0,0 +1,430 @@
|
|||||||
|
//! The JSON API under `/v1` and the WebSocket.
|
||||||
|
|
||||||
|
use archify_sync_protocol::{
|
||||||
|
CreateInviteRequest, CreateSpaceRequest, Invite, JoinResponse, LogoutRequest, Member,
|
||||||
|
PROTOCOL_VERSION, PublishRequest, ServerInfo, SetupRequest, Space, SpaceRole, TokenRequest,
|
||||||
|
TokenResponse, Version, VersionFile, VersionFiles, VersionKind,
|
||||||
|
};
|
||||||
|
use archify_sync_store::{InviteOutcome, MemberRow, SpaceRow, VersionRow};
|
||||||
|
use axum::Json;
|
||||||
|
use axum::extract::{Path, Query, State, WebSocketUpgrade};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::Response;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::auth::{self, Authed};
|
||||||
|
use crate::doc;
|
||||||
|
use crate::error::{ApiErr, ApiResult};
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
pub async fn info(State(state): State<AppState>) -> ApiResult<Json<ServerInfo>> {
|
||||||
|
Ok(Json(ServerInfo {
|
||||||
|
name: state.config.server_name.clone(),
|
||||||
|
version: env!("CARGO_PKG_VERSION").into(),
|
||||||
|
protocol: PROTOCOL_VERSION,
|
||||||
|
set_up: state.first_start.lock().await.is_none(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn healthz() -> &'static str {
|
||||||
|
"ok"
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn readyz(State(state): State<AppState>) -> ApiResult<&'static str> {
|
||||||
|
state.store.ping().await?;
|
||||||
|
Ok("ok")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- accounts
|
||||||
|
|
||||||
|
/// Creates the first admin with the first-start code. Shared with the setup page.
|
||||||
|
pub async fn setup_admin(
|
||||||
|
state: &AppState,
|
||||||
|
req: &SetupRequest,
|
||||||
|
) -> ApiResult<archify_sync_store::AccountRow> {
|
||||||
|
let mut first_start = state.first_start.lock().await;
|
||||||
|
let Some(code) = first_start.as_ref() else {
|
||||||
|
return Err(ApiErr::conflict("This server is set up already."));
|
||||||
|
};
|
||||||
|
if !auth::same_secret(&req.code.trim().to_ascii_uppercase(), code) {
|
||||||
|
return Err(ApiErr::unauthorized(
|
||||||
|
"The first-start code does not match. It is in the server's log.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
auth::check_name(&req.name)?;
|
||||||
|
auth::check_new_password(&req.password)?;
|
||||||
|
if req.organisation.trim().is_empty() {
|
||||||
|
return Err(ApiErr::invalid("Name the organisation."));
|
||||||
|
}
|
||||||
|
let hash = auth::hash_password(&req.password)?;
|
||||||
|
let display = req
|
||||||
|
.display_name
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or(&req.name);
|
||||||
|
let account = state
|
||||||
|
.store
|
||||||
|
.create_first_admin(req.organisation.trim(), &req.name, display, &hash)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| ApiErr::conflict("This server is set up already."))?;
|
||||||
|
*first_start = None;
|
||||||
|
tracing::info!(account = %account.id, "first admin set up");
|
||||||
|
Ok(account)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn setup(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(req): Json<SetupRequest>,
|
||||||
|
) -> ApiResult<Json<TokenResponse>> {
|
||||||
|
let account = setup_admin(&state, &req).await?;
|
||||||
|
Ok(Json(auth::issue_tokens(&state, &account, None).await?))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn token(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(req): Json<TokenRequest>,
|
||||||
|
) -> ApiResult<Json<TokenResponse>> {
|
||||||
|
match req {
|
||||||
|
TokenRequest::AuthorizationCode {
|
||||||
|
code,
|
||||||
|
code_verifier,
|
||||||
|
redirect_uri,
|
||||||
|
} => {
|
||||||
|
let account_id = state
|
||||||
|
.codes
|
||||||
|
.take(&code, &code_verifier, &redirect_uri)
|
||||||
|
.await
|
||||||
|
.ok_or_else(|| {
|
||||||
|
ApiErr::unauthorized("The sign-in code is not valid. Sign in again.")
|
||||||
|
})?;
|
||||||
|
let account = state
|
||||||
|
.store
|
||||||
|
.account(account_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| ApiErr::unauthorized("Unknown account."))?;
|
||||||
|
Ok(Json(auth::issue_tokens(&state, &account, None).await?))
|
||||||
|
}
|
||||||
|
TokenRequest::RefreshToken { refresh_token } => {
|
||||||
|
Ok(Json(auth::refresh(&state, &refresh_token).await?))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn logout(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(req): Json<LogoutRequest>,
|
||||||
|
) -> ApiResult<StatusCode> {
|
||||||
|
state
|
||||||
|
.store
|
||||||
|
.revoke_refresh(&auth::sha256(&req.refresh_token))
|
||||||
|
.await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn me(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
authed: Authed,
|
||||||
|
) -> ApiResult<Json<archify_sync_protocol::Account>> {
|
||||||
|
let account = state
|
||||||
|
.store
|
||||||
|
.account(authed.account)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| ApiErr::unauthorized("Unknown account."))?;
|
||||||
|
Ok(Json(auth::account_dto(&account)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ spaces
|
||||||
|
|
||||||
|
fn space_dto(row: SpaceRow) -> Space {
|
||||||
|
Space {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
role: role(&row.role),
|
||||||
|
created_at: row.created_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn role(name: &str) -> SpaceRole {
|
||||||
|
if name == "owner" {
|
||||||
|
SpaceRole::Owner
|
||||||
|
} else {
|
||||||
|
SpaceRole::Editor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn member_dto(row: MemberRow) -> Member {
|
||||||
|
Member {
|
||||||
|
account_id: row.account_id,
|
||||||
|
name: row.name,
|
||||||
|
display_name: row.display_name,
|
||||||
|
role: role(&row.role),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn version_dto(row: VersionRow) -> Version {
|
||||||
|
Version {
|
||||||
|
id: row.id,
|
||||||
|
major: row.major,
|
||||||
|
minor: row.minor,
|
||||||
|
kind: match row.kind.as_str() {
|
||||||
|
"publish" => VersionKind::Publish,
|
||||||
|
"restore" => VersionKind::Restore,
|
||||||
|
_ => VersionKind::Snapshot,
|
||||||
|
},
|
||||||
|
name: row.name,
|
||||||
|
created_by: row.created_by,
|
||||||
|
created_at: row.created_at,
|
||||||
|
restored_from: row.restored_from,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The space as the caller sees it; not found when the caller is no member.
|
||||||
|
async fn member_space(state: &AppState, authed: &Authed, space: Uuid) -> ApiResult<SpaceRow> {
|
||||||
|
state
|
||||||
|
.store
|
||||||
|
.space_for(space, authed.account)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| ApiErr::not_found("No such space, or you are not a member."))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_spaces(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
authed: Authed,
|
||||||
|
) -> ApiResult<Json<Vec<Space>>> {
|
||||||
|
let rows = state.store.spaces_of(authed.account).await?;
|
||||||
|
Ok(Json(rows.into_iter().map(space_dto).collect()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_space(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
authed: Authed,
|
||||||
|
Json(req): Json<CreateSpaceRequest>,
|
||||||
|
) -> ApiResult<(StatusCode, Json<Space>)> {
|
||||||
|
let name = req.name.trim();
|
||||||
|
if name.is_empty() || name.len() > 200 {
|
||||||
|
return Err(ApiErr::invalid("A space name has 1 to 200 characters."));
|
||||||
|
}
|
||||||
|
let row = state
|
||||||
|
.store
|
||||||
|
.create_space(authed.organisation, name, authed.account)
|
||||||
|
.await?;
|
||||||
|
Ok((StatusCode::CREATED, Json(space_dto(row))))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_space(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
authed: Authed,
|
||||||
|
Path(space): Path<Uuid>,
|
||||||
|
) -> ApiResult<Json<Space>> {
|
||||||
|
Ok(Json(space_dto(member_space(&state, &authed, space).await?)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn members(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
authed: Authed,
|
||||||
|
Path(space): Path<Uuid>,
|
||||||
|
) -> ApiResult<Json<Vec<Member>>> {
|
||||||
|
member_space(&state, &authed, space).await?;
|
||||||
|
Ok(Json(
|
||||||
|
state
|
||||||
|
.store
|
||||||
|
.members(space)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(member_dto)
|
||||||
|
.collect(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove_member(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
authed: Authed,
|
||||||
|
Path((space, account)): Path<(Uuid, Uuid)>,
|
||||||
|
) -> ApiResult<StatusCode> {
|
||||||
|
let row = member_space(&state, &authed, space).await?;
|
||||||
|
// Anyone may leave; only owners and admins remove others.
|
||||||
|
if account != authed.account && row.role != "owner" && !authed.admin {
|
||||||
|
return Err(ApiErr::forbidden(
|
||||||
|
"Only the owner or an admin removes members.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if state.store.remove_member(space, account).await? {
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
} else {
|
||||||
|
Err(ApiErr::not_found("No such member, or it is the owner."))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_invite(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
authed: Authed,
|
||||||
|
Path(space): Path<Uuid>,
|
||||||
|
Json(req): Json<CreateInviteRequest>,
|
||||||
|
) -> ApiResult<(StatusCode, Json<Invite>)> {
|
||||||
|
let row = member_space(&state, &authed, space).await?;
|
||||||
|
if row.role != "owner" && !authed.admin && !state.config.members_can_invite {
|
||||||
|
return Err(ApiErr::forbidden(
|
||||||
|
"Only the owner or an admin invites people to this space.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let hours = req
|
||||||
|
.expires_in_hours
|
||||||
|
.unwrap_or(state.config.invite_hours)
|
||||||
|
.clamp(1, 24 * 90);
|
||||||
|
let expires_at = chrono::Utc::now() + chrono::Duration::hours(i64::from(hours));
|
||||||
|
let max_uses = req.max_uses.map(|n| n.clamp(1, 10_000) as i32);
|
||||||
|
let token = auth::random_token();
|
||||||
|
state
|
||||||
|
.store
|
||||||
|
.create_invite(
|
||||||
|
&auth::sha256(&token),
|
||||||
|
space,
|
||||||
|
authed.account,
|
||||||
|
expires_at,
|
||||||
|
max_uses,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let url = format!("{}/join/{token}", state.config.public_url);
|
||||||
|
Ok((
|
||||||
|
StatusCode::CREATED,
|
||||||
|
Json(Invite {
|
||||||
|
token,
|
||||||
|
url,
|
||||||
|
expires_at,
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn accept_invite(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
authed: Authed,
|
||||||
|
Path(token): Path<String>,
|
||||||
|
) -> ApiResult<Json<JoinResponse>> {
|
||||||
|
let account = state
|
||||||
|
.store
|
||||||
|
.account(authed.account)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| ApiErr::unauthorized("Unknown account."))?;
|
||||||
|
match state
|
||||||
|
.store
|
||||||
|
.accept_invite(&auth::sha256(&token), &account)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
InviteOutcome::Joined { space_id } => {
|
||||||
|
let row = member_space(&state, &authed, space_id).await?;
|
||||||
|
Ok(Json(JoinResponse {
|
||||||
|
space: space_dto(row),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
InviteOutcome::Invalid => Err(ApiErr::not_found(
|
||||||
|
"This invite link has expired or was used up.",
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- versions
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct Limit {
|
||||||
|
limit: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn versions(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
authed: Authed,
|
||||||
|
Path(space): Path<Uuid>,
|
||||||
|
Query(q): Query<Limit>,
|
||||||
|
) -> ApiResult<Json<Vec<Version>>> {
|
||||||
|
member_space(&state, &authed, space).await?;
|
||||||
|
let rows = state
|
||||||
|
.store
|
||||||
|
.versions(space, q.limit.unwrap_or(100).clamp(1, 1000))
|
||||||
|
.await?;
|
||||||
|
Ok(Json(rows.into_iter().map(version_dto).collect()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn publish(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
authed: Authed,
|
||||||
|
Path(space): Path<Uuid>,
|
||||||
|
Json(req): Json<PublishRequest>,
|
||||||
|
) -> ApiResult<(StatusCode, Json<Version>)> {
|
||||||
|
member_space(&state, &authed, space).await?;
|
||||||
|
let name = req.name.trim();
|
||||||
|
if name.is_empty() || name.len() > 200 {
|
||||||
|
return Err(ApiErr::invalid("Name the version (1 to 200 characters)."));
|
||||||
|
}
|
||||||
|
let row = state
|
||||||
|
.hub
|
||||||
|
.publish(space, name, authed.account)
|
||||||
|
.await
|
||||||
|
.map_err(ApiErr::internal)?;
|
||||||
|
Ok((StatusCode::CREATED, Json(version_dto(row))))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn version_files(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
authed: Authed,
|
||||||
|
Path((space, version)): Path<(Uuid, i64)>,
|
||||||
|
) -> ApiResult<Json<VersionFiles>> {
|
||||||
|
member_space(&state, &authed, space).await?;
|
||||||
|
let (row, encoded) = state
|
||||||
|
.store
|
||||||
|
.version(space, version)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| ApiErr::not_found("No such version."))?;
|
||||||
|
let files = doc::files(&doc::from_state(&encoded).map_err(ApiErr::internal)?)
|
||||||
|
.into_iter()
|
||||||
|
.map(|(path, text)| VersionFile { path, text })
|
||||||
|
.collect();
|
||||||
|
Ok(Json(VersionFiles {
|
||||||
|
version: version_dto(row),
|
||||||
|
files,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn restore(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
authed: Authed,
|
||||||
|
Path((space, version)): Path<(Uuid, i64)>,
|
||||||
|
) -> ApiResult<(StatusCode, Json<Version>)> {
|
||||||
|
member_space(&state, &authed, space).await?;
|
||||||
|
let row = state
|
||||||
|
.hub
|
||||||
|
.restore(space, version, authed.account)
|
||||||
|
.await
|
||||||
|
.map_err(ApiErr::internal)?
|
||||||
|
.ok_or_else(|| ApiErr::not_found("No such version."))?;
|
||||||
|
Ok((StatusCode::CREATED, Json(version_dto(row))))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------- live
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct SyncQuery {
|
||||||
|
v: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /v1/spaces/{id}/sync`: the y-websocket protocol for one space.
|
||||||
|
pub async fn sync(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
authed: Authed,
|
||||||
|
Path(space): Path<Uuid>,
|
||||||
|
Query(q): Query<SyncQuery>,
|
||||||
|
ws: WebSocketUpgrade,
|
||||||
|
) -> ApiResult<Response> {
|
||||||
|
if q.v.unwrap_or(PROTOCOL_VERSION) != PROTOCOL_VERSION {
|
||||||
|
return Err(ApiErr::new(
|
||||||
|
StatusCode::UPGRADE_REQUIRED,
|
||||||
|
"protocol",
|
||||||
|
format!("This server speaks protocol {PROTOCOL_VERSION}. Update the extension."),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
member_space(&state, &authed, space).await?;
|
||||||
|
let hub = state.hub.clone();
|
||||||
|
Ok(ws
|
||||||
|
.max_message_size(state.config.max_message_bytes)
|
||||||
|
.on_upgrade(move |socket| hub.serve(socket, space, authed.account)))
|
||||||
|
}
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
//! Passwords, tokens and sign-in codes.
|
||||||
|
//!
|
||||||
|
//! Access tokens are short JWTs signed with a key kept in the database, so
|
||||||
|
//! every replica accepts them. Refresh tokens are random, stored as hashes,
|
||||||
|
//! single use, and grouped in a family: presenting a used one signs out the
|
||||||
|
//! whole session. The browser sign-in hands a one-time code to the editor's
|
||||||
|
//! URI handler; the editor exchanges it with its PKCE verifier. Repeated
|
||||||
|
//! wrong passwords for one name lock that name out for a while.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use archify_sync_protocol::{Account, OrgRole, TokenResponse};
|
||||||
|
use archify_sync_store::{AccountRow, RefreshOutcome};
|
||||||
|
use argon2::Argon2;
|
||||||
|
use argon2::password_hash::{PasswordHasher, PasswordVerifier, phc::PasswordHash};
|
||||||
|
use axum::extract::FromRequestParts;
|
||||||
|
use axum::http::header::AUTHORIZATION;
|
||||||
|
use axum::http::request::Parts;
|
||||||
|
use base64::Engine;
|
||||||
|
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||||
|
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::error::{ApiErr, ApiResult};
|
||||||
|
use crate::metrics::{METRICS, Metrics};
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
const ISSUER: &str = "archify-sync";
|
||||||
|
const CODE_TTL: Duration = Duration::from_secs(120);
|
||||||
|
|
||||||
|
pub fn hash_password(password: &str) -> ApiResult<String> {
|
||||||
|
Argon2::default()
|
||||||
|
.hash_password(password.as_bytes())
|
||||||
|
.map(|h| h.to_string())
|
||||||
|
.map_err(ApiErr::internal)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn verify_password(password: &str, hash: &str) -> bool {
|
||||||
|
PasswordHash::new(hash).is_ok_and(|parsed| {
|
||||||
|
Argon2::default()
|
||||||
|
.verify_password(password.as_bytes(), &parsed)
|
||||||
|
.is_ok()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks the rules for a new password; the message is shown to the person.
|
||||||
|
pub fn check_new_password(password: &str) -> ApiResult<()> {
|
||||||
|
if password.chars().count() < 10 {
|
||||||
|
return Err(ApiErr::invalid(
|
||||||
|
"The password needs at least 10 characters.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn check_name(name: &str) -> ApiResult<()> {
|
||||||
|
let ok = (2..=64).contains(&name.len())
|
||||||
|
&& name
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '@'));
|
||||||
|
if ok {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(ApiErr::invalid(
|
||||||
|
"A sign-in name has 2 to 64 letters, digits or . _ - @.",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A random token for links and sessions, URL safe.
|
||||||
|
pub fn random_token() -> String {
|
||||||
|
let mut bytes = [0u8; 32];
|
||||||
|
rand::fill(&mut bytes);
|
||||||
|
URL_SAFE_NO_PAD.encode(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sha256(text: &str) -> Vec<u8> {
|
||||||
|
Sha256::digest(text.as_bytes()).to_vec()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Constant-time comparison of two secrets.
|
||||||
|
pub fn same_secret(a: &str, b: &str) -> bool {
|
||||||
|
let (a, b) = (sha256(a), sha256(b));
|
||||||
|
a.iter().zip(&b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The code a new server prints, for its first admin: `ABCD-EFGH-JKLM`.
|
||||||
|
pub fn first_start_code() -> String {
|
||||||
|
const ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||||
|
let mut bytes = [0u8; 12];
|
||||||
|
rand::fill(&mut bytes);
|
||||||
|
let chars: Vec<char> = bytes
|
||||||
|
.iter()
|
||||||
|
.map(|b| ALPHABET[(*b as usize) % ALPHABET.len()] as char)
|
||||||
|
.collect();
|
||||||
|
chars
|
||||||
|
.chunks(4)
|
||||||
|
.map(|c| c.iter().collect::<String>())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("-")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn account_dto(row: &AccountRow) -> Account {
|
||||||
|
Account {
|
||||||
|
id: row.id,
|
||||||
|
organisation_id: row.organisation_id,
|
||||||
|
name: row.name.clone(),
|
||||||
|
display_name: row.display_name.clone(),
|
||||||
|
role: if row.role == "admin" {
|
||||||
|
OrgRole::Admin
|
||||||
|
} else {
|
||||||
|
OrgRole::Member
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct Claims {
|
||||||
|
sub: Uuid,
|
||||||
|
org: Uuid,
|
||||||
|
role: String,
|
||||||
|
iss: String,
|
||||||
|
iat: u64,
|
||||||
|
exp: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Keys {
|
||||||
|
encoding: EncodingKey,
|
||||||
|
decoding: DecodingKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Keys {
|
||||||
|
pub fn from_secret(secret: &[u8]) -> Self {
|
||||||
|
Self {
|
||||||
|
encoding: EncodingKey::from_secret(secret),
|
||||||
|
decoding: DecodingKey::from_secret(secret),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A request signed in with a valid access token.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Authed {
|
||||||
|
pub account: Uuid,
|
||||||
|
pub organisation: Uuid,
|
||||||
|
pub admin: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromRequestParts<AppState> for Authed {
|
||||||
|
type Rejection = ApiErr;
|
||||||
|
|
||||||
|
async fn from_request_parts(
|
||||||
|
parts: &mut Parts,
|
||||||
|
state: &AppState,
|
||||||
|
) -> Result<Self, Self::Rejection> {
|
||||||
|
let header = parts
|
||||||
|
.headers
|
||||||
|
.get(AUTHORIZATION)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.ok_or_else(|| ApiErr::unauthorized("Sign in first."))?;
|
||||||
|
let token = header
|
||||||
|
.strip_prefix("Bearer ")
|
||||||
|
.ok_or_else(|| ApiErr::unauthorized("Expected a bearer token."))?;
|
||||||
|
let mut validation = Validation::new(Algorithm::HS256);
|
||||||
|
validation.set_issuer(&[ISSUER]);
|
||||||
|
let data = jsonwebtoken::decode::<Claims>(token, &state.keys.decoding, &validation)
|
||||||
|
.map_err(|_| ApiErr::unauthorized("The sign-in has expired or is not valid."))?;
|
||||||
|
let c = data.claims;
|
||||||
|
Ok(Authed {
|
||||||
|
account: c.sub,
|
||||||
|
organisation: c.org,
|
||||||
|
admin: c.role == "admin",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A new access token and refresh token, in the given session family.
|
||||||
|
pub async fn issue_tokens(
|
||||||
|
state: &AppState,
|
||||||
|
account: &AccountRow,
|
||||||
|
family: Option<Uuid>,
|
||||||
|
) -> ApiResult<TokenResponse> {
|
||||||
|
let now = jsonwebtoken::get_current_timestamp();
|
||||||
|
let ttl = state.config.access_ttl.as_secs();
|
||||||
|
let claims = Claims {
|
||||||
|
sub: account.id,
|
||||||
|
org: account.organisation_id,
|
||||||
|
role: account.role.clone(),
|
||||||
|
iss: ISSUER.into(),
|
||||||
|
iat: now,
|
||||||
|
exp: now + ttl,
|
||||||
|
};
|
||||||
|
let access_token = jsonwebtoken::encode(
|
||||||
|
&Header::new(Algorithm::HS256),
|
||||||
|
&claims,
|
||||||
|
&state.keys.encoding,
|
||||||
|
)
|
||||||
|
.map_err(ApiErr::internal)?;
|
||||||
|
let refresh_token = random_token();
|
||||||
|
let expires_at = chrono::Utc::now() + state.config.refresh_ttl;
|
||||||
|
state
|
||||||
|
.store
|
||||||
|
.insert_refresh(
|
||||||
|
&sha256(&refresh_token),
|
||||||
|
family.unwrap_or_else(Uuid::now_v7),
|
||||||
|
account.id,
|
||||||
|
expires_at,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(TokenResponse {
|
||||||
|
access_token,
|
||||||
|
expires_in: ttl,
|
||||||
|
refresh_token,
|
||||||
|
account: account_dto(account),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn refresh(state: &AppState, refresh_token: &str) -> ApiResult<TokenResponse> {
|
||||||
|
match state
|
||||||
|
.store
|
||||||
|
.use_refresh(&sha256(refresh_token), state.config.refresh_grace_secs)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
RefreshOutcome::Valid { account, family } => {
|
||||||
|
issue_tokens(state, &account, Some(family)).await
|
||||||
|
}
|
||||||
|
RefreshOutcome::Reused => {
|
||||||
|
Metrics::inc(&METRICS.refresh_reuse_total);
|
||||||
|
tracing::warn!("a used refresh token was presented again; its session is signed out");
|
||||||
|
Err(ApiErr::unauthorized(
|
||||||
|
"This session was signed out. Sign in again.",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
RefreshOutcome::Invalid => Err(ApiErr::unauthorized(
|
||||||
|
"The session has expired. Sign in again.",
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sign-in codes waiting for the editor to exchange them.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct PendingCodes(Mutex<HashMap<String, PendingCode>>);
|
||||||
|
|
||||||
|
struct PendingCode {
|
||||||
|
account: Uuid,
|
||||||
|
challenge: String,
|
||||||
|
redirect_uri: String,
|
||||||
|
created: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PendingCodes {
|
||||||
|
pub async fn insert(&self, account: Uuid, challenge: String, redirect_uri: String) -> String {
|
||||||
|
let code = random_token();
|
||||||
|
let mut codes = self.0.lock().await;
|
||||||
|
codes.retain(|_, c| c.created.elapsed() < CODE_TTL);
|
||||||
|
codes.insert(
|
||||||
|
code.clone(),
|
||||||
|
PendingCode {
|
||||||
|
account,
|
||||||
|
challenge,
|
||||||
|
redirect_uri,
|
||||||
|
created: Instant::now(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
code
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The account behind a code, if the code is fresh and the verifier and redirect match. One use only.
|
||||||
|
pub async fn take(&self, code: &str, verifier: &str, redirect_uri: &str) -> Option<Uuid> {
|
||||||
|
let pending = self.0.lock().await.remove(code)?;
|
||||||
|
let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()));
|
||||||
|
(pending.created.elapsed() < CODE_TTL
|
||||||
|
&& pending.redirect_uri == redirect_uri
|
||||||
|
&& same_secret(&challenge, &pending.challenge))
|
||||||
|
.then_some(pending.account)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the browser may send a sign-in code to this URI: the extension's
|
||||||
|
/// handler in a known editor, or a loopback address.
|
||||||
|
pub fn redirect_allowed(state: &AppState, uri: &str) -> bool {
|
||||||
|
let Ok(url) = url::Url::parse(uri) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let host = url.host_str().unwrap_or_default().to_ascii_lowercase();
|
||||||
|
match url.scheme() {
|
||||||
|
"http" => host == "127.0.0.1" || host == "localhost" || host == "[::1]",
|
||||||
|
scheme => {
|
||||||
|
state.config.redirect_schemes.iter().any(|s| s == scheme)
|
||||||
|
&& host == state.config.extension_id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wrong passwords per account name. After `SIGN_IN_FAILURES` within
|
||||||
|
/// `SIGN_IN_WINDOW`, the name is refused until the window has passed.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct SignInLimits(Mutex<HashMap<String, (u32, Instant)>>);
|
||||||
|
|
||||||
|
const SIGN_IN_FAILURES: u32 = 10;
|
||||||
|
const SIGN_IN_WINDOW: Duration = Duration::from_secs(15 * 60);
|
||||||
|
|
||||||
|
impl SignInLimits {
|
||||||
|
pub async fn blocked(&self, name: &str) -> bool {
|
||||||
|
let mut limits = self.0.lock().await;
|
||||||
|
limits.retain(|_, (_, first)| first.elapsed() < SIGN_IN_WINDOW);
|
||||||
|
limits
|
||||||
|
.get(&name.to_lowercase())
|
||||||
|
.is_some_and(|(failures, _)| *failures >= SIGN_IN_FAILURES)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn failed(&self, name: &str) {
|
||||||
|
let mut limits = self.0.lock().await;
|
||||||
|
let entry = limits
|
||||||
|
.entry(name.to_lowercase())
|
||||||
|
.or_insert((0, Instant::now()));
|
||||||
|
entry.0 += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn succeeded(&self, name: &str) {
|
||||||
|
self.0.lock().await.remove(&name.to_lowercase());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
//! Settings from the environment. Every variable starts with `ARCHIFY_SYNC_`.
|
||||||
|
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
|
||||||
|
use crate::hub::SnapshotPolicy;
|
||||||
|
use crate::oidc::OidcConfig;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Config {
|
||||||
|
pub database_url: String,
|
||||||
|
pub database_max_connections: u32,
|
||||||
|
pub listen: SocketAddr,
|
||||||
|
/// Where `/metrics` is served; off when unset.
|
||||||
|
pub metrics_listen: Option<SocketAddr>,
|
||||||
|
/// The address people reach the server at; invite links point here.
|
||||||
|
pub public_url: String,
|
||||||
|
pub server_name: String,
|
||||||
|
pub snapshots: SnapshotPolicy,
|
||||||
|
pub invite_hours: u32,
|
||||||
|
/// Whether editors may create invite links too, not only owners and admins.
|
||||||
|
pub members_can_invite: bool,
|
||||||
|
pub access_ttl: Duration,
|
||||||
|
pub refresh_ttl: Duration,
|
||||||
|
/// A refresh token used again within this many seconds is not treated as stolen.
|
||||||
|
pub refresh_grace_secs: u32,
|
||||||
|
/// URI schemes of the editors that may receive a sign-in code (`vscode`, `cursor`, …).
|
||||||
|
pub redirect_schemes: Vec<String>,
|
||||||
|
/// The extension's id, the authority of its callback URI.
|
||||||
|
pub extension_id: String,
|
||||||
|
pub max_message_bytes: usize,
|
||||||
|
/// Single sign-on, when `ARCHIFY_SYNC_OIDC_ISSUER` is set.
|
||||||
|
pub oidc: Option<OidcConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn from_env() -> Result<Self> {
|
||||||
|
let listen: SocketAddr = var("LISTEN", "0.0.0.0:8080")
|
||||||
|
.parse()
|
||||||
|
.context("ARCHIFY_SYNC_LISTEN")?;
|
||||||
|
let public_url = var("PUBLIC_URL", &format!("http://localhost:{}", listen.port()));
|
||||||
|
Ok(Self {
|
||||||
|
database_url: std::env::var("ARCHIFY_SYNC_DATABASE_URL")
|
||||||
|
.or_else(|_| std::env::var("DATABASE_URL"))
|
||||||
|
.context("ARCHIFY_SYNC_DATABASE_URL is not set")?,
|
||||||
|
database_max_connections: number("DATABASE_MAX_CONNECTIONS", 10)?,
|
||||||
|
listen,
|
||||||
|
metrics_listen: std::env::var("ARCHIFY_SYNC_METRICS_LISTEN")
|
||||||
|
.ok()
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
.map(|v| v.parse())
|
||||||
|
.transpose()
|
||||||
|
.context("ARCHIFY_SYNC_METRICS_LISTEN")?,
|
||||||
|
public_url: public_url.trim_end_matches('/').to_owned(),
|
||||||
|
server_name: var("NAME", "Archify Sync"),
|
||||||
|
snapshots: SnapshotPolicy {
|
||||||
|
idle: Duration::from_secs(number("SNAPSHOT_IDLE_SECS", 30)?),
|
||||||
|
max_age: Duration::from_secs(number("SNAPSHOT_MAX_SECS", 600)?),
|
||||||
|
max_edits: number("SNAPSHOT_MAX_EDITS", 2000)?,
|
||||||
|
},
|
||||||
|
invite_hours: number("INVITE_HOURS", 168)?,
|
||||||
|
members_can_invite: var("MEMBERS_CAN_INVITE", "false") == "true",
|
||||||
|
access_ttl: Duration::from_secs(number("ACCESS_TTL_SECS", 900)?),
|
||||||
|
refresh_ttl: Duration::from_secs(number::<u64>("REFRESH_TTL_DAYS", 30)? * 86_400),
|
||||||
|
refresh_grace_secs: number("REFRESH_GRACE_SECS", 30)?,
|
||||||
|
redirect_schemes: var(
|
||||||
|
"REDIRECT_SCHEMES",
|
||||||
|
"vscode,vscode-insiders,vscodium,cursor,windsurf",
|
||||||
|
)
|
||||||
|
.split(',')
|
||||||
|
.map(|s| s.trim().to_ascii_lowercase())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.collect(),
|
||||||
|
extension_id: var("EXTENSION_ID", "root-at-skic.archify-vscode-ext")
|
||||||
|
.to_ascii_lowercase(),
|
||||||
|
max_message_bytes: number("MAX_MESSAGE_BYTES", 8 * 1024 * 1024)?,
|
||||||
|
oidc: oidc_from_env()?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list(name: &str) -> Vec<String> {
|
||||||
|
var(name, "")
|
||||||
|
.split(',')
|
||||||
|
.map(|s| s.trim().trim_start_matches('/').to_owned())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn oidc_from_env() -> Result<Option<OidcConfig>> {
|
||||||
|
let issuer = var("OIDC_ISSUER", "");
|
||||||
|
if issuer.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let parsed = url::Url::parse(&issuer).context("ARCHIFY_SYNC_OIDC_ISSUER")?;
|
||||||
|
let loopback = matches!(parsed.host_str(), Some("localhost" | "127.0.0.1" | "[::1]"));
|
||||||
|
anyhow::ensure!(
|
||||||
|
parsed.scheme() == "https" || (parsed.scheme() == "http" && loopback),
|
||||||
|
"ARCHIFY_SYNC_OIDC_ISSUER must be https (http only on this machine)"
|
||||||
|
);
|
||||||
|
let client_id = var("OIDC_CLIENT_ID", "");
|
||||||
|
anyhow::ensure!(
|
||||||
|
!client_id.is_empty(),
|
||||||
|
"ARCHIFY_SYNC_OIDC_CLIENT_ID is not set"
|
||||||
|
);
|
||||||
|
// The secret from the environment, or from a mounted file.
|
||||||
|
let client_secret = match std::env::var("ARCHIFY_SYNC_OIDC_CLIENT_SECRET") {
|
||||||
|
Ok(secret) if !secret.is_empty() => Some(secret),
|
||||||
|
_ => match std::env::var("ARCHIFY_SYNC_OIDC_CLIENT_SECRET_FILE") {
|
||||||
|
Ok(path) if !path.is_empty() => Some(
|
||||||
|
std::fs::read_to_string(&path)
|
||||||
|
.with_context(|| format!("ARCHIFY_SYNC_OIDC_CLIENT_SECRET_FILE {path}"))?
|
||||||
|
.trim()
|
||||||
|
.to_owned(),
|
||||||
|
),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
Ok(Some(OidcConfig {
|
||||||
|
issuer: issuer.trim_end_matches('/').to_owned(),
|
||||||
|
client_id,
|
||||||
|
client_secret,
|
||||||
|
label: var("OIDC_LABEL", "single sign-on"),
|
||||||
|
scopes: var("OIDC_SCOPES", "openid profile email"),
|
||||||
|
groups_claim: var("OIDC_GROUPS_CLAIM", "groups"),
|
||||||
|
allowed_groups: list("OIDC_ALLOWED_GROUPS"),
|
||||||
|
admin_groups: list("OIDC_ADMIN_GROUPS"),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn var(name: &str, default: &str) -> String {
|
||||||
|
std::env::var(format!("ARCHIFY_SYNC_{name}")).unwrap_or_else(|_| default.to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn number<T: std::str::FromStr>(name: &str, default: T) -> Result<T>
|
||||||
|
where
|
||||||
|
T::Err: std::error::Error + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
match std::env::var(format!("ARCHIFY_SYNC_{name}")) {
|
||||||
|
Ok(v) => v.parse().with_context(|| format!("ARCHIFY_SYNC_{name}")),
|
||||||
|
Err(_) => Ok(default),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
//! A space's Yjs document: one `Y.Text` per model file in the `files` map.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use archify_sync_protocol::FILES_MAP;
|
||||||
|
use archify_sync_store::StoredDoc;
|
||||||
|
use yrs::updates::decoder::Decode;
|
||||||
|
use yrs::{
|
||||||
|
Doc, GetString, Map, OffsetKind, Options, Out, ReadTxn, StateVector, Text, TextPrelim,
|
||||||
|
Transact, Update,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A document that counts text offsets in UTF-16, as Yjs in the extension does.
|
||||||
|
pub fn new_doc() -> Doc {
|
||||||
|
Doc::with_options(Options {
|
||||||
|
offset_kind: OffsetKind::Utf16,
|
||||||
|
..Options::default()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load(stored: &StoredDoc) -> anyhow::Result<Doc> {
|
||||||
|
let doc = new_doc();
|
||||||
|
{
|
||||||
|
let mut txn = doc.transact_mut();
|
||||||
|
if let Some(state) = &stored.state {
|
||||||
|
txn.apply_update(Update::decode_v1(state)?)?;
|
||||||
|
}
|
||||||
|
for update in &stored.updates {
|
||||||
|
txn.apply_update(Update::decode_v1(update)?)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(doc)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_state(state: &[u8]) -> anyhow::Result<Doc> {
|
||||||
|
let doc = new_doc();
|
||||||
|
doc.transact_mut().apply_update(Update::decode_v1(state)?)?;
|
||||||
|
Ok(doc)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn encode_state(doc: &Doc) -> Vec<u8> {
|
||||||
|
doc.transact()
|
||||||
|
.encode_state_as_update_v1(&StateVector::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Path → text of every model file.
|
||||||
|
pub fn files(doc: &Doc) -> BTreeMap<String, String> {
|
||||||
|
let map = doc.get_or_insert_map(FILES_MAP);
|
||||||
|
let txn = doc.transact();
|
||||||
|
map.iter(&txn)
|
||||||
|
.filter_map(|(path, value)| match value {
|
||||||
|
Out::YText(text) => Some((path.to_owned(), text.get_string(&txn))),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Makes `doc` hold the files of `target` as ordinary edits, so every copy
|
||||||
|
/// takes them like any other change. Returns the update, empty when nothing changed.
|
||||||
|
pub fn replace_files(doc: &Doc, target: &BTreeMap<String, String>) -> Vec<u8> {
|
||||||
|
let map = doc.get_or_insert_map(FILES_MAP);
|
||||||
|
let mut txn = doc.transact_mut();
|
||||||
|
let current: Vec<String> = map.keys(&txn).map(str::to_owned).collect();
|
||||||
|
for path in current.iter().filter(|p| !target.contains_key(*p)) {
|
||||||
|
map.remove(&mut txn, path);
|
||||||
|
}
|
||||||
|
for (path, text) in target {
|
||||||
|
match map.get(&txn, path) {
|
||||||
|
Some(Out::YText(existing)) => {
|
||||||
|
let old = existing.get_string(&txn);
|
||||||
|
if let Some((at, remove, insert)) = minimal_change(&old, text) {
|
||||||
|
if remove > 0 {
|
||||||
|
existing.remove_range(&mut txn, at, remove);
|
||||||
|
}
|
||||||
|
if !insert.is_empty() {
|
||||||
|
existing.insert(&mut txn, at, insert);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
map.insert(&mut txn, path.as_str(), TextPrelim::new(text.as_str()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
txn.encode_update_v1()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The one edit that turns `old` into `new`: UTF-16 offset, UTF-16 length to
|
||||||
|
/// remove, text to insert. Keeps the common start and end, as the extension's
|
||||||
|
/// `minimalChange` does, and never splits a character.
|
||||||
|
pub fn minimal_change<'a>(old: &str, new: &'a str) -> Option<(u32, u32, &'a str)> {
|
||||||
|
if old == new {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let prefix: usize = old
|
||||||
|
.chars()
|
||||||
|
.zip(new.chars())
|
||||||
|
.take_while(|(a, b)| a == b)
|
||||||
|
.map(|(a, _)| a.len_utf8())
|
||||||
|
.sum();
|
||||||
|
let (old_rest, new_rest) = (&old[prefix..], &new[prefix..]);
|
||||||
|
let suffix: usize = old_rest
|
||||||
|
.chars()
|
||||||
|
.rev()
|
||||||
|
.zip(new_rest.chars().rev())
|
||||||
|
.take_while(|(a, b)| a == b)
|
||||||
|
.map(|(a, _)| a.len_utf8())
|
||||||
|
.sum();
|
||||||
|
let removed = &old_rest[..old_rest.len() - suffix];
|
||||||
|
let inserted = &new_rest[..new_rest.len() - suffix];
|
||||||
|
let utf16 = |s: &str| s.encode_utf16().count() as u32;
|
||||||
|
Some((utf16(&old[..prefix]), utf16(removed), inserted))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn texts(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
|
||||||
|
pairs
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| (k.to_string(), v.to_string()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn minimal_change_keeps_common_ends() {
|
||||||
|
assert_eq!(
|
||||||
|
minimal_change("label: API", "label: Gateway API"),
|
||||||
|
Some((7, 0, "Gateway "))
|
||||||
|
);
|
||||||
|
assert_eq!(minimal_change("abc", "abc"), None);
|
||||||
|
assert_eq!(minimal_change("a😀b", "a😀cb"), Some((3, 0, "c")));
|
||||||
|
assert_eq!(minimal_change("x😀y", "x😃y"), Some((1, 2, "😃")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replace_files_reaches_target_and_syncs() {
|
||||||
|
let doc = new_doc();
|
||||||
|
replace_files(
|
||||||
|
&doc,
|
||||||
|
&texts(&[("main.archify", "model: a\n"), ("old.archify", "x")]),
|
||||||
|
);
|
||||||
|
let other = from_state(&encode_state(&doc)).unwrap();
|
||||||
|
|
||||||
|
let target = texts(&[("main.archify", "model: b\n"), ("new.archify", "y")]);
|
||||||
|
let update = replace_files(&doc, &target);
|
||||||
|
assert_eq!(files(&doc), target);
|
||||||
|
|
||||||
|
other
|
||||||
|
.transact_mut()
|
||||||
|
.apply_update(Update::decode_v1(&update).unwrap())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(files(&other), target);
|
||||||
|
assert!(
|
||||||
|
replace_files(&doc, &target).len() <= 2,
|
||||||
|
"no change gives an empty update"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
use archify_sync_protocol::ApiError;
|
||||||
|
use archify_sync_store::StoreError;
|
||||||
|
use axum::Json;
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
|
||||||
|
/// An HTTP error with a stable code for clients.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ApiErr {
|
||||||
|
pub status: StatusCode,
|
||||||
|
pub code: &'static str,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type ApiResult<T> = Result<T, ApiErr>;
|
||||||
|
|
||||||
|
impl ApiErr {
|
||||||
|
pub fn new(status: StatusCode, code: &'static str, message: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
status,
|
||||||
|
code,
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn too_many(message: impl Into<String>) -> Self {
|
||||||
|
Self::new(StatusCode::TOO_MANY_REQUESTS, "too_many_attempts", message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unauthorized(message: impl Into<String>) -> Self {
|
||||||
|
Self::new(StatusCode::UNAUTHORIZED, "unauthorized", message)
|
||||||
|
}
|
||||||
|
pub fn forbidden(message: impl Into<String>) -> Self {
|
||||||
|
Self::new(StatusCode::FORBIDDEN, "forbidden", message)
|
||||||
|
}
|
||||||
|
pub fn not_found(message: impl Into<String>) -> Self {
|
||||||
|
Self::new(StatusCode::NOT_FOUND, "not_found", message)
|
||||||
|
}
|
||||||
|
pub fn invalid(message: impl Into<String>) -> Self {
|
||||||
|
Self::new(StatusCode::BAD_REQUEST, "invalid", message)
|
||||||
|
}
|
||||||
|
pub fn conflict(message: impl Into<String>) -> Self {
|
||||||
|
Self::new(StatusCode::CONFLICT, "conflict", message)
|
||||||
|
}
|
||||||
|
pub fn internal(error: impl std::fmt::Display) -> Self {
|
||||||
|
tracing::error!(%error, "request failed");
|
||||||
|
Self::new(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"internal",
|
||||||
|
"Something went wrong on the server.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<StoreError> for ApiErr {
|
||||||
|
fn from(error: StoreError) -> Self {
|
||||||
|
match error {
|
||||||
|
StoreError::Conflict(what) => Self::conflict(format!("This {what} already exists.")),
|
||||||
|
other => Self::internal(other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for ApiErr {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
let body = ApiError {
|
||||||
|
error: self.code.to_owned(),
|
||||||
|
message: self.message,
|
||||||
|
};
|
||||||
|
(self.status, Json(body)).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,466 @@
|
|||||||
|
//! The live hub: one room per open space, holding its Yjs document and
|
||||||
|
//! awareness in memory. Clients speak the y-websocket protocol (sync step 1
|
||||||
|
//! and 2, updates, awareness). Every update is appended to PostgreSQL before
|
||||||
|
//! it is applied and passed on, under the room's lock, so a version's
|
||||||
|
//! `up_to` never drops an update the version does not hold.
|
||||||
|
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use archify_sync_protocol::VersionKind;
|
||||||
|
use archify_sync_store::{Store, VersionRow};
|
||||||
|
use axum::extract::ws::{Message as WsMessage, WebSocket};
|
||||||
|
use bytes::Bytes;
|
||||||
|
use futures_util::{SinkExt, StreamExt};
|
||||||
|
use tokio::sync::{Mutex, broadcast};
|
||||||
|
use uuid::Uuid;
|
||||||
|
use yrs::sync::{Awareness, Message, SyncMessage};
|
||||||
|
use yrs::updates::decoder::Decode;
|
||||||
|
use yrs::updates::encoder::Encode;
|
||||||
|
use yrs::{ClientID, ReadTxn, Transact, Update};
|
||||||
|
|
||||||
|
use crate::doc;
|
||||||
|
use crate::metrics::{METRICS, Metrics};
|
||||||
|
|
||||||
|
/// An encoded update with no changes in it.
|
||||||
|
const EMPTY_UPDATE: &[u8] = &[0, 0];
|
||||||
|
|
||||||
|
pub struct Hub {
|
||||||
|
store: Store,
|
||||||
|
rooms: Mutex<HashMap<Uuid, Arc<Room>>>,
|
||||||
|
snapshots: SnapshotPolicy,
|
||||||
|
next_conn: AtomicU64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When a room with edits gets a snapshot, which also empties its update log.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct SnapshotPolicy {
|
||||||
|
/// After this long without edits.
|
||||||
|
pub idle: Duration,
|
||||||
|
/// After this long of editing without a pause, so a busy space's log stays short.
|
||||||
|
pub max_age: Duration,
|
||||||
|
/// After this many edits.
|
||||||
|
pub max_edits: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Room {
|
||||||
|
pub space: Uuid,
|
||||||
|
state: Mutex<RoomState>,
|
||||||
|
/// Frames for every connection, tagged with the sender so it skips its own.
|
||||||
|
tx: broadcast::Sender<(u64, Bytes)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RoomState {
|
||||||
|
awareness: Awareness,
|
||||||
|
/// Users holding the room: connections and short API calls.
|
||||||
|
holders: usize,
|
||||||
|
last_update: i64,
|
||||||
|
latest: Option<VersionRow>,
|
||||||
|
/// Edits since the latest version.
|
||||||
|
dirty: Option<Dirty>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
struct Dirty {
|
||||||
|
since: Instant,
|
||||||
|
last: Instant,
|
||||||
|
edits: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RoomState {
|
||||||
|
fn edited(&mut self) {
|
||||||
|
let now = Instant::now();
|
||||||
|
let dirty = self.dirty.get_or_insert(Dirty {
|
||||||
|
since: now,
|
||||||
|
last: now,
|
||||||
|
edits: 0,
|
||||||
|
});
|
||||||
|
dirty.last = now;
|
||||||
|
dirty.edits += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SnapshotPolicy {
|
||||||
|
fn due(&self, dirty: &Dirty) -> bool {
|
||||||
|
dirty.last.elapsed() >= self.idle
|
||||||
|
|| dirty.since.elapsed() >= self.max_age
|
||||||
|
|| dirty.edits >= self.max_edits
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Hub {
|
||||||
|
pub fn new(store: Store, snapshots: SnapshotPolicy) -> Self {
|
||||||
|
Self {
|
||||||
|
store,
|
||||||
|
rooms: Mutex::new(HashMap::new()),
|
||||||
|
snapshots,
|
||||||
|
next_conn: AtomicU64::new(1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The space's room, loaded from the store on first use. Pair with [`Hub::release`].
|
||||||
|
pub async fn acquire(&self, space: Uuid) -> Result<Arc<Room>> {
|
||||||
|
let mut rooms = self.rooms.lock().await;
|
||||||
|
if let Some(room) = rooms.get(&space) {
|
||||||
|
room.state.lock().await.holders += 1;
|
||||||
|
return Ok(room.clone());
|
||||||
|
}
|
||||||
|
let stored = self.store.load_doc(space).await?;
|
||||||
|
let doc = doc::load(&stored).with_context(|| format!("space {space} does not load"))?;
|
||||||
|
let (tx, _) = broadcast::channel(1024);
|
||||||
|
let room = Arc::new(Room {
|
||||||
|
space,
|
||||||
|
tx,
|
||||||
|
state: Mutex::new(RoomState {
|
||||||
|
awareness: Awareness::new(doc),
|
||||||
|
holders: 1,
|
||||||
|
last_update: stored.last_update,
|
||||||
|
latest: stored.latest,
|
||||||
|
dirty: (!stored.updates.is_empty()).then(|| Dirty {
|
||||||
|
since: Instant::now(),
|
||||||
|
last: Instant::now(),
|
||||||
|
edits: stored.updates.len() as u64,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
rooms.insert(space, room.clone());
|
||||||
|
tracing::debug!(%space, "room opened");
|
||||||
|
Ok(room)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lets go of a room; the last one out takes a snapshot and closes it.
|
||||||
|
pub async fn release(&self, room: &Arc<Room>) {
|
||||||
|
let mut rooms = self.rooms.lock().await;
|
||||||
|
let mut state = room.state.lock().await;
|
||||||
|
state.holders = state.holders.saturating_sub(1);
|
||||||
|
if state.holders > 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Err(error) = self.snapshot_locked(room.space, &mut state).await {
|
||||||
|
// The update log still has every edit; the room stays open for the next try.
|
||||||
|
tracing::error!(space = %room.space, %error, "snapshot on close failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rooms.remove(&room.space);
|
||||||
|
tracing::debug!(space = %room.space, "room closed");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshots every room whose edits are due under the [`SnapshotPolicy`].
|
||||||
|
pub async fn snapshot_idle_rooms(&self) {
|
||||||
|
let rooms: Vec<Arc<Room>> = self.rooms.lock().await.values().cloned().collect();
|
||||||
|
for room in rooms {
|
||||||
|
let mut state = room.state.lock().await;
|
||||||
|
if state.dirty.is_some_and(|dirty| self.snapshots.due(&dirty))
|
||||||
|
&& let Err(error) = self.snapshot_locked(room.space, &mut state).await
|
||||||
|
{
|
||||||
|
tracing::error!(space = %room.space, %error, "idle snapshot failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshots every room with edits; on shutdown.
|
||||||
|
pub async fn snapshot_all(&self) {
|
||||||
|
let rooms: Vec<Arc<Room>> = self.rooms.lock().await.values().cloned().collect();
|
||||||
|
for room in rooms {
|
||||||
|
let mut state = room.state.lock().await;
|
||||||
|
if let Err(error) = self.snapshot_locked(room.space, &mut state).await {
|
||||||
|
tracing::error!(space = %room.space, %error, "snapshot on shutdown failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn snapshot_locked(
|
||||||
|
&self,
|
||||||
|
space: Uuid,
|
||||||
|
state: &mut RoomState,
|
||||||
|
) -> Result<Option<VersionRow>> {
|
||||||
|
if state.dirty.is_none() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
self.version_locked(space, state, VersionKind::Snapshot, None, None, None)
|
||||||
|
.await
|
||||||
|
.map(Some)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn version_locked(
|
||||||
|
&self,
|
||||||
|
space: Uuid,
|
||||||
|
state: &mut RoomState,
|
||||||
|
kind: VersionKind,
|
||||||
|
name: Option<&str>,
|
||||||
|
restored_from: Option<i64>,
|
||||||
|
by: Option<Uuid>,
|
||||||
|
) -> Result<VersionRow> {
|
||||||
|
let (major, minor) = match (&state.latest, kind) {
|
||||||
|
(None, VersionKind::Publish) => (1, 0),
|
||||||
|
(None, _) => (0, 1),
|
||||||
|
(Some(v), VersionKind::Publish) => (v.major + 1, 0),
|
||||||
|
(Some(v), _) => (v.major, v.minor + 1),
|
||||||
|
};
|
||||||
|
let (kind_name, counter) = match kind {
|
||||||
|
VersionKind::Snapshot => ("snapshot", &METRICS.snapshots_total),
|
||||||
|
VersionKind::Publish => ("publish", &METRICS.publishes_total),
|
||||||
|
VersionKind::Restore => ("restore", &METRICS.restores_total),
|
||||||
|
};
|
||||||
|
let encoded = doc::encode_state(state.awareness.doc());
|
||||||
|
let row = self
|
||||||
|
.store
|
||||||
|
.insert_version(
|
||||||
|
space,
|
||||||
|
major,
|
||||||
|
minor,
|
||||||
|
kind_name,
|
||||||
|
name,
|
||||||
|
&encoded,
|
||||||
|
state.last_update,
|
||||||
|
restored_from,
|
||||||
|
by,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
tracing::info!(%space, version = %format!("{major}.{minor}"), kind = kind_name, "version stored");
|
||||||
|
Metrics::inc(counter);
|
||||||
|
state.latest = Some(row.clone());
|
||||||
|
state.dirty = None;
|
||||||
|
Ok(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marks the current state as a named, major version.
|
||||||
|
pub async fn publish(&self, space: Uuid, name: &str, by: Uuid) -> Result<VersionRow> {
|
||||||
|
let room = self.acquire(space).await?;
|
||||||
|
let result = {
|
||||||
|
let mut state = room.state.lock().await;
|
||||||
|
self.version_locked(
|
||||||
|
space,
|
||||||
|
&mut state,
|
||||||
|
VersionKind::Publish,
|
||||||
|
Some(name),
|
||||||
|
None,
|
||||||
|
Some(by),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
};
|
||||||
|
self.release(&room).await;
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Brings back the files of an earlier version as a new edit, then stores a restore version.
|
||||||
|
pub async fn restore(&self, space: Uuid, version: i64, by: Uuid) -> Result<Option<VersionRow>> {
|
||||||
|
let Some((_, old_state)) = self.store.version(space, version).await? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let target = doc::files(&doc::from_state(&old_state)?);
|
||||||
|
let room = self.acquire(space).await?;
|
||||||
|
let result = async {
|
||||||
|
let mut state = room.state.lock().await;
|
||||||
|
let update = doc::replace_files(state.awareness.doc(), &target);
|
||||||
|
if update != EMPTY_UPDATE {
|
||||||
|
state.last_update = self.store.append_update(space, &update, Some(by)).await?;
|
||||||
|
state.edited();
|
||||||
|
let frame = Message::Sync(SyncMessage::Update(update)).encode_v1();
|
||||||
|
let _ = room.tx.send((0, Bytes::from(frame)));
|
||||||
|
}
|
||||||
|
self.version_locked(
|
||||||
|
space,
|
||||||
|
&mut state,
|
||||||
|
VersionKind::Restore,
|
||||||
|
None,
|
||||||
|
Some(version),
|
||||||
|
Some(by),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
self.release(&room).await;
|
||||||
|
result.map(Some)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serves one WebSocket until it closes.
|
||||||
|
pub async fn serve(self: Arc<Self>, socket: WebSocket, space: Uuid, account: Uuid) {
|
||||||
|
let room = match self.acquire(space).await {
|
||||||
|
Ok(room) => room,
|
||||||
|
Err(error) => {
|
||||||
|
tracing::error!(%space, %error, "room does not open");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let conn = self.next_conn.fetch_add(1, Ordering::Relaxed);
|
||||||
|
Metrics::inc(&METRICS.connections_total);
|
||||||
|
METRICS.connections_open.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let mut clients = HashSet::new();
|
||||||
|
if let Err(error) = self.run(&room, socket, conn, account, &mut clients).await {
|
||||||
|
tracing::debug!(%space, conn, %error, "connection ended");
|
||||||
|
}
|
||||||
|
METRICS.connections_open.fetch_sub(1, Ordering::Relaxed);
|
||||||
|
self.leave_awareness(&room, conn, clients).await;
|
||||||
|
self.release(&room).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run(
|
||||||
|
&self,
|
||||||
|
room: &Arc<Room>,
|
||||||
|
socket: WebSocket,
|
||||||
|
conn: u64,
|
||||||
|
account: Uuid,
|
||||||
|
clients: &mut HashSet<ClientID>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let (mut sink, mut stream) = socket.split();
|
||||||
|
let mut rx = room.tx.subscribe();
|
||||||
|
|
||||||
|
// Ask for what the client has that the server lacks, and show who is here.
|
||||||
|
let hello = {
|
||||||
|
let state = room.state.lock().await;
|
||||||
|
let sv = state.awareness.doc().transact().state_vector();
|
||||||
|
let mut frames = vec![Message::Sync(SyncMessage::SyncStep1(sv)).encode_v1()];
|
||||||
|
let awareness = state.awareness.update()?;
|
||||||
|
if !awareness.clients.is_empty() {
|
||||||
|
frames.push(Message::Awareness(awareness).encode_v1());
|
||||||
|
}
|
||||||
|
frames
|
||||||
|
};
|
||||||
|
for frame in hello {
|
||||||
|
sink.send(WsMessage::Binary(frame.into())).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
incoming = stream.next() => {
|
||||||
|
let Some(incoming) = incoming else { return Ok(()) };
|
||||||
|
match incoming? {
|
||||||
|
WsMessage::Binary(data) => {
|
||||||
|
for reply in self.handle(room, conn, account, &data, clients).await? {
|
||||||
|
sink.send(WsMessage::Binary(reply.into())).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WsMessage::Close(_) => return Ok(()),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
outgoing = rx.recv() => match outgoing {
|
||||||
|
Ok((from, frame)) if from != conn => sink.send(WsMessage::Binary(frame)).await?,
|
||||||
|
Ok(_) => {}
|
||||||
|
// Too slow to keep up: close, and the client catches up on reconnect.
|
||||||
|
Err(broadcast::error::RecvError::Lagged(_)) => {
|
||||||
|
Metrics::inc(&METRICS.lagged_connections_total);
|
||||||
|
anyhow::bail!("connection lagged behind")
|
||||||
|
}
|
||||||
|
Err(broadcast::error::RecvError::Closed) => return Ok(()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One frame from a client; returns the frames to answer with.
|
||||||
|
async fn handle(
|
||||||
|
&self,
|
||||||
|
room: &Arc<Room>,
|
||||||
|
conn: u64,
|
||||||
|
account: Uuid,
|
||||||
|
data: &[u8],
|
||||||
|
clients: &mut HashSet<ClientID>,
|
||||||
|
) -> Result<Vec<Vec<u8>>> {
|
||||||
|
match Message::decode_v1(data)? {
|
||||||
|
Message::Sync(SyncMessage::SyncStep1(sv)) => {
|
||||||
|
let state = room.state.lock().await;
|
||||||
|
let update = state
|
||||||
|
.awareness
|
||||||
|
.doc()
|
||||||
|
.transact()
|
||||||
|
.encode_state_as_update_v1(&sv);
|
||||||
|
Ok(vec![
|
||||||
|
Message::Sync(SyncMessage::SyncStep2(update)).encode_v1(),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
Message::Sync(SyncMessage::SyncStep2(update))
|
||||||
|
| Message::Sync(SyncMessage::Update(update)) => {
|
||||||
|
if update.as_slice() == EMPTY_UPDATE {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
let decoded = Update::decode_v1(&update)?;
|
||||||
|
let mut state = room.state.lock().await;
|
||||||
|
// Durable first: the edit is in PostgreSQL before anyone else sees it.
|
||||||
|
let started = Instant::now();
|
||||||
|
state.last_update = self
|
||||||
|
.store
|
||||||
|
.append_update(room.space, &update, Some(account))
|
||||||
|
.await?;
|
||||||
|
METRICS.update_stored(update.len(), started.elapsed());
|
||||||
|
state.awareness.doc().transact_mut().apply_update(decoded)?;
|
||||||
|
state.edited();
|
||||||
|
let frame = Message::Sync(SyncMessage::Update(update)).encode_v1();
|
||||||
|
let _ = room.tx.send((conn, Bytes::from(frame)));
|
||||||
|
Ok(vec![])
|
||||||
|
}
|
||||||
|
Message::Awareness(update) => {
|
||||||
|
let mut state = room.state.lock().await;
|
||||||
|
if let Some(summary) = state.awareness.apply_update_summary(update)? {
|
||||||
|
clients.extend(summary.added.iter().chain(&summary.updated));
|
||||||
|
}
|
||||||
|
let _ = room.tx.send((conn, Bytes::copy_from_slice(data)));
|
||||||
|
Ok(vec![])
|
||||||
|
}
|
||||||
|
Message::AwarenessQuery => {
|
||||||
|
let state = room.state.lock().await;
|
||||||
|
Ok(vec![
|
||||||
|
Message::Awareness(state.awareness.update()?).encode_v1(),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
Message::Auth(_) | Message::Custom(..) => Ok(vec![]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears the awareness states a connection brought, and tells the others.
|
||||||
|
async fn leave_awareness(&self, room: &Arc<Room>, conn: u64, clients: HashSet<ClientID>) {
|
||||||
|
if clients.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut state = room.state.lock().await;
|
||||||
|
for client in &clients {
|
||||||
|
state.awareness.remove_state(*client);
|
||||||
|
}
|
||||||
|
if let Ok(update) = state.awareness.update_with_clients(clients) {
|
||||||
|
let _ = room
|
||||||
|
.tx
|
||||||
|
.send((conn, Bytes::from(Message::Awareness(update).encode_v1())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn open_rooms(&self) -> usize {
|
||||||
|
self.rooms.lock().await.len()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_busy_space_still_gets_snapshots() {
|
||||||
|
let policy = SnapshotPolicy {
|
||||||
|
idle: Duration::from_secs(30),
|
||||||
|
max_age: Duration::from_secs(600),
|
||||||
|
max_edits: 2000,
|
||||||
|
};
|
||||||
|
let now = Instant::now();
|
||||||
|
let typing = Dirty {
|
||||||
|
since: now,
|
||||||
|
last: now,
|
||||||
|
edits: 10,
|
||||||
|
};
|
||||||
|
assert!(!policy.due(&typing));
|
||||||
|
assert!(policy.due(&Dirty {
|
||||||
|
last: now - Duration::from_secs(31),
|
||||||
|
..typing
|
||||||
|
}));
|
||||||
|
assert!(policy.due(&Dirty {
|
||||||
|
since: now - Duration::from_secs(601),
|
||||||
|
..typing
|
||||||
|
}));
|
||||||
|
assert!(policy.due(&Dirty {
|
||||||
|
edits: 2000,
|
||||||
|
..typing
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
//! Archify Sync server: live, shared Archify models.
|
||||||
|
|
||||||
|
mod api;
|
||||||
|
mod auth;
|
||||||
|
mod config;
|
||||||
|
mod doc;
|
||||||
|
mod error;
|
||||||
|
mod hub;
|
||||||
|
mod metrics;
|
||||||
|
mod oidc;
|
||||||
|
mod pages;
|
||||||
|
mod state;
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use archify_sync_store::Store;
|
||||||
|
use axum::Router;
|
||||||
|
use axum::extract::DefaultBodyLimit;
|
||||||
|
use axum::routing::{delete, get, post};
|
||||||
|
use tower_http::trace::TraceLayer;
|
||||||
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
// The image has no shell or curl; `archify-sync-server healthcheck` is its probe.
|
||||||
|
if std::env::args().nth(1).as_deref() == Some("healthcheck") {
|
||||||
|
return healthcheck().await;
|
||||||
|
}
|
||||||
|
// reqwest (single sign-on) and sqlx share rustls with the ring provider.
|
||||||
|
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||||
|
let filter = EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| EnvFilter::new("info,tower_http=warn,sqlx=warn"));
|
||||||
|
if std::env::var("ARCHIFY_SYNC_LOG_JSON").is_ok_and(|v| v == "true") {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(filter)
|
||||||
|
.json()
|
||||||
|
.init();
|
||||||
|
} else {
|
||||||
|
tracing_subscriber::fmt().with_env_filter(filter).init();
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = Config::from_env()?;
|
||||||
|
let store = Store::connect(&config.database_url, config.database_max_connections).await?;
|
||||||
|
store.migrate().await?;
|
||||||
|
let listen = config.listen;
|
||||||
|
let state = AppState::new(config, store).await?;
|
||||||
|
|
||||||
|
let background = tokio::spawn(housekeeping(state.clone()));
|
||||||
|
if let Some(address) = state.config.metrics_listen {
|
||||||
|
let listener = tokio::net::TcpListener::bind(address).await?;
|
||||||
|
tracing::info!(listen = %address, "metrics listening");
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/metrics", get(metrics::serve))
|
||||||
|
.with_state(state.clone());
|
||||||
|
tokio::spawn(async move { axum::serve(listener, app).await });
|
||||||
|
}
|
||||||
|
let listener = tokio::net::TcpListener::bind(listen).await?;
|
||||||
|
tracing::info!(%listen, version = env!("CARGO_PKG_VERSION"), "Archify Sync listening");
|
||||||
|
axum::serve(listener, router(state.clone()))
|
||||||
|
.with_graceful_shutdown(shutdown())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
background.abort();
|
||||||
|
state.hub.snapshot_all().await;
|
||||||
|
tracing::info!("stopped");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn router(state: AppState) -> Router {
|
||||||
|
let v1 = Router::new()
|
||||||
|
.route("/info", get(api::info))
|
||||||
|
.route("/setup", post(api::setup))
|
||||||
|
.route("/auth/token", post(api::token))
|
||||||
|
.route("/auth/logout", post(api::logout))
|
||||||
|
.route("/me", get(api::me))
|
||||||
|
.route("/spaces", get(api::list_spaces).post(api::create_space))
|
||||||
|
.route("/spaces/{space}", get(api::get_space))
|
||||||
|
.route("/spaces/{space}/members", get(api::members))
|
||||||
|
.route(
|
||||||
|
"/spaces/{space}/members/{account}",
|
||||||
|
delete(api::remove_member),
|
||||||
|
)
|
||||||
|
.route("/spaces/{space}/invites", post(api::create_invite))
|
||||||
|
.route("/invites/{token}/accept", post(api::accept_invite))
|
||||||
|
.route(
|
||||||
|
"/spaces/{space}/versions",
|
||||||
|
get(api::versions).post(api::publish),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/spaces/{space}/versions/{version}/files",
|
||||||
|
get(api::version_files),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/spaces/{space}/versions/{version}/restore",
|
||||||
|
post(api::restore),
|
||||||
|
)
|
||||||
|
.route("/spaces/{space}/sync", get(api::sync));
|
||||||
|
|
||||||
|
Router::new()
|
||||||
|
.nest("/v1", v1)
|
||||||
|
.route("/healthz", get(api::healthz))
|
||||||
|
.route("/readyz", get(api::readyz))
|
||||||
|
.route("/setup", get(pages::setup_page).post(pages::setup_submit))
|
||||||
|
.route(
|
||||||
|
"/authorize",
|
||||||
|
get(pages::authorize_page).post(pages::authorize_submit),
|
||||||
|
)
|
||||||
|
.route("/join/{token}", get(pages::join_page))
|
||||||
|
.route("/oidc/start", get(pages::oidc_start))
|
||||||
|
.route("/oidc/callback", get(pages::oidc_callback))
|
||||||
|
.layer(DefaultBodyLimit::max(1024 * 1024))
|
||||||
|
.layer(TraceLayer::new_for_http())
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Idle snapshots every few seconds; expired sessions once an hour.
|
||||||
|
async fn housekeeping(state: AppState) {
|
||||||
|
let mut tick = tokio::time::interval(Duration::from_secs(5));
|
||||||
|
let mut n: u64 = 0;
|
||||||
|
loop {
|
||||||
|
tick.tick().await;
|
||||||
|
state.hub.snapshot_idle_rooms().await;
|
||||||
|
n += 1;
|
||||||
|
if n.is_multiple_of(720) {
|
||||||
|
match state.store.delete_expired_refresh().await {
|
||||||
|
Ok(removed) if removed > 0 => tracing::info!(removed, "expired sessions removed"),
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(error) => tracing::warn!(%error, "removing expired sessions failed"),
|
||||||
|
}
|
||||||
|
let rooms = state.hub.open_rooms().await;
|
||||||
|
tracing::debug!(rooms, "open rooms");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Asks the running server's `/healthz` on this machine; exits non-zero if it does not answer.
|
||||||
|
async fn healthcheck() -> Result<()> {
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
let listen: std::net::SocketAddr = std::env::var("ARCHIFY_SYNC_LISTEN")
|
||||||
|
.unwrap_or_else(|_| "0.0.0.0:8080".into())
|
||||||
|
.parse()?;
|
||||||
|
let target = std::net::SocketAddr::from(([127, 0, 0, 1], listen.port()));
|
||||||
|
let reply = tokio::time::timeout(Duration::from_secs(3), async {
|
||||||
|
let mut stream = tokio::net::TcpStream::connect(target).await?;
|
||||||
|
stream
|
||||||
|
.write_all(b"GET /healthz HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
|
||||||
|
.await?;
|
||||||
|
let mut reply = String::new();
|
||||||
|
stream.read_to_string(&mut reply).await?;
|
||||||
|
anyhow::Ok(reply)
|
||||||
|
})
|
||||||
|
.await??;
|
||||||
|
anyhow::ensure!(reply.starts_with("HTTP/1.1 200"), "unhealthy");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn shutdown() {
|
||||||
|
let ctrl_c = async {
|
||||||
|
let _ = tokio::signal::ctrl_c().await;
|
||||||
|
};
|
||||||
|
#[cfg(unix)]
|
||||||
|
let term = async {
|
||||||
|
if let Ok(mut s) = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
||||||
|
{
|
||||||
|
s.recv().await;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
let term = std::future::pending::<()>();
|
||||||
|
tokio::select! { _ = ctrl_c => {}, _ = term => {} }
|
||||||
|
tracing::info!("shutting down; storing snapshots");
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
//! Prometheus metrics, served on their own address (`ARCHIFY_SYNC_METRICS_LISTEN`)
|
||||||
|
//! so they never reach the public one.
|
||||||
|
|
||||||
|
use std::fmt::Write;
|
||||||
|
use std::sync::atomic::{AtomicI64, AtomicU64, Ordering};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum::http::header;
|
||||||
|
use axum::response::IntoResponse;
|
||||||
|
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
pub static METRICS: Metrics = Metrics::new();
|
||||||
|
|
||||||
|
pub struct Metrics {
|
||||||
|
pub connections_total: AtomicU64,
|
||||||
|
pub connections_open: AtomicI64,
|
||||||
|
pub updates_total: AtomicU64,
|
||||||
|
pub update_bytes_total: AtomicU64,
|
||||||
|
/// Time to store an update in PostgreSQL, in microseconds.
|
||||||
|
update_store_micros_total: AtomicU64,
|
||||||
|
pub snapshots_total: AtomicU64,
|
||||||
|
pub publishes_total: AtomicU64,
|
||||||
|
pub restores_total: AtomicU64,
|
||||||
|
pub sign_in_failures_total: AtomicU64,
|
||||||
|
pub sign_in_blocked_total: AtomicU64,
|
||||||
|
pub refresh_reuse_total: AtomicU64,
|
||||||
|
pub lagged_connections_total: AtomicU64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Metrics {
|
||||||
|
const fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
connections_total: AtomicU64::new(0),
|
||||||
|
connections_open: AtomicI64::new(0),
|
||||||
|
updates_total: AtomicU64::new(0),
|
||||||
|
update_bytes_total: AtomicU64::new(0),
|
||||||
|
update_store_micros_total: AtomicU64::new(0),
|
||||||
|
snapshots_total: AtomicU64::new(0),
|
||||||
|
publishes_total: AtomicU64::new(0),
|
||||||
|
restores_total: AtomicU64::new(0),
|
||||||
|
sign_in_failures_total: AtomicU64::new(0),
|
||||||
|
sign_in_blocked_total: AtomicU64::new(0),
|
||||||
|
refresh_reuse_total: AtomicU64::new(0),
|
||||||
|
lagged_connections_total: AtomicU64::new(0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn inc(counter: &AtomicU64) {
|
||||||
|
counter.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_stored(&self, bytes: usize, took: Duration) {
|
||||||
|
self.updates_total.fetch_add(1, Ordering::Relaxed);
|
||||||
|
self.update_bytes_total
|
||||||
|
.fetch_add(bytes as u64, Ordering::Relaxed);
|
||||||
|
self.update_store_micros_total
|
||||||
|
.fetch_add(took.as_micros() as u64, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn serve(State(state): State<AppState>) -> impl IntoResponse {
|
||||||
|
let m = &METRICS;
|
||||||
|
let get = |c: &AtomicU64| c.load(Ordering::Relaxed);
|
||||||
|
let mut out = String::new();
|
||||||
|
let mut metric = |name: &str, kind: &str, help: &str, value: String| {
|
||||||
|
let _ = writeln!(out, "# HELP archify_sync_{name} {help}");
|
||||||
|
let _ = writeln!(out, "# TYPE archify_sync_{name} {kind}");
|
||||||
|
let _ = writeln!(out, "archify_sync_{name} {value}");
|
||||||
|
};
|
||||||
|
metric(
|
||||||
|
"rooms_open",
|
||||||
|
"gauge",
|
||||||
|
"Spaces open in memory.",
|
||||||
|
state.hub.open_rooms().await.to_string(),
|
||||||
|
);
|
||||||
|
metric(
|
||||||
|
"connections_open",
|
||||||
|
"gauge",
|
||||||
|
"Live editor connections.",
|
||||||
|
m.connections_open.load(Ordering::Relaxed).to_string(),
|
||||||
|
);
|
||||||
|
metric(
|
||||||
|
"connections_total",
|
||||||
|
"counter",
|
||||||
|
"Editor connections since start.",
|
||||||
|
get(&m.connections_total).to_string(),
|
||||||
|
);
|
||||||
|
metric(
|
||||||
|
"connections_lagged_total",
|
||||||
|
"counter",
|
||||||
|
"Connections closed for falling behind.",
|
||||||
|
get(&m.lagged_connections_total).to_string(),
|
||||||
|
);
|
||||||
|
metric(
|
||||||
|
"updates_total",
|
||||||
|
"counter",
|
||||||
|
"Edits stored.",
|
||||||
|
get(&m.updates_total).to_string(),
|
||||||
|
);
|
||||||
|
metric(
|
||||||
|
"update_bytes_total",
|
||||||
|
"counter",
|
||||||
|
"Bytes of edits stored.",
|
||||||
|
get(&m.update_bytes_total).to_string(),
|
||||||
|
);
|
||||||
|
metric(
|
||||||
|
"update_store_seconds_total",
|
||||||
|
"counter",
|
||||||
|
"Time spent storing edits in PostgreSQL.",
|
||||||
|
format!("{:.6}", get(&m.update_store_micros_total) as f64 / 1e6),
|
||||||
|
);
|
||||||
|
metric(
|
||||||
|
"snapshots_total",
|
||||||
|
"counter",
|
||||||
|
"Snapshot versions taken.",
|
||||||
|
get(&m.snapshots_total).to_string(),
|
||||||
|
);
|
||||||
|
metric(
|
||||||
|
"publishes_total",
|
||||||
|
"counter",
|
||||||
|
"Versions published.",
|
||||||
|
get(&m.publishes_total).to_string(),
|
||||||
|
);
|
||||||
|
metric(
|
||||||
|
"restores_total",
|
||||||
|
"counter",
|
||||||
|
"Versions restored.",
|
||||||
|
get(&m.restores_total).to_string(),
|
||||||
|
);
|
||||||
|
metric(
|
||||||
|
"sign_in_failures_total",
|
||||||
|
"counter",
|
||||||
|
"Sign-ins with a wrong name or password.",
|
||||||
|
get(&m.sign_in_failures_total).to_string(),
|
||||||
|
);
|
||||||
|
metric(
|
||||||
|
"sign_in_blocked_total",
|
||||||
|
"counter",
|
||||||
|
"Sign-ins refused for too many failures.",
|
||||||
|
get(&m.sign_in_blocked_total).to_string(),
|
||||||
|
);
|
||||||
|
metric(
|
||||||
|
"refresh_reuse_total",
|
||||||
|
"counter",
|
||||||
|
"Sessions signed out for a reused refresh token.",
|
||||||
|
get(&m.refresh_reuse_total).to_string(),
|
||||||
|
);
|
||||||
|
([(header::CONTENT_TYPE, "text/plain; version=0.0.4")], out)
|
||||||
|
}
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
//! Single sign-on through an OpenID Connect provider (Keycloak, Authentik,
|
||||||
|
//! Entra ID, …). The editor's sign-in stays as it is: the browser page offers
|
||||||
|
//! "Sign in with …", the provider signs the person in and comes back to
|
||||||
|
//! `/oidc/callback`, and the server hands the editor its one-time code as for
|
||||||
|
//! a password sign-in. One provider client serves every address the server is
|
||||||
|
//! reached at; each address only needs its callback listed at the provider.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, anyhow, bail, ensure};
|
||||||
|
use base64::Engine;
|
||||||
|
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||||
|
use jsonwebtoken::jwk::JwkSet;
|
||||||
|
use jsonwebtoken::{DecodingKey, Validation, decode, decode_header};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::Value;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
use crate::auth::random_token;
|
||||||
|
use crate::pages::AuthorizeQuery;
|
||||||
|
|
||||||
|
/// How long the provider may take to send the person back.
|
||||||
|
const PENDING_TTL: Duration = Duration::from_secs(10 * 60);
|
||||||
|
const PENDING_MAX: usize = 10_000;
|
||||||
|
const CACHE_TTL: Duration = Duration::from_secs(60 * 60);
|
||||||
|
/// Unknown signing keys trigger a fresh key set at most this often.
|
||||||
|
const JWKS_REFRESH_MIN: Duration = Duration::from_secs(60);
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct OidcConfig {
|
||||||
|
/// `https://keycloak.example.com/realms/home`, as the provider names itself.
|
||||||
|
pub issuer: String,
|
||||||
|
pub client_id: String,
|
||||||
|
/// Confidential clients (Keycloak's default) have one; public clients rely on PKCE alone.
|
||||||
|
pub client_secret: Option<String>,
|
||||||
|
/// The button: "Sign in with {label}".
|
||||||
|
pub label: String,
|
||||||
|
pub scopes: String,
|
||||||
|
/// The claim that lists the person's groups (Keycloak's group mapper: `groups`).
|
||||||
|
pub groups_claim: String,
|
||||||
|
/// When set, only members of one of these groups may sign in, and they
|
||||||
|
/// get an account on first sign-in without an invite.
|
||||||
|
pub allowed_groups: Vec<String>,
|
||||||
|
/// Members of these groups are admins of the server.
|
||||||
|
pub admin_groups: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Who the provider says signed in.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Person {
|
||||||
|
pub subject: String,
|
||||||
|
/// `preferred_username`, or the part of the e-mail before the `@`.
|
||||||
|
pub username: Option<String>,
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
pub email: Option<String>,
|
||||||
|
pub groups: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Person {
|
||||||
|
pub fn in_any(&self, groups: &[String]) -> bool {
|
||||||
|
self.groups.iter().any(|g| groups.contains(g))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
struct Discovery {
|
||||||
|
issuer: String,
|
||||||
|
authorization_endpoint: String,
|
||||||
|
token_endpoint: String,
|
||||||
|
jwks_uri: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Pending {
|
||||||
|
query: AuthorizeQuery,
|
||||||
|
nonce: String,
|
||||||
|
verifier: String,
|
||||||
|
created: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct TokenReply {
|
||||||
|
id_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Oidc {
|
||||||
|
pub config: OidcConfig,
|
||||||
|
http: reqwest::Client,
|
||||||
|
discovery: Mutex<Option<(Instant, Discovery)>>,
|
||||||
|
jwks: Mutex<Option<(Instant, JwkSet)>>,
|
||||||
|
pending: Mutex<HashMap<String, Pending>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Oidc {
|
||||||
|
pub fn new(config: OidcConfig) -> Result<Self> {
|
||||||
|
let http = reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(10))
|
||||||
|
.redirect(reqwest::redirect::Policy::none())
|
||||||
|
.build()?;
|
||||||
|
Ok(Self {
|
||||||
|
config,
|
||||||
|
http,
|
||||||
|
discovery: Mutex::new(None),
|
||||||
|
jwks: Mutex::new(None),
|
||||||
|
pending: Mutex::new(HashMap::new()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the provider sends people back; list it in the provider's client.
|
||||||
|
pub fn callback_url(public_url: &str) -> String {
|
||||||
|
format!("{public_url}/oidc/callback")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn discovery(&self) -> Result<Discovery> {
|
||||||
|
let mut cached = self.discovery.lock().await;
|
||||||
|
if let Some((at, d)) = cached.as_ref()
|
||||||
|
&& at.elapsed() < CACHE_TTL
|
||||||
|
{
|
||||||
|
return Ok(d.clone());
|
||||||
|
}
|
||||||
|
let url = format!(
|
||||||
|
"{}/.well-known/openid-configuration",
|
||||||
|
self.config.issuer.trim_end_matches('/')
|
||||||
|
);
|
||||||
|
let d: Discovery = self
|
||||||
|
.http
|
||||||
|
.get(&url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.and_then(|r| r.error_for_status())
|
||||||
|
.with_context(|| format!("the identity provider does not answer at {url}"))?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.context("the identity provider's configuration does not read")?;
|
||||||
|
ensure!(
|
||||||
|
d.issuer.trim_end_matches('/') == self.config.issuer.trim_end_matches('/'),
|
||||||
|
"the identity provider calls itself {}, not {}",
|
||||||
|
d.issuer,
|
||||||
|
self.config.issuer
|
||||||
|
);
|
||||||
|
*cached = Some((Instant::now(), d.clone()));
|
||||||
|
Ok(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn key_for(&self, kid: Option<&str>) -> Result<DecodingKey> {
|
||||||
|
let d = self.discovery().await?;
|
||||||
|
let mut cached = self.jwks.lock().await;
|
||||||
|
let find = |set: &JwkSet| match kid {
|
||||||
|
Some(kid) => set.find(kid).cloned(),
|
||||||
|
None => set.keys.first().cloned(),
|
||||||
|
};
|
||||||
|
let stale = cached.as_ref().is_none_or(|(at, set)| {
|
||||||
|
at.elapsed() >= CACHE_TTL || (find(set).is_none() && at.elapsed() >= JWKS_REFRESH_MIN)
|
||||||
|
});
|
||||||
|
if stale {
|
||||||
|
let set: JwkSet = self
|
||||||
|
.http
|
||||||
|
.get(&d.jwks_uri)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.and_then(|r| r.error_for_status())
|
||||||
|
.context("the identity provider's signing keys do not load")?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.context("the identity provider's signing keys do not read")?;
|
||||||
|
*cached = Some((Instant::now(), set));
|
||||||
|
}
|
||||||
|
let (_, set) = cached.as_ref().expect("filled above");
|
||||||
|
let jwk = find(set).ok_or_else(|| anyhow!("the sign-in was signed with an unknown key"))?;
|
||||||
|
Ok(DecodingKey::from_jwk(&jwk)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The provider's sign-in address for this editor sign-in.
|
||||||
|
pub async fn start(&self, public_url: &str, query: AuthorizeQuery) -> Result<String> {
|
||||||
|
let d = self.discovery().await?;
|
||||||
|
let state = random_token();
|
||||||
|
let nonce = random_token();
|
||||||
|
let verifier = random_token();
|
||||||
|
let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()));
|
||||||
|
{
|
||||||
|
let mut pending = self.pending.lock().await;
|
||||||
|
pending.retain(|_, p| p.created.elapsed() < PENDING_TTL);
|
||||||
|
ensure!(
|
||||||
|
pending.len() < PENDING_MAX,
|
||||||
|
"too many sign-ins are waiting; try again in a few minutes"
|
||||||
|
);
|
||||||
|
pending.insert(
|
||||||
|
state.clone(),
|
||||||
|
Pending {
|
||||||
|
query,
|
||||||
|
nonce: nonce.clone(),
|
||||||
|
verifier,
|
||||||
|
created: Instant::now(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut url = url::Url::parse(&d.authorization_endpoint)
|
||||||
|
.context("the identity provider's sign-in address is not valid")?;
|
||||||
|
url.query_pairs_mut()
|
||||||
|
.append_pair("response_type", "code")
|
||||||
|
.append_pair("client_id", &self.config.client_id)
|
||||||
|
.append_pair("redirect_uri", &Self::callback_url(public_url))
|
||||||
|
.append_pair("scope", &self.config.scopes)
|
||||||
|
.append_pair("state", &state)
|
||||||
|
.append_pair("nonce", &nonce)
|
||||||
|
.append_pair("code_challenge", &challenge)
|
||||||
|
.append_pair("code_challenge_method", "S256");
|
||||||
|
Ok(url.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The provider sent the person back: exchanges the code and checks the ID token.
|
||||||
|
pub async fn finish(
|
||||||
|
&self,
|
||||||
|
public_url: &str,
|
||||||
|
code: &str,
|
||||||
|
state: &str,
|
||||||
|
) -> Result<(AuthorizeQuery, Person)> {
|
||||||
|
let pending = self
|
||||||
|
.pending
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.remove(state)
|
||||||
|
.filter(|p| p.created.elapsed() < PENDING_TTL)
|
||||||
|
.ok_or_else(|| anyhow!("this sign-in has expired; start again from VS Code"))?;
|
||||||
|
let d = self.discovery().await?;
|
||||||
|
let redirect_uri = Self::callback_url(public_url);
|
||||||
|
let mut form = vec![
|
||||||
|
("grant_type", "authorization_code"),
|
||||||
|
("code", code),
|
||||||
|
("redirect_uri", redirect_uri.as_str()),
|
||||||
|
("client_id", self.config.client_id.as_str()),
|
||||||
|
("code_verifier", pending.verifier.as_str()),
|
||||||
|
];
|
||||||
|
if let Some(secret) = &self.config.client_secret {
|
||||||
|
form.push(("client_secret", secret.as_str()));
|
||||||
|
}
|
||||||
|
let response = self
|
||||||
|
.http
|
||||||
|
.post(&d.token_endpoint)
|
||||||
|
.form(&form)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("the identity provider does not answer")?;
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let status = response.status();
|
||||||
|
let body: Value = response.json().await.unwrap_or(Value::Null);
|
||||||
|
// The provider's own words (invalid_client, …); never the request itself.
|
||||||
|
bail!(
|
||||||
|
"the identity provider refused the sign-in ({status}{})",
|
||||||
|
body.get("error")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(|e| format!(", {e}"))
|
||||||
|
.unwrap_or_default()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let reply: TokenReply = response
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.context("the identity provider sent no ID token")?;
|
||||||
|
let person = self
|
||||||
|
.check_id_token(&d, &reply.id_token, &pending.nonce)
|
||||||
|
.await?;
|
||||||
|
Ok((pending.query, person))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn check_id_token(&self, d: &Discovery, token: &str, nonce: &str) -> Result<Person> {
|
||||||
|
let header = decode_header(token).context("the ID token does not read")?;
|
||||||
|
let key = self.key_for(header.kid.as_deref()).await?;
|
||||||
|
let mut validation = Validation::new(header.alg);
|
||||||
|
validation.set_issuer(&[d.issuer.as_str()]);
|
||||||
|
validation.set_audience(&[self.config.client_id.as_str()]);
|
||||||
|
validation.set_required_spec_claims(&["exp", "iss", "aud", "sub"]);
|
||||||
|
validation.leeway = 60;
|
||||||
|
let claims: HashMap<String, Value> = decode(token, &key, &validation)
|
||||||
|
.context("the ID token is not valid")?
|
||||||
|
.claims;
|
||||||
|
ensure!(
|
||||||
|
claims.get("nonce").and_then(Value::as_str) == Some(nonce),
|
||||||
|
"the ID token belongs to another sign-in"
|
||||||
|
);
|
||||||
|
Ok(person_from(&claims, &self.config.groups_claim))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn person_from(claims: &HashMap<String, Value>, groups_claim: &str) -> Person {
|
||||||
|
let text = |key: &str| {
|
||||||
|
claims
|
||||||
|
.get(key)
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(str::to_owned)
|
||||||
|
};
|
||||||
|
let email = text("email");
|
||||||
|
let groups = match claims.get(groups_claim) {
|
||||||
|
Some(Value::Array(items)) => items
|
||||||
|
.iter()
|
||||||
|
.filter_map(Value::as_str)
|
||||||
|
// Keycloak sends "/team" when the mapper keeps full paths.
|
||||||
|
.map(|g| g.trim_start_matches('/').to_owned())
|
||||||
|
.collect(),
|
||||||
|
Some(Value::String(one)) => vec![one.trim_start_matches('/').to_owned()],
|
||||||
|
_ => vec![],
|
||||||
|
};
|
||||||
|
Person {
|
||||||
|
subject: text("sub").unwrap_or_default(),
|
||||||
|
username: text("preferred_username").or_else(|| {
|
||||||
|
email
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|e| e.split('@').next())
|
||||||
|
.map(str::to_owned)
|
||||||
|
}),
|
||||||
|
display_name: text("name"),
|
||||||
|
email,
|
||||||
|
groups,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A sign-in name from what the provider calls the person: letters, digits and `. _ - @`.
|
||||||
|
pub fn account_name(person: &Person) -> String {
|
||||||
|
let raw = person.username.as_deref().unwrap_or("user");
|
||||||
|
let mut name: String = raw
|
||||||
|
.chars()
|
||||||
|
.map(|c| {
|
||||||
|
if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '@') {
|
||||||
|
c.to_ascii_lowercase()
|
||||||
|
} else {
|
||||||
|
'-'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.take(56)
|
||||||
|
.collect();
|
||||||
|
if name.len() < 2 {
|
||||||
|
name = format!("user-{name}");
|
||||||
|
}
|
||||||
|
name
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn people_from_keycloak_claims() {
|
||||||
|
let claims: HashMap<String, Value> = serde_json::from_value(serde_json::json!({
|
||||||
|
"sub": "f3c1",
|
||||||
|
"preferred_username": "Ana Šmit",
|
||||||
|
"name": "Ana Šmit",
|
||||||
|
"email": "ana@example.com",
|
||||||
|
"groups": ["/archify-users", "admins"],
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
let person = person_from(&claims, "groups");
|
||||||
|
assert_eq!(person.subject, "f3c1");
|
||||||
|
assert_eq!(person.groups, ["archify-users", "admins"]);
|
||||||
|
assert!(person.in_any(&["archify-users".into()]));
|
||||||
|
assert!(!person.in_any(&["others".into()]));
|
||||||
|
assert_eq!(account_name(&person), "ana--mit");
|
||||||
|
|
||||||
|
let bare: HashMap<String, Value> =
|
||||||
|
serde_json::from_value(serde_json::json!({ "sub": "x", "email": "bo@example.com" }))
|
||||||
|
.unwrap();
|
||||||
|
let person = person_from(&bare, "groups");
|
||||||
|
assert_eq!(account_name(&person), "bo");
|
||||||
|
assert!(person.groups.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,574 @@
|
|||||||
|
//! The few pages people open in a browser: first-start setup, sign-in for
|
||||||
|
//! the editor (with sign-up through an invite), and the join page an invite
|
||||||
|
//! link opens.
|
||||||
|
|
||||||
|
use archify_sync_protocol::SetupRequest;
|
||||||
|
use archify_sync_store::{AccountRow, InviteOutcome};
|
||||||
|
use axum::Form;
|
||||||
|
use axum::extract::{Path, Query, State};
|
||||||
|
use axum::http::{HeaderValue, StatusCode, header};
|
||||||
|
use axum::response::{Html, IntoResponse, Redirect, Response};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::api;
|
||||||
|
use crate::auth;
|
||||||
|
use crate::error::ApiErr;
|
||||||
|
use crate::metrics::{METRICS, Metrics};
|
||||||
|
use crate::oidc::{self, Person};
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
const CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; form-action 'self' vscode: vscode-insiders: vscodium: cursor: windsurf: http://127.0.0.1:* http://localhost:*; frame-ancestors 'none'; base-uri 'none'";
|
||||||
|
|
||||||
|
fn escape(text: &str) -> String {
|
||||||
|
text.replace('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('"', """)
|
||||||
|
.replace('\'', "'")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn page(state: &AppState, title: &str, body: &str) -> Response {
|
||||||
|
let html = format!(
|
||||||
|
r#"<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{title} · {server}</title><style>
|
||||||
|
:root {{ color-scheme: light dark; --fg: #1f2328; --muted: #59636e; --bg: #f6f8fa; --card: #fff; --line: #d1d9e0; --accent: #0969da; --bad: #d1242f; }}
|
||||||
|
@media (prefers-color-scheme: dark) {{ :root {{ --fg: #e6edf3; --muted: #9198a1; --bg: #0d1117; --card: #151b23; --line: #3d444d; --accent: #4493f8; --bad: #f85149; }} }}
|
||||||
|
body {{ margin: 0; font: 15px/1.5 system-ui, sans-serif; color: var(--fg); background: var(--bg); }}
|
||||||
|
main {{ max-width: 420px; margin: 48px auto; padding: 0 16px; }}
|
||||||
|
.card {{ background: var(--card); border: 1px solid var(--line); border-radius: 8px; padding: 24px; margin-bottom: 16px; }}
|
||||||
|
h1 {{ font-size: 20px; margin: 0 0 4px; }} h2 {{ font-size: 16px; margin: 0 0 12px; }}
|
||||||
|
p.muted {{ color: var(--muted); margin: 0 0 16px; }}
|
||||||
|
label {{ display: block; margin: 12px 0 4px; font-weight: 600; }}
|
||||||
|
input {{ width: 100%; box-sizing: border-box; padding: 8px; border: 1px solid var(--line); border-radius: 6px; background: transparent; color: inherit; font: inherit; }}
|
||||||
|
button, a.button {{ display: inline-block; margin-top: 16px; padding: 8px 16px; border: 0; border-radius: 6px; background: var(--accent); color: #fff; font: inherit; font-weight: 600; text-decoration: none; cursor: pointer; }}
|
||||||
|
.error {{ color: var(--bad); margin: 0 0 12px; }} code {{ font-size: 14px; }}
|
||||||
|
</style></head><body><main><h1>{server}</h1><p class="muted">Archify Sync</p>{body}</main></body></html>"#,
|
||||||
|
title = escape(title),
|
||||||
|
server = escape(&state.config.server_name),
|
||||||
|
);
|
||||||
|
let mut response = Html(html).into_response();
|
||||||
|
let headers = response.headers_mut();
|
||||||
|
headers.insert(
|
||||||
|
header::CONTENT_SECURITY_POLICY,
|
||||||
|
HeaderValue::from_static(CSP),
|
||||||
|
);
|
||||||
|
headers.insert(
|
||||||
|
header::X_CONTENT_TYPE_OPTIONS,
|
||||||
|
HeaderValue::from_static("nosniff"),
|
||||||
|
);
|
||||||
|
headers.insert(
|
||||||
|
header::REFERRER_POLICY,
|
||||||
|
HeaderValue::from_static("no-referrer"),
|
||||||
|
);
|
||||||
|
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
fn error_line(error: Option<&str>) -> String {
|
||||||
|
error
|
||||||
|
.map(|e| format!(r#"<p class="error">{}</p>"#, escape(e)))
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hidden(name: &str, value: &str) -> String {
|
||||||
|
format!(
|
||||||
|
r#"<input type="hidden" name="{name}" value="{}">"#,
|
||||||
|
escape(value)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------- setup
|
||||||
|
|
||||||
|
pub async fn setup_page(State(state): State<AppState>) -> Response {
|
||||||
|
setup_form(&state, None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn setup_form(state: &AppState, error: Option<&str>) -> Response {
|
||||||
|
if state.first_start.lock().await.is_none() {
|
||||||
|
return page(
|
||||||
|
state,
|
||||||
|
"Set up",
|
||||||
|
r#"<div class="card"><h2>Set up</h2><p>This server is set up. Sign in from VS Code with <b>Archify: Sign in to Sync Server…</b></p></div>"#,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let body = format!(
|
||||||
|
r#"<div class="card"><h2>Set up the first admin</h2><p class="muted">The first-start code is in the server's log.</p>{}
|
||||||
|
<form method="post" action="/setup">
|
||||||
|
<label for="code">First-start code</label><input id="code" name="code" autocomplete="off" required>
|
||||||
|
<label for="organisation">Organisation</label><input id="organisation" name="organisation" required>
|
||||||
|
<label for="name">Sign-in name</label><input id="name" name="name" autocomplete="username" required>
|
||||||
|
<label for="display_name">Your name</label><input id="display_name" name="display_name">
|
||||||
|
<label for="password">Password (10 characters or more)</label><input id="password" name="password" type="password" autocomplete="new-password" required>
|
||||||
|
<button type="submit">Set up</button></form></div>"#,
|
||||||
|
error_line(error)
|
||||||
|
);
|
||||||
|
page(state, "Set up", &body)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct SetupForm {
|
||||||
|
code: String,
|
||||||
|
organisation: String,
|
||||||
|
name: String,
|
||||||
|
display_name: Option<String>,
|
||||||
|
password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn setup_submit(State(state): State<AppState>, Form(form): Form<SetupForm>) -> Response {
|
||||||
|
let req = SetupRequest {
|
||||||
|
code: form.code,
|
||||||
|
organisation: form.organisation,
|
||||||
|
name: form.name,
|
||||||
|
display_name: form.display_name,
|
||||||
|
password: form.password,
|
||||||
|
};
|
||||||
|
match api::setup_admin(&state, &req).await {
|
||||||
|
Ok(account) => page(
|
||||||
|
&state,
|
||||||
|
"Set up",
|
||||||
|
&format!(
|
||||||
|
r#"<div class="card"><h2>Done</h2><p>{} is the admin. In VS Code, run <b>Archify: Sign in to Sync Server…</b> with <code>{}</code>.</p></div>"#,
|
||||||
|
escape(&account.display_name),
|
||||||
|
escape(&state.config.public_url)
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Err(e) => setup_form(&state, Some(&e.message)).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------- sign-in
|
||||||
|
|
||||||
|
/// The editor's sign-in request, carried through the password form or the identity provider.
|
||||||
|
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||||
|
pub struct AuthorizeQuery {
|
||||||
|
response_type: String,
|
||||||
|
redirect_uri: String,
|
||||||
|
state: String,
|
||||||
|
code_challenge: String,
|
||||||
|
code_challenge_method: String,
|
||||||
|
invite: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthorizeQuery {
|
||||||
|
fn to_query_string(&self) -> String {
|
||||||
|
let mut q = url::form_urlencoded::Serializer::new(String::new());
|
||||||
|
q.append_pair("response_type", &self.response_type)
|
||||||
|
.append_pair("redirect_uri", &self.redirect_uri)
|
||||||
|
.append_pair("state", &self.state)
|
||||||
|
.append_pair("code_challenge", &self.code_challenge)
|
||||||
|
.append_pair("code_challenge_method", &self.code_challenge_method);
|
||||||
|
if let Some(invite) = &self.invite {
|
||||||
|
q.append_pair("invite", invite);
|
||||||
|
}
|
||||||
|
q.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct AuthorizeForm {
|
||||||
|
#[serde(flatten)]
|
||||||
|
query: AuthorizeQuery,
|
||||||
|
/// `signin` or `signup`.
|
||||||
|
action: String,
|
||||||
|
name: String,
|
||||||
|
password: String,
|
||||||
|
display_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_authorize(state: &AppState, q: &AuthorizeQuery) -> Result<(), &'static str> {
|
||||||
|
if q.response_type != "code" || q.code_challenge_method != "S256" || q.code_challenge.len() < 43
|
||||||
|
{
|
||||||
|
return Err("This sign-in request is not valid. Start again from VS Code.");
|
||||||
|
}
|
||||||
|
if !auth::redirect_allowed(state, &q.redirect_uri) {
|
||||||
|
return Err("This sign-in request does not come from the Archify extension.");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn authorize_page(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(q): Query<AuthorizeQuery>,
|
||||||
|
) -> Response {
|
||||||
|
authorize_form(&state, &q, None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn authorize_form(state: &AppState, q: &AuthorizeQuery, error: Option<&str>) -> Response {
|
||||||
|
if let Err(message) = check_authorize(state, q) {
|
||||||
|
let mut response = page(
|
||||||
|
state,
|
||||||
|
"Sign in",
|
||||||
|
&format!(r#"<div class="card">{}</div>"#, error_line(Some(message))),
|
||||||
|
);
|
||||||
|
*response.status_mut() = StatusCode::BAD_REQUEST;
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
let fields = [
|
||||||
|
hidden("response_type", &q.response_type),
|
||||||
|
hidden("redirect_uri", &q.redirect_uri),
|
||||||
|
hidden("state", &q.state),
|
||||||
|
hidden("code_challenge", &q.code_challenge),
|
||||||
|
hidden("code_challenge_method", &q.code_challenge_method),
|
||||||
|
q.invite
|
||||||
|
.as_deref()
|
||||||
|
.map(|i| hidden("invite", i))
|
||||||
|
.unwrap_or_default(),
|
||||||
|
]
|
||||||
|
.concat();
|
||||||
|
// Single sign-on first when the server has it; the password form stays for local accounts.
|
||||||
|
let mut body = state
|
||||||
|
.oidc
|
||||||
|
.as_ref()
|
||||||
|
.map(|o| {
|
||||||
|
format!(
|
||||||
|
r#"<div class="card"><h2>Sign in for VS Code</h2>{}<a class="button" href="/oidc/start?{}">Sign in with {}</a></div>"#,
|
||||||
|
error_line(error),
|
||||||
|
escape(&q.to_query_string()),
|
||||||
|
escape(&o.config.label),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
let (title, error) = if body.is_empty() {
|
||||||
|
("Sign in for VS Code", error)
|
||||||
|
} else {
|
||||||
|
("Or with a password", None)
|
||||||
|
};
|
||||||
|
body.push_str(&format!(
|
||||||
|
r#"<div class="card"><h2>{title}</h2>{}
|
||||||
|
<form method="post" action="/authorize">{fields}{}
|
||||||
|
<label for="name">Sign-in name</label><input id="name" name="name" autocomplete="username" required>
|
||||||
|
<label for="password">Password</label><input id="password" name="password" type="password" autocomplete="current-password" required>
|
||||||
|
<button type="submit">Sign in</button></form></div>"#,
|
||||||
|
error_line(error),
|
||||||
|
hidden("action", "signin"),
|
||||||
|
));
|
||||||
|
// A valid invite lets someone new make an account in the space's organisation.
|
||||||
|
if let Some(invite) = &q.invite
|
||||||
|
&& let Ok(Some((_, space_name))) =
|
||||||
|
state.store.invite_organisation(&auth::sha256(invite)).await
|
||||||
|
{
|
||||||
|
body.push_str(&format!(
|
||||||
|
r#"<div class="card"><h2>New here?</h2><p class="muted">You were invited to <b>{}</b>. Make an account.</p>
|
||||||
|
<form method="post" action="/authorize">{fields}{}
|
||||||
|
<label for="new-name">Sign-in name</label><input id="new-name" name="name" autocomplete="username" required>
|
||||||
|
<label for="new-display">Your name</label><input id="new-display" name="display_name">
|
||||||
|
<label for="new-password">Password (10 characters or more)</label><input id="new-password" name="password" type="password" autocomplete="new-password" required>
|
||||||
|
<button type="submit">Make account</button></form></div>"#,
|
||||||
|
escape(&space_name),
|
||||||
|
hidden("action", "signup"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
page(state, "Sign in", &body)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn authorize_submit(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Form(form): Form<AuthorizeForm>,
|
||||||
|
) -> Response {
|
||||||
|
let q = form.query.clone();
|
||||||
|
if check_authorize(&state, &q).is_err() {
|
||||||
|
return authorize_form(&state, &q, None).await;
|
||||||
|
}
|
||||||
|
let account = match form.action.as_str() {
|
||||||
|
"signup" => sign_up(&state, &form).await,
|
||||||
|
_ => sign_in(&state, &form).await,
|
||||||
|
};
|
||||||
|
match account {
|
||||||
|
Ok(account) => complete(&state, &q, &account).await,
|
||||||
|
Err(e) => authorize_form(&state, &q, Some(&e.message)).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Signed in: joins the invite's space, if any, and sends the editor its one-time code.
|
||||||
|
async fn complete(state: &AppState, q: &AuthorizeQuery, account: &AccountRow) -> Response {
|
||||||
|
if let Some(invite) = &q.invite
|
||||||
|
&& let Ok(InviteOutcome::Invalid) = state
|
||||||
|
.store
|
||||||
|
.accept_invite(&auth::sha256(invite), account)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::info!(account = %account.id, "signed in with an invite that is no longer valid");
|
||||||
|
}
|
||||||
|
let code = state
|
||||||
|
.codes
|
||||||
|
.insert(account.id, q.code_challenge.clone(), q.redirect_uri.clone())
|
||||||
|
.await;
|
||||||
|
let Ok(mut target) = url::Url::parse(&q.redirect_uri) else {
|
||||||
|
return authorize_form(state, q, None).await;
|
||||||
|
};
|
||||||
|
target
|
||||||
|
.query_pairs_mut()
|
||||||
|
.append_pair("code", &code)
|
||||||
|
.append_pair("state", &q.state);
|
||||||
|
Redirect::to(target.as_str()).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn sign_in(state: &AppState, form: &AuthorizeForm) -> Result<AccountRow, ApiErr> {
|
||||||
|
let name = form.name.trim();
|
||||||
|
if state.sign_in_limits.blocked(name).await {
|
||||||
|
Metrics::inc(&METRICS.sign_in_blocked_total);
|
||||||
|
return Err(ApiErr::too_many(
|
||||||
|
"Too many wrong passwords for this name. Try again in 15 minutes.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let account = state.store.account_by_name(name).await?;
|
||||||
|
match account {
|
||||||
|
Some(a)
|
||||||
|
if a.password_hash
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|h| auth::verify_password(&form.password, h)) =>
|
||||||
|
{
|
||||||
|
state.sign_in_limits.succeeded(name).await;
|
||||||
|
Ok(a)
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
Metrics::inc(&METRICS.sign_in_failures_total);
|
||||||
|
state.sign_in_limits.failed(name).await;
|
||||||
|
// The same work whether or not the name exists.
|
||||||
|
if account_missing(&account) {
|
||||||
|
let _ = auth::hash_password(&form.password);
|
||||||
|
}
|
||||||
|
Err(ApiErr::unauthorized("The name or the password is wrong."))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn account_missing(account: &Option<AccountRow>) -> bool {
|
||||||
|
account
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|a| a.password_hash.as_ref())
|
||||||
|
.is_none()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn sign_up(state: &AppState, form: &AuthorizeForm) -> Result<AccountRow, ApiErr> {
|
||||||
|
let invite = form
|
||||||
|
.query
|
||||||
|
.invite
|
||||||
|
.as_deref()
|
||||||
|
.ok_or_else(|| ApiErr::forbidden("New accounts need an invite link."))?;
|
||||||
|
let (organisation, _) = state
|
||||||
|
.store
|
||||||
|
.invite_organisation(&auth::sha256(invite))
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| ApiErr::forbidden("This invite link has expired or was used up."))?;
|
||||||
|
let name = form.name.trim();
|
||||||
|
auth::check_name(name)?;
|
||||||
|
auth::check_new_password(&form.password)?;
|
||||||
|
let hash = auth::hash_password(&form.password)?;
|
||||||
|
let display = form
|
||||||
|
.display_name
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or(name);
|
||||||
|
Ok(state
|
||||||
|
.store
|
||||||
|
.create_account(organisation, name, display, &hash)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------- join
|
||||||
|
|
||||||
|
pub async fn join_page(State(state): State<AppState>, Path(token): Path<String>) -> Response {
|
||||||
|
let space = state
|
||||||
|
.store
|
||||||
|
.invite_organisation(&auth::sha256(&token))
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
|
let Some((_, space_name)) = space else {
|
||||||
|
let mut response = page(
|
||||||
|
&state,
|
||||||
|
"Join",
|
||||||
|
r#"<div class="card"><h2>Join</h2><p class="error">This invite link has expired or was used up. Ask for a new one.</p></div>"#,
|
||||||
|
);
|
||||||
|
*response.status_mut() = StatusCode::NOT_FOUND;
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
let link = |scheme: &str| {
|
||||||
|
let mut url = url::Url::parse(&format!("{scheme}://{}/join", state.config.extension_id))
|
||||||
|
.expect("valid base");
|
||||||
|
url.query_pairs_mut()
|
||||||
|
.append_pair("server", &state.config.public_url)
|
||||||
|
.append_pair("invite", &token);
|
||||||
|
escape(url.as_str())
|
||||||
|
};
|
||||||
|
let body = format!(
|
||||||
|
r#"<div class="card"><h2>Join {space}</h2><p>You were invited to work on the model <b>{space}</b> together.</p>
|
||||||
|
<p class="muted">You need the Archify Diagram Viewer extension.</p>
|
||||||
|
<a class="button" href="{vscode}">Open in VS Code</a></div>
|
||||||
|
<div class="card"><p class="muted">Other editors: <a href="{codium}">VSCodium</a> · <a href="{cursor}">Cursor</a></p></div>"#,
|
||||||
|
space = escape(&space_name),
|
||||||
|
vscode = link("vscode"),
|
||||||
|
codium = link("vscodium"),
|
||||||
|
cursor = link("cursor"),
|
||||||
|
);
|
||||||
|
page(&state, "Join", &body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------- single sign-on
|
||||||
|
|
||||||
|
pub async fn oidc_start(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(q): Query<AuthorizeQuery>,
|
||||||
|
) -> Response {
|
||||||
|
let Some(oidc) = &state.oidc else {
|
||||||
|
return StatusCode::NOT_FOUND.into_response();
|
||||||
|
};
|
||||||
|
if check_authorize(&state, &q).is_err() {
|
||||||
|
return authorize_form(&state, &q, None).await;
|
||||||
|
}
|
||||||
|
match oidc.start(&state.config.public_url, q.clone()).await {
|
||||||
|
Ok(url) => Redirect::to(&url).into_response(),
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!(%error, "single sign-on did not start");
|
||||||
|
authorize_form(
|
||||||
|
&state,
|
||||||
|
&q,
|
||||||
|
Some(&format!("Single sign-on is not available: {error}.")),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct CallbackQuery {
|
||||||
|
code: Option<String>,
|
||||||
|
state: Option<String>,
|
||||||
|
error: Option<String>,
|
||||||
|
error_description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sso_problem(state: &AppState, message: &str) -> Response {
|
||||||
|
let mut response = page(
|
||||||
|
state,
|
||||||
|
"Sign in",
|
||||||
|
&format!(
|
||||||
|
r#"<div class="card"><h2>Sign in for VS Code</h2>{}<p class="muted">Close this tab and sign in again from VS Code.</p></div>"#,
|
||||||
|
error_line(Some(message))
|
||||||
|
),
|
||||||
|
);
|
||||||
|
*response.status_mut() = StatusCode::BAD_REQUEST;
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn oidc_callback(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(cb): Query<CallbackQuery>,
|
||||||
|
) -> Response {
|
||||||
|
let Some(oidc) = &state.oidc else {
|
||||||
|
return StatusCode::NOT_FOUND.into_response();
|
||||||
|
};
|
||||||
|
if let Some(error) = cb.error {
|
||||||
|
let detail = cb.error_description.unwrap_or(error);
|
||||||
|
return sso_problem(
|
||||||
|
&state,
|
||||||
|
&format!("The identity provider did not sign you in: {detail}"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let (Some(code), Some(sso_state)) = (cb.code, cb.state) else {
|
||||||
|
return sso_problem(&state, "The identity provider sent no sign-in.");
|
||||||
|
};
|
||||||
|
let (q, person) = match oidc
|
||||||
|
.finish(&state.config.public_url, &code, &sso_state)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(found) => found,
|
||||||
|
Err(error) => {
|
||||||
|
Metrics::inc(&METRICS.sign_in_failures_total);
|
||||||
|
tracing::warn!(%error, "single sign-on failed");
|
||||||
|
return sso_problem(&state, &format!("Single sign-on failed: {error}."));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match sso_account(&state, &q, &person).await {
|
||||||
|
Ok(account) => {
|
||||||
|
tracing::info!(account = %account.id, name = %account.name, "signed in through single sign-on");
|
||||||
|
complete(&state, &q, &account).await
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
Metrics::inc(&METRICS.sign_in_failures_total);
|
||||||
|
authorize_form(&state, &q, Some(&e.message)).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The account of a person the identity provider signed in: found by the
|
||||||
|
/// provider's subject, or made on the first sign-in when an invite or the
|
||||||
|
/// allowed groups let them in.
|
||||||
|
async fn sso_account(
|
||||||
|
state: &AppState,
|
||||||
|
q: &AuthorizeQuery,
|
||||||
|
person: &Person,
|
||||||
|
) -> Result<AccountRow, ApiErr> {
|
||||||
|
let config = &state.oidc.as_ref().expect("checked by the caller").config;
|
||||||
|
if person.subject.is_empty() {
|
||||||
|
return Err(ApiErr::unauthorized(
|
||||||
|
"The identity provider did not say who you are.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let admin = person.in_any(&config.admin_groups);
|
||||||
|
if !config.allowed_groups.is_empty() && !admin && !person.in_any(&config.allowed_groups) {
|
||||||
|
return Err(ApiErr::forbidden(format!(
|
||||||
|
"{} signed you in, but your account there is not in a group this server allows.",
|
||||||
|
config.label
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if let Some(mut account) = state
|
||||||
|
.store
|
||||||
|
.account_by_identity(&config.issuer, &person.subject)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
if admin && account.role != "admin" {
|
||||||
|
state.store.set_role(account.id, "admin").await?;
|
||||||
|
account.role = "admin".into();
|
||||||
|
}
|
||||||
|
return Ok(account);
|
||||||
|
}
|
||||||
|
let organisation = match q.invite.as_deref() {
|
||||||
|
Some(invite) => state
|
||||||
|
.store
|
||||||
|
.invite_organisation(&auth::sha256(invite))
|
||||||
|
.await?
|
||||||
|
.map(|(org, _)| org),
|
||||||
|
None if !config.allowed_groups.is_empty() || admin => {
|
||||||
|
state.store.first_organisation().await?
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
let Some(organisation) = organisation else {
|
||||||
|
return Err(ApiErr::forbidden(format!(
|
||||||
|
"{} signed you in, but you have no account on this server yet. Ask someone for an invite link and open it.",
|
||||||
|
config.label
|
||||||
|
)));
|
||||||
|
};
|
||||||
|
let name = oidc::account_name(person);
|
||||||
|
let display = person
|
||||||
|
.display_name
|
||||||
|
.clone()
|
||||||
|
.or_else(|| person.username.clone())
|
||||||
|
.unwrap_or_else(|| name.clone());
|
||||||
|
let role = if admin { "admin" } else { "member" };
|
||||||
|
match state
|
||||||
|
.store
|
||||||
|
.create_sso_account(
|
||||||
|
organisation,
|
||||||
|
&name,
|
||||||
|
&display,
|
||||||
|
role,
|
||||||
|
&config.issuer,
|
||||||
|
&person.subject,
|
||||||
|
person.email.as_deref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(account) => Ok(account),
|
||||||
|
// Two sign-ins of the same person at once: the other one made the account.
|
||||||
|
Err(archify_sync_store::StoreError::Conflict("identity")) => state
|
||||||
|
.store
|
||||||
|
.account_by_identity(&config.issuer, &person.subject)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| ApiErr::unauthorized("Sign in again.")),
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use archify_sync_store::Store;
|
||||||
|
use base64::Engine;
|
||||||
|
use base64::engine::general_purpose::STANDARD;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
use crate::auth::{self, Keys, PendingCodes, SignInLimits};
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::hub::Hub;
|
||||||
|
use crate::oidc::Oidc;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppState(Arc<Inner>);
|
||||||
|
|
||||||
|
pub struct Inner {
|
||||||
|
pub config: Config,
|
||||||
|
pub store: Store,
|
||||||
|
pub hub: Arc<Hub>,
|
||||||
|
pub keys: Keys,
|
||||||
|
pub codes: PendingCodes,
|
||||||
|
pub sign_in_limits: SignInLimits,
|
||||||
|
pub oidc: Option<Oidc>,
|
||||||
|
/// The first-start code while the server has no accounts; printed in the log.
|
||||||
|
pub first_start: Mutex<Option<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::ops::Deref for AppState {
|
||||||
|
type Target = Inner;
|
||||||
|
fn deref(&self) -> &Inner {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
pub async fn new(config: Config, store: Store) -> anyhow::Result<Self> {
|
||||||
|
// One signing key per server, shared by replicas through the database.
|
||||||
|
let generated = STANDARD.encode(auth::random_token());
|
||||||
|
let stored = store
|
||||||
|
.setting_or_insert("jwt_key", serde_json::Value::String(generated))
|
||||||
|
.await?;
|
||||||
|
let secret = STANDARD.decode(stored.as_str().unwrap_or_default())?;
|
||||||
|
anyhow::ensure!(secret.len() >= 32, "the stored signing key is too short");
|
||||||
|
|
||||||
|
let first_start = if store.account_count().await? == 0 {
|
||||||
|
let code = auth::first_start_code();
|
||||||
|
tracing::warn!(
|
||||||
|
"first start: no accounts yet. Set up the first admin at {}/setup with code {code}",
|
||||||
|
config.public_url
|
||||||
|
);
|
||||||
|
Some(code)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let oidc = match &config.oidc {
|
||||||
|
Some(c) => {
|
||||||
|
tracing::info!(
|
||||||
|
issuer = %c.issuer,
|
||||||
|
client = %c.client_id,
|
||||||
|
callback = %Oidc::callback_url(&config.public_url),
|
||||||
|
"single sign-on is on"
|
||||||
|
);
|
||||||
|
Some(Oidc::new(c.clone())?)
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
let hub = Arc::new(Hub::new(store.clone(), config.snapshots));
|
||||||
|
Ok(Self(Arc::new(Inner {
|
||||||
|
keys: Keys::from_secret(&secret),
|
||||||
|
codes: PendingCodes::default(),
|
||||||
|
sign_in_limits: SignInLimits::default(),
|
||||||
|
oidc,
|
||||||
|
first_start: Mutex::new(first_start),
|
||||||
|
config,
|
||||||
|
store,
|
||||||
|
hub,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[package]
|
||||||
|
name = "archify-sync-store"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
chrono = { version = "0.4.45", features = ["serde"] }
|
||||||
|
serde_json = "1.0.154"
|
||||||
|
sqlx = { version = "0.9.0", default-features = false, features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "chrono", "json", "migrate", "macros"] }
|
||||||
|
thiserror = "2.0.21"
|
||||||
|
tokio = { version = "1.54.1", features = ["rt"] }
|
||||||
|
uuid = { version = "1.29.0", features = ["serde", "v7"] }
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
-- Organisations, accounts and sessions.
|
||||||
|
|
||||||
|
CREATE TABLE organisations (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
name text NOT NULL,
|
||||||
|
meta jsonb NOT NULL DEFAULT '{}',
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE accounts (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
organisation_id uuid NOT NULL REFERENCES organisations (id) ON DELETE CASCADE,
|
||||||
|
name text NOT NULL, -- sign-in name, unique per server
|
||||||
|
display_name text NOT NULL,
|
||||||
|
role text NOT NULL CHECK (role IN ('admin', 'member')),
|
||||||
|
password_hash text, -- argon2; NULL for SSO-only accounts
|
||||||
|
meta jsonb NOT NULL DEFAULT '{}',
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX accounts_name ON accounts (lower(name));
|
||||||
|
|
||||||
|
-- Refresh tokens rotate on every use; a reused token revokes its family.
|
||||||
|
CREATE TABLE refresh_tokens (
|
||||||
|
token_hash bytea PRIMARY KEY,
|
||||||
|
family uuid NOT NULL,
|
||||||
|
account_id uuid NOT NULL REFERENCES accounts (id) ON DELETE CASCADE,
|
||||||
|
expires_at timestamptz NOT NULL,
|
||||||
|
used_at timestamptz,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX refresh_tokens_family ON refresh_tokens (family);
|
||||||
|
|
||||||
|
-- Server-wide values: the signing key, whether setup is done.
|
||||||
|
CREATE TABLE settings (
|
||||||
|
key text PRIMARY KEY,
|
||||||
|
value jsonb NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Spaces: one shared model each.
|
||||||
|
|
||||||
|
CREATE TABLE spaces (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
organisation_id uuid NOT NULL REFERENCES organisations (id) ON DELETE CASCADE,
|
||||||
|
name text NOT NULL,
|
||||||
|
created_by uuid REFERENCES accounts (id) ON DELETE SET NULL,
|
||||||
|
meta jsonb NOT NULL DEFAULT '{}',
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE members (
|
||||||
|
space_id uuid NOT NULL REFERENCES spaces (id) ON DELETE CASCADE,
|
||||||
|
account_id uuid NOT NULL REFERENCES accounts (id) ON DELETE CASCADE,
|
||||||
|
role text NOT NULL CHECK (role IN ('owner', 'editor')),
|
||||||
|
joined_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (space_id, account_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX members_account ON members (account_id);
|
||||||
|
|
||||||
|
CREATE TABLE invites (
|
||||||
|
token_hash bytea PRIMARY KEY,
|
||||||
|
space_id uuid NOT NULL REFERENCES spaces (id) ON DELETE CASCADE,
|
||||||
|
created_by uuid REFERENCES accounts (id) ON DELETE SET NULL,
|
||||||
|
expires_at timestamptz NOT NULL,
|
||||||
|
max_uses integer,
|
||||||
|
uses integer NOT NULL DEFAULT 0,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- The CRDT: an append-only log of Yjs updates since the last version, and
|
||||||
|
-- versions as full encoded documents. Taking a version compacts the log.
|
||||||
|
|
||||||
|
CREATE TABLE updates (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
space_id uuid NOT NULL REFERENCES spaces (id) ON DELETE CASCADE,
|
||||||
|
data bytea NOT NULL,
|
||||||
|
account_id uuid REFERENCES accounts (id) ON DELETE SET NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX updates_space ON updates (space_id, id);
|
||||||
|
|
||||||
|
CREATE TABLE versions (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
space_id uuid NOT NULL REFERENCES spaces (id) ON DELETE CASCADE,
|
||||||
|
major integer NOT NULL,
|
||||||
|
minor integer NOT NULL,
|
||||||
|
kind text NOT NULL CHECK (kind IN ('snapshot', 'publish', 'restore')),
|
||||||
|
name text,
|
||||||
|
state bytea NOT NULL,
|
||||||
|
up_to bigint NOT NULL, -- last update id the state contains
|
||||||
|
restored_from bigint REFERENCES versions (id) ON DELETE SET NULL,
|
||||||
|
created_by uuid REFERENCES accounts (id) ON DELETE SET NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (space_id, major, minor)
|
||||||
|
);
|
||||||
|
CREATE INDEX versions_space ON versions (space_id, id DESC);
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- Accounts signed in through an identity provider (OIDC): the provider's
|
||||||
|
-- issuer and subject name the person; the account is ours.
|
||||||
|
|
||||||
|
CREATE TABLE identities (
|
||||||
|
issuer text NOT NULL,
|
||||||
|
subject text NOT NULL,
|
||||||
|
account_id uuid NOT NULL REFERENCES accounts (id) ON DELETE CASCADE,
|
||||||
|
email text,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
last_seen_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (issuer, subject)
|
||||||
|
);
|
||||||
|
CREATE INDEX identities_account ON identities (account_id);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
$schema: 'https://moonrepo.dev/schemas/project.json'
|
||||||
|
language: rust
|
||||||
|
tasks:
|
||||||
|
check:
|
||||||
|
command: cargo clippy -p archify-sync-store --all-targets -- -D warnings
|
||||||
|
inputs: ['src/**/*', 'Cargo.toml']
|
||||||
|
test:
|
||||||
|
command: cargo test -p archify-sync-store
|
||||||
|
inputs: ['src/**/*', 'Cargo.toml']
|
||||||
|
format:
|
||||||
|
command: cargo fmt -p archify-sync-store --check
|
||||||
|
inputs: ['src/**/*']
|
||||||
@@ -0,0 +1,726 @@
|
|||||||
|
//! PostgreSQL, the server's only state: accounts and sessions, spaces and
|
||||||
|
//! their members, the Yjs update log and the versions.
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use sqlx::postgres::{PgPool, PgPoolOptions};
|
||||||
|
use sqlx::{FromRow, Postgres, Transaction};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
pub use sqlx::Error as SqlError;
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum StoreError {
|
||||||
|
#[error(transparent)]
|
||||||
|
Sql(#[from] sqlx::Error),
|
||||||
|
#[error(transparent)]
|
||||||
|
Migrate(#[from] sqlx::migrate::MigrateError),
|
||||||
|
#[error("{0} already exists")]
|
||||||
|
Conflict(&'static str),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type Result<T> = std::result::Result<T, StoreError>;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, FromRow)]
|
||||||
|
pub struct AccountRow {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub organisation_id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub role: String,
|
||||||
|
pub password_hash: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, FromRow)]
|
||||||
|
pub struct SpaceRow {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub organisation_id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
/// The asking account's role in the space.
|
||||||
|
pub role: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, FromRow)]
|
||||||
|
pub struct MemberRow {
|
||||||
|
pub account_id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub role: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, FromRow)]
|
||||||
|
pub struct VersionRow {
|
||||||
|
pub id: i64,
|
||||||
|
pub major: i32,
|
||||||
|
pub minor: i32,
|
||||||
|
pub kind: String,
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub up_to: i64,
|
||||||
|
pub restored_from: Option<i64>,
|
||||||
|
pub created_by: Option<Uuid>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a space's document is made of: the latest version's state and the updates after it.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct StoredDoc {
|
||||||
|
pub state: Option<Vec<u8>>,
|
||||||
|
pub updates: Vec<Vec<u8>>,
|
||||||
|
/// Id of the last update in the log, or the latest version's `up_to` when the log is empty.
|
||||||
|
pub last_update: i64,
|
||||||
|
pub latest: Option<VersionRow>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// family, account, expires_at, used_at
|
||||||
|
type RefreshRow = (Uuid, Uuid, DateTime<Utc>, bool);
|
||||||
|
|
||||||
|
pub enum RefreshOutcome {
|
||||||
|
Valid {
|
||||||
|
account: AccountRow,
|
||||||
|
family: Uuid,
|
||||||
|
},
|
||||||
|
/// Unknown or expired.
|
||||||
|
Invalid,
|
||||||
|
/// Used before: someone holds a copy. The whole family is revoked.
|
||||||
|
Reused,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum InviteOutcome {
|
||||||
|
Joined { space_id: Uuid },
|
||||||
|
Invalid,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Store {
|
||||||
|
pool: PgPool,
|
||||||
|
}
|
||||||
|
|
||||||
|
// A macro, not a const: sqlx takes only static SQL, which `concat!` keeps.
|
||||||
|
macro_rules! version_columns {
|
||||||
|
() => {
|
||||||
|
"id, major, minor, kind, name, up_to, restored_from, created_by, created_at"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Store {
|
||||||
|
pub async fn connect(url: &str, max_connections: u32) -> Result<Self> {
|
||||||
|
let pool = PgPoolOptions::new()
|
||||||
|
.max_connections(max_connections)
|
||||||
|
.connect(url)
|
||||||
|
.await?;
|
||||||
|
Ok(Self { pool })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn migrate(&self) -> Result<()> {
|
||||||
|
sqlx::migrate!("./migrations").run(&self.pool).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ping(&self) -> Result<()> {
|
||||||
|
sqlx::query("SELECT 1").execute(&self.pool).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ settings
|
||||||
|
|
||||||
|
pub async fn setting(&self, key: &str) -> Result<Option<serde_json::Value>> {
|
||||||
|
Ok(
|
||||||
|
sqlx::query_scalar("SELECT value FROM settings WHERE key = $1")
|
||||||
|
.bind(key)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stores the value unless the key exists; returns the value that is stored.
|
||||||
|
pub async fn setting_or_insert(
|
||||||
|
&self,
|
||||||
|
key: &str,
|
||||||
|
value: serde_json::Value,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO settings (key, value) VALUES ($1, $2) ON CONFLICT (key) DO NOTHING",
|
||||||
|
)
|
||||||
|
.bind(key)
|
||||||
|
.bind(&value)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(self.setting(key).await?.unwrap_or(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ accounts
|
||||||
|
|
||||||
|
pub async fn account_count(&self) -> Result<i64> {
|
||||||
|
Ok(sqlx::query_scalar("SELECT count(*) FROM accounts")
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The first organisation and its admin, only while there are no accounts.
|
||||||
|
pub async fn create_first_admin(
|
||||||
|
&self,
|
||||||
|
organisation: &str,
|
||||||
|
name: &str,
|
||||||
|
display_name: &str,
|
||||||
|
password_hash: &str,
|
||||||
|
) -> Result<Option<AccountRow>> {
|
||||||
|
let mut tx = self.pool.begin().await?;
|
||||||
|
// Serialises concurrent setup attempts.
|
||||||
|
sqlx::query("LOCK TABLE accounts IN EXCLUSIVE MODE")
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
let count: i64 = sqlx::query_scalar("SELECT count(*) FROM accounts")
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
if count > 0 {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let org = Uuid::now_v7();
|
||||||
|
sqlx::query("INSERT INTO organisations (id, name) VALUES ($1, $2)")
|
||||||
|
.bind(org)
|
||||||
|
.bind(organisation)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
let account = insert_account(
|
||||||
|
&mut tx,
|
||||||
|
org,
|
||||||
|
name,
|
||||||
|
display_name,
|
||||||
|
"admin",
|
||||||
|
Some(password_hash),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(Some(account))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_account(
|
||||||
|
&self,
|
||||||
|
organisation_id: Uuid,
|
||||||
|
name: &str,
|
||||||
|
display_name: &str,
|
||||||
|
password_hash: &str,
|
||||||
|
) -> Result<AccountRow> {
|
||||||
|
let mut tx = self.pool.begin().await?;
|
||||||
|
let account = insert_account(
|
||||||
|
&mut tx,
|
||||||
|
organisation_id,
|
||||||
|
name,
|
||||||
|
display_name,
|
||||||
|
"member",
|
||||||
|
Some(password_hash),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(account)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn account_by_name(&self, name: &str) -> Result<Option<AccountRow>> {
|
||||||
|
Ok(sqlx::query_as(
|
||||||
|
"SELECT id, organisation_id, name, display_name, role, password_hash FROM accounts WHERE lower(name) = lower($1)",
|
||||||
|
)
|
||||||
|
.bind(name)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn account(&self, id: Uuid) -> Result<Option<AccountRow>> {
|
||||||
|
Ok(sqlx::query_as(
|
||||||
|
"SELECT id, organisation_id, name, display_name, role, password_hash FROM accounts WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------ SSO identities
|
||||||
|
|
||||||
|
/// The account an identity provider's person signs in as, if any.
|
||||||
|
pub async fn account_by_identity(
|
||||||
|
&self,
|
||||||
|
issuer: &str,
|
||||||
|
subject: &str,
|
||||||
|
) -> Result<Option<AccountRow>> {
|
||||||
|
Ok(sqlx::query_as(
|
||||||
|
"UPDATE identities i SET last_seen_at = now() FROM accounts a
|
||||||
|
WHERE i.issuer = $1 AND i.subject = $2 AND a.id = i.account_id
|
||||||
|
RETURNING a.id, a.organisation_id, a.name, a.display_name, a.role, a.password_hash",
|
||||||
|
)
|
||||||
|
.bind(issuer)
|
||||||
|
.bind(subject)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A new account without a password, signed in through an identity
|
||||||
|
/// provider. Takes `name`, or `name-2`, `name-3`, … when it is taken: an
|
||||||
|
/// SSO person never takes over an existing account by its name.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub async fn create_sso_account(
|
||||||
|
&self,
|
||||||
|
organisation_id: Uuid,
|
||||||
|
name: &str,
|
||||||
|
display_name: &str,
|
||||||
|
role: &str,
|
||||||
|
issuer: &str,
|
||||||
|
subject: &str,
|
||||||
|
email: Option<&str>,
|
||||||
|
) -> Result<AccountRow> {
|
||||||
|
for n in 1..=20 {
|
||||||
|
let candidate = if n == 1 {
|
||||||
|
name.to_owned()
|
||||||
|
} else {
|
||||||
|
format!("{name}-{n}")
|
||||||
|
};
|
||||||
|
let mut tx = self.pool.begin().await?;
|
||||||
|
let account = match insert_account(
|
||||||
|
&mut tx,
|
||||||
|
organisation_id,
|
||||||
|
&candidate,
|
||||||
|
display_name,
|
||||||
|
role,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(account) => account,
|
||||||
|
Err(StoreError::Conflict(_)) => continue,
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
};
|
||||||
|
sqlx::query("INSERT INTO identities (issuer, subject, account_id, email) VALUES ($1, $2, $3, $4)")
|
||||||
|
.bind(issuer)
|
||||||
|
.bind(subject)
|
||||||
|
.bind(account.id)
|
||||||
|
.bind(email)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| match &e {
|
||||||
|
sqlx::Error::Database(d) if d.is_unique_violation() => StoreError::Conflict("identity"),
|
||||||
|
_ => StoreError::Sql(e),
|
||||||
|
})?;
|
||||||
|
tx.commit().await?;
|
||||||
|
return Ok(account);
|
||||||
|
}
|
||||||
|
Err(StoreError::Conflict("account"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_role(&self, account: Uuid, role: &str) -> Result<()> {
|
||||||
|
sqlx::query("UPDATE accounts SET role = $2 WHERE id = $1")
|
||||||
|
.bind(account)
|
||||||
|
.bind(role)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The server's organisation: the one made at setup.
|
||||||
|
pub async fn first_organisation(&self) -> Result<Option<Uuid>> {
|
||||||
|
Ok(
|
||||||
|
sqlx::query_scalar("SELECT id FROM organisations ORDER BY created_at LIMIT 1")
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------ refresh tokens
|
||||||
|
|
||||||
|
pub async fn insert_refresh(
|
||||||
|
&self,
|
||||||
|
hash: &[u8],
|
||||||
|
family: Uuid,
|
||||||
|
account: Uuid,
|
||||||
|
expires_at: DateTime<Utc>,
|
||||||
|
) -> Result<()> {
|
||||||
|
sqlx::query("INSERT INTO refresh_tokens (token_hash, family, account_id, expires_at) VALUES ($1, $2, $3, $4)")
|
||||||
|
.bind(hash)
|
||||||
|
.bind(family)
|
||||||
|
.bind(account)
|
||||||
|
.bind(expires_at)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marks the token used. A token used again later than `grace_secs` after its
|
||||||
|
/// first use revokes every token of its family; within the grace, two windows
|
||||||
|
/// of the same editor that refreshed at the same moment both get through.
|
||||||
|
pub async fn use_refresh(&self, hash: &[u8], grace_secs: u32) -> Result<RefreshOutcome> {
|
||||||
|
let mut tx = self.pool.begin().await?;
|
||||||
|
let row: Option<RefreshRow> = sqlx::query_as(
|
||||||
|
"SELECT family, account_id, expires_at,
|
||||||
|
used_at IS NOT NULL AND used_at < now() - make_interval(secs => $2)
|
||||||
|
FROM refresh_tokens WHERE token_hash = $1 FOR UPDATE",
|
||||||
|
)
|
||||||
|
.bind(hash)
|
||||||
|
.bind(f64::from(grace_secs))
|
||||||
|
.fetch_optional(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
let Some((family, account_id, expires_at, reused)) = row else {
|
||||||
|
return Ok(RefreshOutcome::Invalid);
|
||||||
|
};
|
||||||
|
if reused {
|
||||||
|
sqlx::query("DELETE FROM refresh_tokens WHERE family = $1")
|
||||||
|
.bind(family)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
return Ok(RefreshOutcome::Reused);
|
||||||
|
}
|
||||||
|
if expires_at < Utc::now() {
|
||||||
|
return Ok(RefreshOutcome::Invalid);
|
||||||
|
}
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE refresh_tokens SET used_at = coalesce(used_at, now()) WHERE token_hash = $1",
|
||||||
|
)
|
||||||
|
.bind(hash)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
let account: Option<AccountRow> = sqlx::query_as(
|
||||||
|
"SELECT id, organisation_id, name, display_name, role, password_hash FROM accounts WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(account_id)
|
||||||
|
.fetch_optional(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(match account {
|
||||||
|
Some(account) => RefreshOutcome::Valid { account, family },
|
||||||
|
None => RefreshOutcome::Invalid,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Signs out the session the token belongs to.
|
||||||
|
pub async fn revoke_refresh(&self, hash: &[u8]) -> Result<()> {
|
||||||
|
sqlx::query("DELETE FROM refresh_tokens WHERE family = (SELECT family FROM refresh_tokens WHERE token_hash = $1)")
|
||||||
|
.bind(hash)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_expired_refresh(&self) -> Result<u64> {
|
||||||
|
// Used tokens are kept until expiry so a reuse is still recognised.
|
||||||
|
let done = sqlx::query("DELETE FROM refresh_tokens WHERE expires_at < now()")
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(done.rows_affected())
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------- spaces
|
||||||
|
|
||||||
|
pub async fn create_space(
|
||||||
|
&self,
|
||||||
|
organisation_id: Uuid,
|
||||||
|
name: &str,
|
||||||
|
owner: Uuid,
|
||||||
|
) -> Result<SpaceRow> {
|
||||||
|
let mut tx = self.pool.begin().await?;
|
||||||
|
let id = Uuid::now_v7();
|
||||||
|
let created_at: DateTime<Utc> = sqlx::query_scalar(
|
||||||
|
"INSERT INTO spaces (id, organisation_id, name, created_by) VALUES ($1, $2, $3, $4) RETURNING created_at",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(organisation_id)
|
||||||
|
.bind(name)
|
||||||
|
.bind(owner)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
sqlx::query("INSERT INTO members (space_id, account_id, role) VALUES ($1, $2, 'owner')")
|
||||||
|
.bind(id)
|
||||||
|
.bind(owner)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(SpaceRow {
|
||||||
|
id,
|
||||||
|
organisation_id,
|
||||||
|
name: name.to_owned(),
|
||||||
|
created_at,
|
||||||
|
role: "owner".into(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn spaces_of(&self, account: Uuid) -> Result<Vec<SpaceRow>> {
|
||||||
|
Ok(sqlx::query_as(
|
||||||
|
"SELECT s.id, s.organisation_id, s.name, s.created_at, m.role
|
||||||
|
FROM spaces s JOIN members m ON m.space_id = s.id
|
||||||
|
WHERE m.account_id = $1 ORDER BY s.name",
|
||||||
|
)
|
||||||
|
.bind(account)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The space as the account sees it; None when it is not a member.
|
||||||
|
pub async fn space_for(&self, space: Uuid, account: Uuid) -> Result<Option<SpaceRow>> {
|
||||||
|
Ok(sqlx::query_as(
|
||||||
|
"SELECT s.id, s.organisation_id, s.name, s.created_at, m.role
|
||||||
|
FROM spaces s JOIN members m ON m.space_id = s.id
|
||||||
|
WHERE s.id = $1 AND m.account_id = $2",
|
||||||
|
)
|
||||||
|
.bind(space)
|
||||||
|
.bind(account)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn members(&self, space: Uuid) -> Result<Vec<MemberRow>> {
|
||||||
|
Ok(sqlx::query_as(
|
||||||
|
"SELECT a.id AS account_id, a.name, a.display_name, m.role
|
||||||
|
FROM members m JOIN accounts a ON a.id = m.account_id
|
||||||
|
WHERE m.space_id = $1 ORDER BY a.display_name",
|
||||||
|
)
|
||||||
|
.bind(space)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove_member(&self, space: Uuid, account: Uuid) -> Result<bool> {
|
||||||
|
let done = sqlx::query(
|
||||||
|
"DELETE FROM members WHERE space_id = $1 AND account_id = $2 AND role <> 'owner'",
|
||||||
|
)
|
||||||
|
.bind(space)
|
||||||
|
.bind(account)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(done.rows_affected() > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- invites
|
||||||
|
|
||||||
|
pub async fn create_invite(
|
||||||
|
&self,
|
||||||
|
hash: &[u8],
|
||||||
|
space: Uuid,
|
||||||
|
created_by: Uuid,
|
||||||
|
expires_at: DateTime<Utc>,
|
||||||
|
max_uses: Option<i32>,
|
||||||
|
) -> Result<()> {
|
||||||
|
sqlx::query("INSERT INTO invites (token_hash, space_id, created_by, expires_at, max_uses) VALUES ($1, $2, $3, $4, $5)")
|
||||||
|
.bind(hash)
|
||||||
|
.bind(space)
|
||||||
|
.bind(created_by)
|
||||||
|
.bind(expires_at)
|
||||||
|
.bind(max_uses)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The organisation an invite belongs to, if it is still usable; for the join page.
|
||||||
|
pub async fn invite_organisation(&self, hash: &[u8]) -> Result<Option<(Uuid, String)>> {
|
||||||
|
Ok(sqlx::query_as(
|
||||||
|
"SELECT s.organisation_id, s.name FROM invites i JOIN spaces s ON s.id = i.space_id
|
||||||
|
WHERE i.token_hash = $1 AND i.expires_at > now() AND (i.max_uses IS NULL OR i.uses < i.max_uses)",
|
||||||
|
)
|
||||||
|
.bind(hash)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds the account to the invite's space as an editor. Accounts of other
|
||||||
|
/// organisations cannot join.
|
||||||
|
pub async fn accept_invite(&self, hash: &[u8], account: &AccountRow) -> Result<InviteOutcome> {
|
||||||
|
let mut tx = self.pool.begin().await?;
|
||||||
|
// (space, organisation, still usable, already a member)
|
||||||
|
let row: Option<(Uuid, Uuid, bool, bool)> = sqlx::query_as(
|
||||||
|
"SELECT i.space_id, s.organisation_id,
|
||||||
|
i.expires_at > now() AND (i.max_uses IS NULL OR i.uses < i.max_uses),
|
||||||
|
EXISTS (SELECT 1 FROM members m WHERE m.space_id = i.space_id AND m.account_id = $2)
|
||||||
|
FROM invites i JOIN spaces s ON s.id = i.space_id
|
||||||
|
WHERE i.token_hash = $1
|
||||||
|
FOR UPDATE OF i",
|
||||||
|
)
|
||||||
|
.bind(hash)
|
||||||
|
.bind(account.id)
|
||||||
|
.fetch_optional(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
let Some((space_id, organisation_id, usable, member)) = row else {
|
||||||
|
return Ok(InviteOutcome::Invalid);
|
||||||
|
};
|
||||||
|
// Signing up through the invite already joined: accepting again only says where.
|
||||||
|
if member {
|
||||||
|
return Ok(InviteOutcome::Joined { space_id });
|
||||||
|
}
|
||||||
|
if !usable || organisation_id != account.organisation_id {
|
||||||
|
return Ok(InviteOutcome::Invalid);
|
||||||
|
}
|
||||||
|
let added = sqlx::query(
|
||||||
|
"INSERT INTO members (space_id, account_id, role) VALUES ($1, $2, 'editor') ON CONFLICT DO NOTHING",
|
||||||
|
)
|
||||||
|
.bind(space_id)
|
||||||
|
.bind(account.id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
if added.rows_affected() > 0 {
|
||||||
|
sqlx::query("UPDATE invites SET uses = uses + 1 WHERE token_hash = $1")
|
||||||
|
.bind(hash)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(InviteOutcome::Joined { space_id })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ the CRDT
|
||||||
|
|
||||||
|
/// Appends one Yjs update; it is durable when this returns.
|
||||||
|
pub async fn append_update(
|
||||||
|
&self,
|
||||||
|
space: Uuid,
|
||||||
|
data: &[u8],
|
||||||
|
account: Option<Uuid>,
|
||||||
|
) -> Result<i64> {
|
||||||
|
Ok(sqlx::query_scalar(
|
||||||
|
"INSERT INTO updates (space_id, data, account_id) VALUES ($1, $2, $3) RETURNING id",
|
||||||
|
)
|
||||||
|
.bind(space)
|
||||||
|
.bind(data)
|
||||||
|
.bind(account)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_doc(&self, space: Uuid) -> Result<StoredDoc> {
|
||||||
|
let latest = self.latest_version(space).await?;
|
||||||
|
let state: Option<Vec<u8>> = match &latest {
|
||||||
|
Some(v) => {
|
||||||
|
sqlx::query_scalar("SELECT state FROM versions WHERE id = $1")
|
||||||
|
.bind(v.id)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
let from = latest.as_ref().map_or(0, |v| v.up_to);
|
||||||
|
let rows: Vec<(i64, Vec<u8>)> = sqlx::query_as(
|
||||||
|
"SELECT id, data FROM updates WHERE space_id = $1 AND id > $2 ORDER BY id",
|
||||||
|
)
|
||||||
|
.bind(space)
|
||||||
|
.bind(from)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
let last_update = rows.last().map_or(from, |(id, _)| *id);
|
||||||
|
Ok(StoredDoc {
|
||||||
|
state,
|
||||||
|
updates: rows.into_iter().map(|(_, d)| d).collect(),
|
||||||
|
last_update,
|
||||||
|
latest,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn latest_version(&self, space: Uuid) -> Result<Option<VersionRow>> {
|
||||||
|
Ok(sqlx::query_as(concat!(
|
||||||
|
"SELECT ",
|
||||||
|
version_columns!(),
|
||||||
|
" FROM versions WHERE space_id = $1 ORDER BY id DESC LIMIT 1"
|
||||||
|
))
|
||||||
|
.bind(space)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stores a version and drops the updates it contains from the log.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub async fn insert_version(
|
||||||
|
&self,
|
||||||
|
space: Uuid,
|
||||||
|
major: i32,
|
||||||
|
minor: i32,
|
||||||
|
kind: &str,
|
||||||
|
name: Option<&str>,
|
||||||
|
state: &[u8],
|
||||||
|
up_to: i64,
|
||||||
|
restored_from: Option<i64>,
|
||||||
|
created_by: Option<Uuid>,
|
||||||
|
) -> Result<VersionRow> {
|
||||||
|
let mut tx = self.pool.begin().await?;
|
||||||
|
let row: VersionRow = sqlx::query_as(concat!("INSERT INTO versions (space_id, major, minor, kind, name, state, up_to, restored_from, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING ", version_columns!(), ""))
|
||||||
|
.bind(space)
|
||||||
|
.bind(major)
|
||||||
|
.bind(minor)
|
||||||
|
.bind(kind)
|
||||||
|
.bind(name)
|
||||||
|
.bind(state)
|
||||||
|
.bind(up_to)
|
||||||
|
.bind(restored_from)
|
||||||
|
.bind(created_by)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| match &e {
|
||||||
|
sqlx::Error::Database(d) if d.is_unique_violation() => StoreError::Conflict("version"),
|
||||||
|
_ => StoreError::Sql(e),
|
||||||
|
})?;
|
||||||
|
sqlx::query("DELETE FROM updates WHERE space_id = $1 AND id <= $2")
|
||||||
|
.bind(space)
|
||||||
|
.bind(up_to)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn versions(&self, space: Uuid, limit: i64) -> Result<Vec<VersionRow>> {
|
||||||
|
Ok(sqlx::query_as(concat!(
|
||||||
|
"SELECT ",
|
||||||
|
version_columns!(),
|
||||||
|
" FROM versions WHERE space_id = $1 ORDER BY id DESC LIMIT $2"
|
||||||
|
))
|
||||||
|
.bind(space)
|
||||||
|
.bind(limit)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn version(&self, space: Uuid, id: i64) -> Result<Option<(VersionRow, Vec<u8>)>> {
|
||||||
|
let row: Option<VersionRow> = sqlx::query_as(concat!(
|
||||||
|
"SELECT ",
|
||||||
|
version_columns!(),
|
||||||
|
" FROM versions WHERE space_id = $1 AND id = $2"
|
||||||
|
))
|
||||||
|
.bind(space)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
let Some(row) = row else { return Ok(None) };
|
||||||
|
let state: Vec<u8> = sqlx::query_scalar("SELECT state FROM versions WHERE id = $1")
|
||||||
|
.bind(id)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(Some((row, state)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn insert_account(
|
||||||
|
tx: &mut Transaction<'_, Postgres>,
|
||||||
|
organisation_id: Uuid,
|
||||||
|
name: &str,
|
||||||
|
display_name: &str,
|
||||||
|
role: &str,
|
||||||
|
password_hash: Option<&str>,
|
||||||
|
) -> Result<AccountRow> {
|
||||||
|
let id = Uuid::now_v7();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO accounts (id, organisation_id, name, display_name, role, password_hash) VALUES ($1, $2, $3, $4, $5, $6)",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(organisation_id)
|
||||||
|
.bind(name)
|
||||||
|
.bind(display_name)
|
||||||
|
.bind(role)
|
||||||
|
.bind(password_hash)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| match &e {
|
||||||
|
sqlx::Error::Database(d) if d.is_unique_violation() => StoreError::Conflict("account"),
|
||||||
|
_ => StoreError::Sql(e),
|
||||||
|
})?;
|
||||||
|
Ok(AccountRow {
|
||||||
|
id,
|
||||||
|
organisation_id,
|
||||||
|
name: name.to_owned(),
|
||||||
|
display_name: display_name.to_owned(),
|
||||||
|
role: role.to_owned(),
|
||||||
|
password_hash: password_hash.map(str::to_owned),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
$schema: 'https://moonrepo.dev/schemas/project.json'
|
||||||
|
id: protocol-ts
|
||||||
|
language: typescript
|
||||||
|
dependsOn: [protocol]
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"name": "@archify/sync-protocol",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Types shared by the Archify Sync server and the Archify Diagram Viewer extension, generated from Rust.",
|
||||||
|
"license": "MIT",
|
||||||
|
"type": "module",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"types": "src/index.ts",
|
||||||
|
"files": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
import type { OrgRole } from "./OrgRole";
|
||||||
|
|
||||||
|
export type Account = { id: string, organisation_id: string, name: string, display_name: string, role: OrgRole, };
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error body of every failed request.
|
||||||
|
*/
|
||||||
|
export type ApiError = {
|
||||||
|
/**
|
||||||
|
* Stable code for clients (`unauthorized`, `not_found`, `invalid`, `conflict`, …).
|
||||||
|
*/
|
||||||
|
error: string, message: string, };
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
|
export type CreateInviteRequest = {
|
||||||
|
/**
|
||||||
|
* Hours until the link stops working; the server's default when absent.
|
||||||
|
*/
|
||||||
|
expires_in_hours: number | null, max_uses: number | null, };
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
|
export type CreateSpaceRequest = { name: string, };
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
|
export type Invite = {
|
||||||
|
/**
|
||||||
|
* Shown once; the server keeps only its hash.
|
||||||
|
*/
|
||||||
|
token: string,
|
||||||
|
/**
|
||||||
|
* The link to send: opens the server's join page.
|
||||||
|
*/
|
||||||
|
url: string, expires_at: string, };
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
import type { Space } from "./Space";
|
||||||
|
|
||||||
|
export type JoinResponse = { space: Space, };
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
|
export type LogoutRequest = { refresh_token: string, };
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
import type { SpaceRole } from "./SpaceRole";
|
||||||
|
|
||||||
|
export type Member = { account_id: string, name: string, display_name: string, role: SpaceRole, };
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
|
export type OrgRole = "admin" | "member";
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
|
export type PublishRequest = { name: string, };
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /v1/info`: what a client needs before signing in.
|
||||||
|
*/
|
||||||
|
export type ServerInfo = { name: string, version: string, protocol: number,
|
||||||
|
/**
|
||||||
|
* False until the first admin has used the first-start code.
|
||||||
|
*/
|
||||||
|
set_up: boolean, };
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `POST /v1/setup`: the first admin, with the code the server printed on its first start.
|
||||||
|
*/
|
||||||
|
export type SetupRequest = { code: string, organisation: string, name: string, display_name: string | null, password: string, };
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
import type { SpaceRole } from "./SpaceRole";
|
||||||
|
|
||||||
|
export type Space = { id: string, name: string, role: SpaceRole, created_at: string, };
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
|
export type SpaceRole = "owner" | "editor";
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `POST /v1/auth/token`: the code from the browser sign-in, or a refresh token.
|
||||||
|
*/
|
||||||
|
export type TokenRequest = { "grant_type": "authorization_code", code: string, code_verifier: string, redirect_uri: string, } | { "grant_type": "refresh_token", refresh_token: string, };
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
import type { Account } from "./Account";
|
||||||
|
|
||||||
|
export type TokenResponse = { access_token: string,
|
||||||
|
/**
|
||||||
|
* Seconds until the access token expires.
|
||||||
|
*/
|
||||||
|
expires_in: bigint,
|
||||||
|
/**
|
||||||
|
* Single use: every refresh returns a new one, and reusing an old one signs the session out.
|
||||||
|
*/
|
||||||
|
refresh_token: string, account: Account, };
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
import type { VersionKind } from "./VersionKind";
|
||||||
|
|
||||||
|
export type Version = { id: bigint, major: number, minor: number, kind: VersionKind, name: string | null, created_by: string | null, created_at: string,
|
||||||
|
/**
|
||||||
|
* For a restore: the version it restored.
|
||||||
|
*/
|
||||||
|
restored_from: bigint | null, };
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
|
export type VersionFile = { path: string, text: string, };
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
import type { Version } from "./Version";
|
||||||
|
import type { VersionFile } from "./VersionFile";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The model files of a version as text, for comparing and showing.
|
||||||
|
*/
|
||||||
|
export type VersionFiles = { version: Version, files: Array<VersionFile>, };
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
|
export type VersionKind = "snapshot" | "publish" | "restore";
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// Generated types from crates/protocol; regenerate with `moon run protocol:types`.
|
||||||
|
|
||||||
|
/** Version of the wire protocol; sent as `?v=` on the WebSocket. */
|
||||||
|
export const PROTOCOL_VERSION = 1;
|
||||||
|
/** Root map of a space's Yjs document: model-root-relative POSIX path → Y.Text. */
|
||||||
|
export const FILES_MAP = 'files';
|
||||||
|
|
||||||
|
export type { Account } from './generated/Account';
|
||||||
|
export type { ApiError } from './generated/ApiError';
|
||||||
|
export type { CreateInviteRequest } from './generated/CreateInviteRequest';
|
||||||
|
export type { CreateSpaceRequest } from './generated/CreateSpaceRequest';
|
||||||
|
export type { Invite } from './generated/Invite';
|
||||||
|
export type { JoinResponse } from './generated/JoinResponse';
|
||||||
|
export type { LogoutRequest } from './generated/LogoutRequest';
|
||||||
|
export type { Member } from './generated/Member';
|
||||||
|
export type { OrgRole } from './generated/OrgRole';
|
||||||
|
export type { PublishRequest } from './generated/PublishRequest';
|
||||||
|
export type { ServerInfo } from './generated/ServerInfo';
|
||||||
|
export type { SetupRequest } from './generated/SetupRequest';
|
||||||
|
export type { Space } from './generated/Space';
|
||||||
|
export type { SpaceRole } from './generated/SpaceRole';
|
||||||
|
export type { TokenRequest } from './generated/TokenRequest';
|
||||||
|
export type { TokenResponse } from './generated/TokenResponse';
|
||||||
|
export type { Version } from './generated/Version';
|
||||||
|
export type { VersionFile } from './generated/VersionFile';
|
||||||
|
export type { VersionFiles } from './generated/VersionFiles';
|
||||||
|
export type { VersionKind } from './generated/VersionKind';
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
packages:
|
||||||
|
- 'packages/*'
|
||||||
Reference in new issue
Block a user