Compare commits
49
Commits
2274cf57c9
..
master
+6
-1
@@ -11,4 +11,9 @@ README.md
|
|||||||
Dockerfile*
|
Dockerfile*
|
||||||
docker-compose*.yml
|
docker-compose*.yml
|
||||||
migrate/
|
migrate/
|
||||||
scripts/*.txt
|
scripts/*.txt
|
||||||
|
tests/
|
||||||
|
|
||||||
|
# Downloaded in the browser-libs Docker stage
|
||||||
|
static/libs/mermaid/
|
||||||
|
static/libs/highlight/
|
||||||
|
|||||||
@@ -32,10 +32,15 @@ RUST_LOG=rustpad=info,tower_http=warn
|
|||||||
|
|
||||||
# Maximum upload size
|
# Maximum upload size
|
||||||
UPLOAD_MAX_SIZE_MB=20
|
UPLOAD_MAX_SIZE_MB=20
|
||||||
|
GUEST_UPLOAD_ENABLED=false
|
||||||
|
GUEST_UPLOAD_MAX_SIZE_MB=5
|
||||||
|
|
||||||
# Attachment storage: local or s3
|
# Attachment storage: local or s3
|
||||||
STORAGE_DRIVER=local
|
STORAGE_DRIVER=local
|
||||||
FILES_DIR=/data/files
|
FILES_DIR=/data/files
|
||||||
|
# Optional attachment origin. A bare domain is normalized to HTTPS.
|
||||||
|
# The administrator must proxy or serve /f/* on this domain.
|
||||||
|
# FILES_PUBLIC_URL=files.note.example.com
|
||||||
|
|
||||||
# S3-compatible storage (AWS S3, Garage, Ceph, OpenStack, MinIO, R2...)
|
# S3-compatible storage (AWS S3, Garage, Ceph, OpenStack, MinIO, R2...)
|
||||||
# For Docker Garage run: docker compose --profile s3 up -d
|
# For Docker Garage run: docker compose --profile s3 up -d
|
||||||
|
|||||||
+5
-1
@@ -14,4 +14,8 @@ venv
|
|||||||
.venv
|
.venv
|
||||||
migrate/etherpad-dry-run-report.json
|
migrate/etherpad-dry-run-report.json
|
||||||
data/garage
|
data/garage
|
||||||
scripts/*.txt
|
scripts/*.txt
|
||||||
|
|
||||||
|
# Generated by scripts/update_browser_libs.py
|
||||||
|
/static/libs/mermaid/
|
||||||
|
/static/libs/highlight/
|
||||||
|
|||||||
Generated
+1
-1
@@ -2581,7 +2581,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustpad"
|
name = "rustpad"
|
||||||
version = "0.2.4"
|
version = "0.2.48"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"argon2",
|
"argon2",
|
||||||
"aws-config",
|
"aws-config",
|
||||||
|
|||||||
+2
-2
@@ -1,10 +1,10 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rustpad"
|
name = "rustpad"
|
||||||
version = "0.2.4"
|
version = "0.2.48"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.94"
|
rust-version = "1.94"
|
||||||
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
|
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
|
||||||
license = "MIT"
|
license = "Source-Available Code / Dual-Licensed"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
argon2 = "0.5"
|
argon2 = "0.5"
|
||||||
|
|||||||
+11
@@ -1,3 +1,12 @@
|
|||||||
|
FROM python:3.14-slim AS browser-libs
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ARG BROWSER_LIBS_REFRESH=manual
|
||||||
|
COPY scripts/update_browser_libs.py ./scripts/update_browser_libs.py
|
||||||
|
COPY scripts/browser-libs.lock.json ./scripts/browser-libs.lock.json
|
||||||
|
RUN echo "Browser library download: ${BROWSER_LIBS_REFRESH}" \
|
||||||
|
&& python3 ./scripts/update_browser_libs.py --root /app --locked --strict
|
||||||
|
|
||||||
FROM rust:slim-trixie AS builder
|
FROM rust:slim-trixie AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -5,6 +14,8 @@ COPY Cargo.toml Cargo.lock ./
|
|||||||
COPY migrations ./migrations
|
COPY migrations ./migrations
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
COPY static ./static
|
COPY static ./static
|
||||||
|
COPY --from=browser-libs /app/static/libs/mermaid ./static/libs/mermaid
|
||||||
|
COPY --from=browser-libs /app/static/libs/highlight ./static/libs/highlight
|
||||||
|
|
||||||
RUN cargo build --release
|
RUN cargo build --release
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ RustPad is a collaborative Markdown editor with standalone notes and workspaces.
|
|||||||
./dev.sh
|
./dev.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
The script creates `data/db` and `data/files`, builds the project, and starts it with Cargo. If Cargo is unavailable, it runs `docker compose up --build` instead.
|
The script creates `data/db` and `data/files`, refreshes the generated browser libraries, builds the project, and starts it with Cargo. If Cargo is unavailable, it runs `docker compose up --build` instead; the Docker build downloads the libraries in a separate stage.
|
||||||
|
|
||||||
## Workspace features
|
## Workspace features
|
||||||
|
|
||||||
@@ -16,14 +16,16 @@ The script creates `data/db` and `data/files`, builds the project, and starts it
|
|||||||
- Nicknames stored in `localStorage`.
|
- Nicknames stored in `localStorage`.
|
||||||
- Change authors shown in history.
|
- Change authors shown in history.
|
||||||
- Line numbering enabled by default, with per-account preferences stored separately for each note or pad.
|
- Line numbering enabled by default, with per-account preferences stored separately for each note or pad.
|
||||||
|
- The formatting toolbar can be collapsed; the state is saved per account and per note or pad.
|
||||||
- Signed-in users with read/write access can save personal compact view, line, font, size, authorship, and color preferences; resource-linked rows are removed with the note, pad, or account.
|
- Signed-in users with read/write access can save personal compact view, line, font, size, authorship, and color preferences; resource-linked rows are removed with the note, pad, or account.
|
||||||
- Owner color displayed next to each line.
|
- Owner color displayed next to each line.
|
||||||
- Image and file uploads to `data/files/pads/<id>_<token>/` or `data/files/notes/<id>_<token>/`.
|
- Image and file uploads to `data/files/pads/<id>_<token>/` or `data/files/notes/<id>_<token>/`.
|
||||||
- Compact attachment aliases are inserted after upload: `[file=name.ext,label]` and `[image=name.ext,alt]`. The file dialog also provides standard Markdown for compatibility.
|
- Compact attachment aliases are inserted after upload: `[file=name.ext,label]`, `[image=name.ext,alt]`, and `[video=name.ext,label]`. Video uploads can be inserted as an embedded player or a forced-download link.
|
||||||
|
- Standalone YouTube links are rendered as responsive privacy-enhanced players.
|
||||||
- Markdown and Mermaid diagram rendering.
|
- Markdown and Mermaid diagram rendering.
|
||||||
- History with snippets, previews, and version restore.
|
- History with snippets, previews, and version restore.
|
||||||
- Alert blocks: `success`, `info`, `warning`, and `danger`.
|
- Alert blocks: `success`, `info`, `warning`, and `danger`.
|
||||||
- Table of contents generated with `[TOC]`.
|
- Table of contents generated with `[TOC]`, using headings after the marker and nesting them by level.
|
||||||
- Optional line numbers in fenced code blocks.
|
- Optional line numbers in fenced code blocks.
|
||||||
|
|
||||||
## Fenced code blocks and language aliases
|
## Fenced code blocks and language aliases
|
||||||
@@ -80,19 +82,26 @@ In Docker, both directories are located under `/data`.
|
|||||||
|
|
||||||
## Publishing a note as a page
|
## Publishing a note as a page
|
||||||
|
|
||||||
Use the **Page** button in the editor. RustPad creates a permanent public `/s/<token>` URL, copies it to the clipboard, and opens it in a new tab. The page displays the current note and renders Markdown, images, links, and Mermaid diagrams.
|
Use the **Page** button in the editor. RustPad creates a permanent public `/s/<token>` URL, copies it to the clipboard, and opens it in a new tab. The page displays the current note and renders Markdown, images, video players, YouTube embeds, links, and Mermaid diagrams. Its header can toggle source line numbers and expand the document to the full browser width.
|
||||||
|
|
||||||
Publishing a protected note requires its password, but the generated public page itself is accessible without that password.
|
Publishing a protected note requires its password, but the generated public page itself is accessible without that password.
|
||||||
|
|
||||||
## Upload limit
|
## Upload limit
|
||||||
|
|
||||||
Configure the maximum size of a single uploaded file with `UPLOAD_MAX_SIZE_MB` in `.env`, for example:
|
Configure the maximum size of a single uploaded file for signed-in users with `UPLOAD_MAX_SIZE_MB` in `.env`, for example:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
UPLOAD_MAX_SIZE_MB=50
|
UPLOAD_MAX_SIZE_MB=50
|
||||||
```
|
```
|
||||||
|
|
||||||
The default limit is 20 MB. Restart the project with `./dev.sh` after changing it.
|
The default limit is 20 MB. Uploads by guests are disabled by default. Enable them deliberately and set their separate per-file limit with:
|
||||||
|
|
||||||
|
```env
|
||||||
|
GUEST_UPLOAD_ENABLED=true
|
||||||
|
GUEST_UPLOAD_MAX_SIZE_MB=5
|
||||||
|
```
|
||||||
|
|
||||||
|
Guest uploads still require read-write access to the note or workspace. Restart the project with `./dev.sh` after changing these values.
|
||||||
|
|
||||||
## Database selection
|
## Database selection
|
||||||
|
|
||||||
@@ -124,6 +133,18 @@ Nicknames can be used anonymously while they remain unregistered. Registering a
|
|||||||
|
|
||||||
Configure `PUBLIC_URL`, `SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURITY`, `SMTP_USERNAME`, `SMTP_PASSWORD`, and `SMTP_FROM` to enable password-reset emails. `SMTP_FROM` accepts both `RustPad <no-reply@example.com>` and a value wrapped in one matching pair of single or double quotes, as may be passed literally by container env-file implementations. `SMTP_SECURITY` accepts `none` (plain SMTP, typically an internal relay on port 25), `starttls`, or `tls` (implicit TLS, commonly port 465). When omitted, it defaults to `tls` for port 465, `starttls` for port 587, and `none` for port 25 or any other port. SMTP authentication is enabled only when both `SMTP_USERNAME` and `SMTP_PASSWORD` are non-empty. Reset links expire after 30 minutes and can be used only once.
|
Configure `PUBLIC_URL`, `SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURITY`, `SMTP_USERNAME`, `SMTP_PASSWORD`, and `SMTP_FROM` to enable password-reset emails. `SMTP_FROM` accepts both `RustPad <no-reply@example.com>` and a value wrapped in one matching pair of single or double quotes, as may be passed literally by container env-file implementations. `SMTP_SECURITY` accepts `none` (plain SMTP, typically an internal relay on port 25), `starttls`, or `tls` (implicit TLS, commonly port 465). When omitted, it defaults to `tls` for port 465, `starttls` for port 587, and `none` for port 25 or any other port. SMTP authentication is enabled only when both `SMTP_USERNAME` and `SMTP_PASSWORD` are non-empty. Reset links expire after 30 minutes and can be used only once.
|
||||||
|
|
||||||
|
## Browser libraries
|
||||||
|
|
||||||
|
`static/libs/rustpad-player` is project-owned and stays in the repository. Mermaid and Highlight.js are generated locally and ignored by Git. Refresh or restore them with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/update_browser_libs.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The standard-library-only updater checks the current stable npm releases, verifies tarball integrity, and stores each license beside the generated files. `./dev.sh` runs it before local Cargo development. Docker performs the same download in the `browser-libs` stage; `dev.sh` sets `BROWSER_LIBS_REFRESH` so Docker does not reuse a stale dependency layer.
|
||||||
|
|
||||||
|
The editor serves all browser libraries through `/assets` and never loads Mermaid or Highlight.js directly from a public CDN.
|
||||||
|
|
||||||
## Diagnostics and logging
|
## Diagnostics and logging
|
||||||
|
|
||||||
Server logs use `tracing`. Configure verbosity with `RUST_LOG`, for example:
|
Server logs use `tracing`. Configure verbosity with `RUST_LOG`, for example:
|
||||||
@@ -149,7 +170,9 @@ RustPad supports two interchangeable attachment backends selected in `.env`:
|
|||||||
- `STORAGE_DRIVER=local` stores files under `FILES_DIR` and is the default.
|
- `STORAGE_DRIVER=local` stores files under `FILES_DIR` and is the default.
|
||||||
- `STORAGE_DRIVER=s3` uses an S3-compatible service such as AWS S3, Garage, Ceph RGW, OpenStack, or MinIO.
|
- `STORAGE_DRIVER=s3` uses an S3-compatible service such as AWS S3, Garage, Ceph RGW, OpenStack, or MinIO.
|
||||||
|
|
||||||
Public application URLs remain `/f/{token}/{filename}` for both backends. RustPad validates access and streams objects through the API, so the bucket does not need to be public and existing database records do not require migration.
|
Stored attachment paths remain `/f/{token}/{filename}` for both backends. RustPad validates access and streams objects through the API, so the bucket does not need to be public and existing database records do not require migration.
|
||||||
|
|
||||||
|
Set `FILES_PUBLIC_URL=files.note.example.com` to return attachment links through a separate domain. Bare domains are normalized to HTTPS; `http://` can be used explicitly for local deployments. The external domain must serve or proxy the same `/f/{token}/{filename}` paths. Removing the variable immediately restores application-relative `/f/...` links, including for records created while a custom domain was enabled.
|
||||||
|
|
||||||
Set `ASSET_CACHE_MAX_AGE_SECONDS=0` or `FILE_CACHE_MAX_AGE_SECONDS=0` to disable browser caching. RustPad then sends `Cache-Control: no-cache, no-store, must-revalidate`; positive values use `public, max-age=<seconds>`.
|
Set `ASSET_CACHE_MAX_AGE_SECONDS=0` or `FILE_CACHE_MAX_AGE_SECONDS=0` to disable browser caching. RustPad then sends `Cache-Control: no-cache, no-store, must-revalidate`; positive values use `public, max-age=<seconds>`.
|
||||||
|
|
||||||
@@ -276,3 +299,21 @@ SQL is selected explicitly by database engine. Application code uses logical que
|
|||||||
- `src/queries/mysql.rs`
|
- `src/queries/mysql.rs`
|
||||||
|
|
||||||
PostgreSQL statements use native `$1`, `$2`, ... placeholders. Query text is not rewritten at runtime, and result-shape casts are defined independently for each engine.
|
PostgreSQL statements use native `$1`, `$2`, ... placeholders. Query text is not rewritten at runtime, and result-shape casts are defined independently for each engine.
|
||||||
|
|
||||||
|
## Random API test data
|
||||||
|
|
||||||
|
`tests/random_data.py` creates data only through RustPad's HTTP API. It logs in, obtains a CSRF token, creates workspaces and notes, and can seed the initial Markdown content from generated text or cached snapshots of random Wikipedia pages with Wikimedia images.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export RUSTPAD_TEST_PASSWORD='test1234'
|
||||||
|
python3 tests/random_data.py \
|
||||||
|
--ip localhost \
|
||||||
|
--port 3000 \
|
||||||
|
--source wikipedia \
|
||||||
|
--notes 10000 \
|
||||||
|
--workspaces 10 \
|
||||||
|
--notes-in-workspaces 1000 \
|
||||||
|
--user test
|
||||||
|
```
|
||||||
|
|
||||||
|
`--notes-in-workspaces` is applied to every workspace. The example creates 10,000 standalone notes and another 10,000 notes inside 10 workspaces. Wikipedia mode never falls back to generated content. Use `--wikipedia-images`, `--wikipedia-attempts`, `--workers`, `--source-pool-size`, `--scheme https`, `--base-url`, or `--dry-run` as needed. The login value may be a local account e-mail or an LDAP/AD username.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -16,16 +16,41 @@ export RUST_LOG="${RUST_LOG:-rustpad=debug,tower_http=info}"
|
|||||||
# Generate a new asset version on each run to prevent stale HTML and JavaScript.
|
# Generate a new asset version on each run to prevent stale HTML and JavaScript.
|
||||||
export ASSET_VERSION="${ASSET_VERSION:-dev-$(date +%s)}"
|
export ASSET_VERSION="${ASSET_VERSION:-dev-$(date +%s)}"
|
||||||
|
|
||||||
if command -v cargo >/dev/null 2>&1; then
|
update_browser_libs() {
|
||||||
echo "Cleaning RustPad build artifacts..."
|
local python_bin=""
|
||||||
cargo clean --package rustpad
|
if command -v python3 >/dev/null 2>&1; then
|
||||||
|
python_bin="python3"
|
||||||
|
elif command -v python >/dev/null 2>&1; then
|
||||||
|
python_bin="python"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Starting RustPad..."
|
if [[ -z "$python_bin" ]]; then
|
||||||
exec cargo run --package rustpad
|
echo "Python 3 is required to download browser libraries for local Cargo development." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Checking browser libraries..."
|
||||||
|
"$python_bin" scripts/update_browser_libs.py
|
||||||
|
}
|
||||||
|
|
||||||
|
if command -v cargo >/dev/null 2>&1; then
|
||||||
|
if update_browser_libs; then
|
||||||
|
echo "Cleaning RustPad build artifacts..."
|
||||||
|
cargo clean --package rustpad
|
||||||
|
|
||||||
|
echo "Starting RustPad..."
|
||||||
|
exec cargo run --package rustpad
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v docker >/dev/null 2>&1; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Local browser libraries could not be prepared; falling back to Docker." >&2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if command -v docker >/dev/null 2>&1; then
|
if command -v docker >/dev/null 2>&1; then
|
||||||
export IMAGE_TAG="${IMAGE_TAG:-dev}"
|
export IMAGE_TAG="${IMAGE_TAG:-dev}"
|
||||||
|
export BROWSER_LIBS_REFRESH="${BROWSER_LIBS_REFRESH:-dev-$(date +%s)}"
|
||||||
|
|
||||||
echo "Starting RustPad with Docker..."
|
echo "Starting RustPad with Docker..."
|
||||||
exec docker compose up --build --force-recreate --remove-orphans
|
exec docker compose up --build --force-recreate --remove-orphans
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
BROWSER_LIBS_REFRESH: ${BROWSER_LIBS_REFRESH:-manual}
|
||||||
image: rustpad:${IMAGE_TAG:-local}
|
image: rustpad:${IMAGE_TAG:-local}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name files.note.example.com;
|
||||||
|
|
||||||
|
location ^~ /f/ {
|
||||||
|
proxy_pass http://rustpad:3000;
|
||||||
|
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
|
proxy_set_header Cookie "";
|
||||||
|
proxy_set_header Authorization "";
|
||||||
|
|
||||||
|
proxy_hide_header Set-Cookie;
|
||||||
|
|
||||||
|
proxy_intercept_errors on;
|
||||||
|
error_page 404 = @file_not_found;
|
||||||
|
}
|
||||||
|
|
||||||
|
location @file_not_found {
|
||||||
|
internal;
|
||||||
|
default_type text/plain;
|
||||||
|
return 404 "Not Found\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
default_type text/plain;
|
||||||
|
return 404 "Not Found\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
frontend http_front
|
||||||
|
bind *:443 ssl crt /etc/haproxy/certs/example.pem
|
||||||
|
mode http
|
||||||
|
|
||||||
|
acl rustpad_path path_beg /f/
|
||||||
|
|
||||||
|
use_backend rustpad_backend if rustpad_path
|
||||||
|
|
||||||
|
http-request return status 404 \
|
||||||
|
content-type "text/plain" \
|
||||||
|
string "Not Found" unless rustpad_path
|
||||||
|
|
||||||
|
|
||||||
|
backend rustpad_backend
|
||||||
|
mode http
|
||||||
|
|
||||||
|
http-request set-header X-Forwarded-Proto https
|
||||||
|
http-request del-header Cookie
|
||||||
|
http-request del-header Authorization
|
||||||
|
|
||||||
|
http-response del-header Set-Cookie
|
||||||
|
http-response return status 404 \
|
||||||
|
content-type "text/plain" \
|
||||||
|
string "Not Found" if { status 404 }
|
||||||
|
|
||||||
|
server rustpad rustpad:3000 check
|
||||||
@@ -12,3 +12,8 @@ root_domain = ".s3.garage.localhost"
|
|||||||
|
|
||||||
[admin]
|
[admin]
|
||||||
api_bind_addr = "0.0.0.0:3903"
|
api_bind_addr = "0.0.0.0:3903"
|
||||||
|
|
||||||
|
#[s3_web]
|
||||||
|
#bind_addr = "0.0.0.0:3902"
|
||||||
|
#root_domain = ".web.garage.localhost"
|
||||||
|
#index = "index.html"
|
||||||
@@ -1 +1,2 @@
|
|||||||
ALTER TABLE resource_share_links ADD COLUMN token TEXT NULL;
|
-- Legacy migration retained for numbering only. Plaintext share tokens are not stored.
|
||||||
|
SELECT 1;
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE notes ADD COLUMN created_by_guest_id VARCHAR(64) NULL;
|
||||||
|
ALTER TABLE pads ADD COLUMN created_by_guest_id VARCHAR(64) NULL;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN theme VARCHAR(5) NOT NULL DEFAULT 'dark',
|
||||||
|
ADD CONSTRAINT chk_users_theme CHECK (theme IN ('dark', 'light'));
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
CREATE TABLE resource_share_sessions (
|
||||||
|
session_token_hash VARCHAR(64) PRIMARY KEY,
|
||||||
|
share_token_hash VARCHAR(64) NOT NULL,
|
||||||
|
resource_kind VARCHAR(16) NOT NULL,
|
||||||
|
resource_slug VARCHAR(255) NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||||
|
CONSTRAINT fk_resource_share_sessions_link FOREIGN KEY(share_token_hash) REFERENCES resource_share_links(token_hash) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_resource_share_sessions_link ON resource_share_sessions(share_token_hash);
|
||||||
|
CREATE INDEX idx_resource_share_sessions_expiry ON resource_share_sessions(expires_at(32));
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE resource_share_links ADD COLUMN label VARCHAR(120) NULL;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
ALTER TABLE note_revisions
|
||||||
|
ADD COLUMN collaboration_client_id VARCHAR(64) NULL,
|
||||||
|
ADD COLUMN collaboration_update_id BIGINT NULL,
|
||||||
|
ADD UNIQUE INDEX idx_note_revisions_collaboration_update
|
||||||
|
(note_id, collaboration_client_id, collaboration_update_id);
|
||||||
|
|
||||||
|
ALTER TABLE revisions
|
||||||
|
ADD COLUMN collaboration_client_id VARCHAR(64) NULL,
|
||||||
|
ADD COLUMN collaboration_update_id BIGINT NULL,
|
||||||
|
ADD UNIQUE INDEX idx_revisions_collaboration_update
|
||||||
|
(pad_id, collaboration_client_id, collaboration_update_id);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE workspaces ADD COLUMN created_by_guest_id VARCHAR(64) NULL;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- Existing MySQL databases may have inherited utf8mb3 from the database default.
|
||||||
|
-- Convert all persisted document text to utf8mb4 so emoji and other 4-byte
|
||||||
|
-- Unicode characters can be stored in both current documents and revisions.
|
||||||
|
ALTER TABLE pads CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
ALTER TABLE revisions CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
ALTER TABLE workspaces CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
ALTER TABLE notes CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
ALTER TABLE note_revisions CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE user_editor_preferences ADD COLUMN toolbar_collapsed BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
@@ -1 +1,2 @@
|
|||||||
ALTER TABLE resource_share_links ADD COLUMN token TEXT;
|
-- Legacy migration retained for numbering only. Plaintext share tokens are not stored.
|
||||||
|
SELECT 1;
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE notes ADD COLUMN created_by_guest_id TEXT;
|
||||||
|
ALTER TABLE pads ADD COLUMN created_by_guest_id TEXT;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN theme TEXT NOT NULL DEFAULT 'dark'
|
||||||
|
CHECK (theme IN ('dark', 'light'));
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
CREATE TABLE resource_share_sessions (
|
||||||
|
session_token_hash TEXT PRIMARY KEY,
|
||||||
|
share_token_hash TEXT NOT NULL REFERENCES resource_share_links(token_hash) ON DELETE CASCADE,
|
||||||
|
resource_kind TEXT NOT NULL,
|
||||||
|
resource_slug TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_resource_share_sessions_link ON resource_share_sessions(share_token_hash);
|
||||||
|
CREATE INDEX idx_resource_share_sessions_expiry ON resource_share_sessions(expires_at);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE resource_share_links ADD COLUMN label TEXT;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
ALTER TABLE note_revisions ADD COLUMN collaboration_client_id TEXT;
|
||||||
|
ALTER TABLE note_revisions ADD COLUMN collaboration_update_id BIGINT;
|
||||||
|
CREATE UNIQUE INDEX idx_note_revisions_collaboration_update
|
||||||
|
ON note_revisions(note_id, collaboration_client_id, collaboration_update_id)
|
||||||
|
WHERE collaboration_client_id IS NOT NULL AND collaboration_update_id IS NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE revisions ADD COLUMN collaboration_client_id TEXT;
|
||||||
|
ALTER TABLE revisions ADD COLUMN collaboration_update_id BIGINT;
|
||||||
|
CREATE UNIQUE INDEX idx_revisions_collaboration_update
|
||||||
|
ON revisions(pad_id, collaboration_client_id, collaboration_update_id)
|
||||||
|
WHERE collaboration_client_id IS NOT NULL AND collaboration_update_id IS NOT NULL;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE workspaces ADD COLUMN created_by_guest_id TEXT;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- PostgreSQL text values are already stored as UTF-8.
|
||||||
|
SELECT 1;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE user_editor_preferences ADD COLUMN toolbar_collapsed BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
@@ -1 +1,2 @@
|
|||||||
ALTER TABLE resource_share_links ADD COLUMN token TEXT;
|
-- Legacy migration retained for numbering only. Plaintext share tokens are not stored.
|
||||||
|
SELECT 1;
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE notes ADD COLUMN created_by_guest_id TEXT;
|
||||||
|
ALTER TABLE pads ADD COLUMN created_by_guest_id TEXT;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN theme TEXT NOT NULL DEFAULT 'dark'
|
||||||
|
CHECK (theme IN ('dark', 'light'));
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
CREATE TABLE resource_share_sessions (
|
||||||
|
session_token_hash TEXT PRIMARY KEY,
|
||||||
|
share_token_hash TEXT NOT NULL REFERENCES resource_share_links(token_hash) ON DELETE CASCADE,
|
||||||
|
resource_kind TEXT NOT NULL,
|
||||||
|
resource_slug TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_resource_share_sessions_link ON resource_share_sessions(share_token_hash);
|
||||||
|
CREATE INDEX idx_resource_share_sessions_expiry ON resource_share_sessions(expires_at);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE resource_share_links ADD COLUMN label TEXT;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
ALTER TABLE note_revisions ADD COLUMN collaboration_client_id TEXT;
|
||||||
|
ALTER TABLE note_revisions ADD COLUMN collaboration_update_id INTEGER;
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_note_revisions_collaboration_update
|
||||||
|
ON note_revisions(note_id, collaboration_client_id, collaboration_update_id)
|
||||||
|
WHERE collaboration_client_id IS NOT NULL AND collaboration_update_id IS NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE revisions ADD COLUMN collaboration_client_id TEXT;
|
||||||
|
ALTER TABLE revisions ADD COLUMN collaboration_update_id INTEGER;
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_revisions_collaboration_update
|
||||||
|
ON revisions(pad_id, collaboration_client_id, collaboration_update_id)
|
||||||
|
WHERE collaboration_client_id IS NOT NULL AND collaboration_update_id IS NOT NULL;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE workspaces ADD COLUMN created_by_guest_id TEXT;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- SQLite TEXT values already support full Unicode.
|
||||||
|
SELECT 1;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE user_editor_preferences ADD COLUMN toolbar_collapsed INTEGER NOT NULL DEFAULT 0;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"libraries": {
|
||||||
|
"highlight": {
|
||||||
|
"integrity": "sha512-VEPdHzwelZ12hEX18BHduqxMZGolcUsrbeokHYxOUIm8X2+M7nx5QPtPeQgRxR9XjhdLv4/7DD5BWOlSrJ3k7Q==",
|
||||||
|
"package": "@highlightjs/cdn-assets",
|
||||||
|
"repository": "git://github.com/highlightjs/highlight.js.git",
|
||||||
|
"shasum": "136984ae467865e22080b3a4b65398a086e1ae7b",
|
||||||
|
"tarball": "https://registry.npmjs.org/@highlightjs/cdn-assets/-/cdn-assets-11.11.1.tgz",
|
||||||
|
"version": "11.11.1"
|
||||||
|
},
|
||||||
|
"mermaid": {
|
||||||
|
"integrity": "sha512-TQsq6u22fAn3rek5VOubrhKPo1g5hwC3FXUN9hiyupTckcYiGuuKGkNQrKYwGJkXUxZdojwRG46gsSCFZMDp4g==",
|
||||||
|
"package": "mermaid",
|
||||||
|
"repository": "git+https://github.com/mermaid-js/mermaid.git",
|
||||||
|
"shasum": "57ae2342f6c45b967113b04c9258430bdd057ee8",
|
||||||
|
"tarball": "https://registry.npmjs.org/mermaid/-/mermaid-11.16.1.tgz",
|
||||||
|
"version": "11.16.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"schema": 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||||
|
PROJECT_DIR=$(dirname -- "$SCRIPT_DIR")
|
||||||
|
cd "$PROJECT_DIR"
|
||||||
|
|
||||||
|
fatal() {
|
||||||
|
printf 'enterdb: %s\n' "$*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
command -v docker >/dev/null 2>&1 || fatal "docker is not available in PATH"
|
||||||
|
docker compose version >/dev/null 2>&1 || fatal "docker compose is not available"
|
||||||
|
|
||||||
|
service_running() {
|
||||||
|
docker compose ps --status running --services 2>/dev/null | grep -Fxq "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# DATABASE_URL from the running application is the source of truth for the selected database engine.
|
||||||
|
DATABASE_URL_VALUE=""
|
||||||
|
if service_running app; then
|
||||||
|
DATABASE_URL_VALUE=$(docker compose exec -T app sh -c 'printf "%s" "${DATABASE_URL:-}"' 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$DATABASE_URL_VALUE" in
|
||||||
|
postgres://*|postgresql://*)
|
||||||
|
DATABASE_ENGINE=postgres
|
||||||
|
;;
|
||||||
|
mysql://*)
|
||||||
|
DATABASE_ENGINE=mysql
|
||||||
|
;;
|
||||||
|
sqlite://*)
|
||||||
|
fatal "the application uses SQLite; this script supports PostgreSQL and MySQL"
|
||||||
|
;;
|
||||||
|
"")
|
||||||
|
POSTGRES_RUNNING=false
|
||||||
|
MYSQL_RUNNING=false
|
||||||
|
service_running postgres && POSTGRES_RUNNING=true
|
||||||
|
service_running mysql && MYSQL_RUNNING=true
|
||||||
|
|
||||||
|
if [ "$POSTGRES_RUNNING" = true ] && [ "$MYSQL_RUNNING" = false ]; then
|
||||||
|
DATABASE_ENGINE=postgres
|
||||||
|
elif [ "$MYSQL_RUNNING" = true ] && [ "$POSTGRES_RUNNING" = false ]; then
|
||||||
|
DATABASE_ENGINE=mysql
|
||||||
|
elif [ "$POSTGRES_RUNNING" = true ] && [ "$MYSQL_RUNNING" = true ]; then
|
||||||
|
fatal "PostgreSQL and MySQL are both running, and DATABASE_URL could not be read from the app service"
|
||||||
|
else
|
||||||
|
fatal "no running postgres or mysql service was found"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
fatal "unsupported DATABASE_URL: $DATABASE_URL_VALUE"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
case "$DATABASE_ENGINE" in
|
||||||
|
postgres)
|
||||||
|
service_running postgres || fatal "the postgres service is not running"
|
||||||
|
printf 'Connecting to PostgreSQL...\n' >&2
|
||||||
|
exec docker compose exec postgres sh -lc '
|
||||||
|
export PGPASSWORD="${POSTGRES_PASSWORD:-rustpad}"
|
||||||
|
exec psql --host=127.0.0.1 --username="${POSTGRES_USER:-rustpad}" --dbname="${POSTGRES_DB:-rustpad}"
|
||||||
|
'
|
||||||
|
;;
|
||||||
|
mysql)
|
||||||
|
service_running mysql || fatal "the mysql service is not running"
|
||||||
|
printf 'Connecting to MySQL...\n' >&2
|
||||||
|
exec docker compose exec mysql sh -lc '
|
||||||
|
if command -v mysql >/dev/null 2>&1; then
|
||||||
|
client=mysql
|
||||||
|
elif command -v mariadb >/dev/null 2>&1; then
|
||||||
|
client=mariadb
|
||||||
|
else
|
||||||
|
echo "enterdb: no mysql/mariadb client is available in the container" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
export MYSQL_PWD="${MYSQL_PASSWORD:-rustpad}"
|
||||||
|
exec "$client" --user="${MYSQL_USER:-rustpad}" "${MYSQL_DATABASE:-rustpad}"
|
||||||
|
'
|
||||||
|
;;
|
||||||
|
esac
|
||||||
Executable
+651
@@ -0,0 +1,651 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Download and refresh RustPad's third-party browser libraries.
|
||||||
|
|
||||||
|
The script uses only the Python standard library. By default it resolves the
|
||||||
|
current stable package versions from npm. Reproducible builds can use a
|
||||||
|
committed lock file generated with ``--update-lock`` and consumed with
|
||||||
|
``--locked``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tarfile
|
||||||
|
import tempfile
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path, PurePosixPath
|
||||||
|
from typing import Callable, Iterable
|
||||||
|
|
||||||
|
USER_AGENT = "RustPad browser-library updater/0.1"
|
||||||
|
DEFAULT_TIMEOUT = 45
|
||||||
|
LOCK_SCHEMA = 1
|
||||||
|
DEFAULT_LOCK_FILE = Path("scripts/browser-libs.lock.json")
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Library:
|
||||||
|
key: str
|
||||||
|
package: str
|
||||||
|
destination: str
|
||||||
|
entrypoint: str
|
||||||
|
repository_fragment: str
|
||||||
|
installer: Callable[[tarfile.TarFile, Path], None]
|
||||||
|
|
||||||
|
|
||||||
|
def registry_url(package: str, version: str = "latest") -> str:
|
||||||
|
encoded_package = urllib.parse.quote(package, safe="")
|
||||||
|
encoded_version = urllib.parse.quote(version, safe="")
|
||||||
|
return f"https://registry.npmjs.org/{encoded_package}/{encoded_version}"
|
||||||
|
|
||||||
|
|
||||||
|
def request_bytes(url: str, timeout: int) -> bytes:
|
||||||
|
request = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
headers={
|
||||||
|
"Accept": "application/json, application/octet-stream;q=0.9, */*;q=0.8",
|
||||||
|
"User-Agent": USER_AGENT,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||||
|
return response.read()
|
||||||
|
except (urllib.error.URLError, TimeoutError, OSError) as error:
|
||||||
|
raise UpdateError(f"Cannot download {url}: {error}") from error
|
||||||
|
|
||||||
|
|
||||||
|
def package_metadata(package: str, timeout: int, version: str = "latest") -> dict:
|
||||||
|
raw = request_bytes(registry_url(package, version), timeout)
|
||||||
|
try:
|
||||||
|
metadata = json.loads(raw.decode("utf-8"))
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
||||||
|
raise UpdateError(f"Invalid npm metadata for {package}@{version}") from error
|
||||||
|
if not isinstance(metadata, dict):
|
||||||
|
raise UpdateError(f"Unexpected npm metadata for {package}@{version}")
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
|
||||||
|
def repository_url(metadata: dict) -> str:
|
||||||
|
repository = metadata.get("repository", "")
|
||||||
|
if isinstance(repository, dict):
|
||||||
|
repository = repository.get("url", "")
|
||||||
|
return str(repository or "")
|
||||||
|
|
||||||
|
|
||||||
|
def package_dist(metadata: dict) -> dict:
|
||||||
|
dist = metadata.get("dist")
|
||||||
|
if not isinstance(dist, dict):
|
||||||
|
return {}
|
||||||
|
return dist
|
||||||
|
|
||||||
|
|
||||||
|
def validate_tarball_url(url: str, package: str) -> None:
|
||||||
|
parsed = urllib.parse.urlparse(url)
|
||||||
|
if parsed.scheme != "https" or not parsed.netloc:
|
||||||
|
raise UpdateError(f"Invalid HTTPS tarball URL for {package}: {url or 'not provided'}")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_metadata(
|
||||||
|
library: Library,
|
||||||
|
metadata: dict,
|
||||||
|
*,
|
||||||
|
require_integrity: bool = True,
|
||||||
|
) -> tuple[str, dict, str]:
|
||||||
|
version = str(metadata.get("version") or "").strip()
|
||||||
|
dist = package_dist(metadata)
|
||||||
|
tarball = str(dist.get("tarball") or "").strip()
|
||||||
|
integrity = str(dist.get("integrity") or "").strip()
|
||||||
|
shasum = str(dist.get("shasum") or "").strip()
|
||||||
|
repository = repository_url(metadata).strip()
|
||||||
|
|
||||||
|
if not version or not tarball:
|
||||||
|
raise UpdateError(
|
||||||
|
f"npm metadata for {library.package} is missing version or tarball data"
|
||||||
|
)
|
||||||
|
validate_tarball_url(tarball, library.package)
|
||||||
|
if require_integrity and not integrity and not shasum:
|
||||||
|
raise UpdateError(f"npm metadata for {library.package}@{version} has no integrity hash")
|
||||||
|
if library.repository_fragment.lower() not in repository.lower():
|
||||||
|
raise UpdateError(
|
||||||
|
f"Unexpected repository for {library.package}: {repository or 'not provided'}"
|
||||||
|
)
|
||||||
|
return version, dist, repository
|
||||||
|
|
||||||
|
|
||||||
|
def verify_tarball(data: bytes, dist: dict) -> None:
|
||||||
|
integrity = str(dist.get("integrity") or "").strip()
|
||||||
|
algorithms = {
|
||||||
|
"sha512": hashlib.sha512,
|
||||||
|
"sha384": hashlib.sha384,
|
||||||
|
"sha256": hashlib.sha256,
|
||||||
|
}
|
||||||
|
|
||||||
|
if integrity:
|
||||||
|
recognized = False
|
||||||
|
for token in integrity.split():
|
||||||
|
algorithm, separator, encoded = token.partition("-")
|
||||||
|
if not separator or algorithm not in algorithms:
|
||||||
|
continue
|
||||||
|
recognized = True
|
||||||
|
try:
|
||||||
|
expected = base64.b64decode(encoded, validate=True)
|
||||||
|
except (binascii.Error, ValueError) as error:
|
||||||
|
raise UpdateError(f"Invalid {algorithm} integrity value") from error
|
||||||
|
actual = algorithms[algorithm](data).digest()
|
||||||
|
if actual != expected:
|
||||||
|
raise UpdateError(f"Tarball integrity verification failed ({algorithm})")
|
||||||
|
return
|
||||||
|
if not recognized:
|
||||||
|
raise UpdateError("Tarball integrity uses an unsupported hash algorithm")
|
||||||
|
|
||||||
|
shasum = str(dist.get("shasum") or "").strip()
|
||||||
|
if shasum:
|
||||||
|
if hashlib.sha1(data).hexdigest().lower() != shasum.lower():
|
||||||
|
raise UpdateError("Tarball SHA-1 verification failed")
|
||||||
|
return
|
||||||
|
|
||||||
|
raise UpdateError("Tarball metadata does not contain a supported integrity hash")
|
||||||
|
|
||||||
|
|
||||||
|
def safe_relative(member_name: str, prefix: tuple[str, ...]) -> PurePosixPath | None:
|
||||||
|
path = PurePosixPath(member_name)
|
||||||
|
parts = path.parts
|
||||||
|
if len(parts) <= len(prefix) or tuple(parts[: len(prefix)]) != prefix:
|
||||||
|
return None
|
||||||
|
relative = PurePosixPath(*parts[len(prefix) :])
|
||||||
|
if relative.is_absolute() or any(part in {"", ".", ".."} for part in relative.parts):
|
||||||
|
raise UpdateError(f"Unsafe path in package archive: {member_name}")
|
||||||
|
return relative
|
||||||
|
|
||||||
|
|
||||||
|
def write_member(archive: tarfile.TarFile, member: tarfile.TarInfo, destination: Path) -> None:
|
||||||
|
if not member.isfile():
|
||||||
|
return
|
||||||
|
source = archive.extractfile(member)
|
||||||
|
if source is None:
|
||||||
|
raise UpdateError(f"Cannot read {member.name} from package archive")
|
||||||
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with source, destination.open("wb") as output:
|
||||||
|
shutil.copyfileobj(source, output)
|
||||||
|
|
||||||
|
|
||||||
|
def copy_license(archive: tarfile.TarFile, destination: Path) -> None:
|
||||||
|
names = {
|
||||||
|
"package/LICENSE",
|
||||||
|
"package/LICENSE.txt",
|
||||||
|
"package/LICENSE.md",
|
||||||
|
"package/LICENCE",
|
||||||
|
"package/LICENCE.txt",
|
||||||
|
"package/LICENCE.md",
|
||||||
|
}
|
||||||
|
member = next((item for item in archive.getmembers() if item.isfile() and item.name in names), None)
|
||||||
|
if member is None:
|
||||||
|
raise UpdateError("The package archive does not contain a license file")
|
||||||
|
write_member(archive, member, destination / "LICENSE.txt")
|
||||||
|
|
||||||
|
|
||||||
|
def install_mermaid(archive: tarfile.TarFile, destination: Path) -> None:
|
||||||
|
copied = 0
|
||||||
|
for member in archive.getmembers():
|
||||||
|
relative = safe_relative(member.name, ("package", "dist"))
|
||||||
|
if relative is None or not member.isfile():
|
||||||
|
continue
|
||||||
|
suffix = relative.suffix.lower()
|
||||||
|
is_entrypoint = relative == PurePosixPath("mermaid.esm.min.mjs")
|
||||||
|
is_minified_chunk = relative.parts[:2] == ("chunks", "mermaid.esm.min")
|
||||||
|
if not (is_entrypoint or is_minified_chunk):
|
||||||
|
continue
|
||||||
|
if suffix not in {".mjs", ".wasm", ".css"}:
|
||||||
|
continue
|
||||||
|
write_member(archive, member, destination / Path(*relative.parts))
|
||||||
|
copied += 1
|
||||||
|
if copied == 0 or not (destination / "mermaid.esm.min.mjs").is_file():
|
||||||
|
raise UpdateError("Mermaid browser entrypoint was not found in the npm package")
|
||||||
|
copy_license(archive, destination)
|
||||||
|
|
||||||
|
|
||||||
|
def install_highlight(archive: tarfile.TarFile, destination: Path) -> None:
|
||||||
|
candidates = {
|
||||||
|
"package/highlight.min.js",
|
||||||
|
"package/build/highlight.min.js",
|
||||||
|
}
|
||||||
|
member = next((item for item in archive.getmembers() if item.isfile() and item.name in candidates), None)
|
||||||
|
if member is None:
|
||||||
|
member = next(
|
||||||
|
(
|
||||||
|
item
|
||||||
|
for item in archive.getmembers()
|
||||||
|
if item.isfile() and PurePosixPath(item.name).name == "highlight.min.js"
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if member is None:
|
||||||
|
raise UpdateError("Highlight.js browser build was not found in the npm package")
|
||||||
|
write_member(archive, member, destination / "highlight.min.js")
|
||||||
|
copy_license(archive, destination)
|
||||||
|
|
||||||
|
|
||||||
|
LIBRARIES = (
|
||||||
|
Library(
|
||||||
|
key="mermaid",
|
||||||
|
package="mermaid",
|
||||||
|
destination="mermaid",
|
||||||
|
entrypoint="mermaid.esm.min.mjs",
|
||||||
|
repository_fragment="mermaid-js/mermaid",
|
||||||
|
installer=install_mermaid,
|
||||||
|
),
|
||||||
|
Library(
|
||||||
|
key="highlight",
|
||||||
|
package="@highlightjs/cdn-assets",
|
||||||
|
destination="highlight",
|
||||||
|
entrypoint="highlight.min.js",
|
||||||
|
repository_fragment="highlightjs/highlight.js",
|
||||||
|
installer=install_highlight,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
LIBRARIES_BY_KEY = {library.key: library for library in LIBRARIES}
|
||||||
|
|
||||||
|
|
||||||
|
def installed_version(destination: Path) -> str:
|
||||||
|
version_file = destination / "VERSION"
|
||||||
|
try:
|
||||||
|
return version_file.read_text(encoding="utf-8").strip()
|
||||||
|
except OSError:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def is_complete(library: Library, destination: Path) -> bool:
|
||||||
|
return (destination / library.entrypoint).is_file() and (destination / "LICENSE.txt").is_file()
|
||||||
|
|
||||||
|
|
||||||
|
def source_note(library: Library, metadata: dict, version: str) -> str:
|
||||||
|
dist = package_dist(metadata)
|
||||||
|
repository = repository_url(metadata)
|
||||||
|
return (
|
||||||
|
f"Package: {library.package}\n"
|
||||||
|
f"Version: {version}\n"
|
||||||
|
f"Registry: {registry_url(library.package, version)}\n"
|
||||||
|
f"Tarball: {dist.get('tarball', '')}\n"
|
||||||
|
f"Integrity: {dist.get('integrity') or dist.get('shasum') or ''}\n"
|
||||||
|
f"Repository: {repository}\n"
|
||||||
|
"Generated by scripts/update_browser_libs.py; do not edit or commit this directory.\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def install_library(
|
||||||
|
library: Library,
|
||||||
|
metadata: dict,
|
||||||
|
destination: Path,
|
||||||
|
timeout: int,
|
||||||
|
) -> None:
|
||||||
|
version, dist, _ = validate_metadata(library, metadata)
|
||||||
|
tarball = request_bytes(str(dist["tarball"]), timeout)
|
||||||
|
verify_tarball(tarball, dist)
|
||||||
|
|
||||||
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with tempfile.TemporaryDirectory(prefix=f".{library.key}-", dir=destination.parent) as temporary:
|
||||||
|
staging = Path(temporary) / library.destination
|
||||||
|
staging.mkdir(parents=True)
|
||||||
|
archive_path = Path(temporary) / "package.tgz"
|
||||||
|
archive_path.write_bytes(tarball)
|
||||||
|
try:
|
||||||
|
with tarfile.open(archive_path, mode="r:gz") as archive:
|
||||||
|
library.installer(archive, staging)
|
||||||
|
except (tarfile.TarError, OSError) as error:
|
||||||
|
raise UpdateError(f"Cannot unpack {library.package}: {error}") from error
|
||||||
|
|
||||||
|
(staging / "VERSION").write_text(f"{version}\n", encoding="utf-8")
|
||||||
|
(staging / "SOURCE.txt").write_text(
|
||||||
|
source_note(library, metadata, version), encoding="utf-8"
|
||||||
|
)
|
||||||
|
if not is_complete(library, staging):
|
||||||
|
raise UpdateError(f"Generated {library.key} directory is incomplete")
|
||||||
|
|
||||||
|
old_destination = destination.with_name(f".{destination.name}.old")
|
||||||
|
if old_destination.exists():
|
||||||
|
shutil.rmtree(old_destination)
|
||||||
|
if destination.exists():
|
||||||
|
destination.replace(old_destination)
|
||||||
|
try:
|
||||||
|
shutil.move(str(staging), str(destination))
|
||||||
|
except Exception:
|
||||||
|
if old_destination.exists() and not destination.exists():
|
||||||
|
old_destination.replace(destination)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
if old_destination.exists():
|
||||||
|
shutil.rmtree(old_destination)
|
||||||
|
|
||||||
|
|
||||||
|
def selected_libraries(keys: Iterable[str]) -> list[Library]:
|
||||||
|
requested = set(keys)
|
||||||
|
if not requested:
|
||||||
|
return list(LIBRARIES)
|
||||||
|
return [library for library in LIBRARIES if library.key in requested]
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_path(root: Path, value: Path) -> Path:
|
||||||
|
return value.resolve() if value.is_absolute() else (root / value).resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def load_lock_file(path: Path, *, required: bool) -> dict:
|
||||||
|
if not path.is_file():
|
||||||
|
if required:
|
||||||
|
raise UpdateError(
|
||||||
|
f"Lock file does not exist: {path}. Generate it with --update-lock first."
|
||||||
|
)
|
||||||
|
return {"schema": LOCK_SCHEMA, "libraries": {}}
|
||||||
|
try:
|
||||||
|
document = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error:
|
||||||
|
raise UpdateError(f"Cannot read lock file {path}: {error}") from error
|
||||||
|
if not isinstance(document, dict) or document.get("schema") != LOCK_SCHEMA:
|
||||||
|
raise UpdateError(f"Unsupported or missing lock-file schema in {path}")
|
||||||
|
entries = document.get("libraries")
|
||||||
|
if not isinstance(entries, dict):
|
||||||
|
raise UpdateError(f"Lock file {path} has no libraries object")
|
||||||
|
return document
|
||||||
|
|
||||||
|
|
||||||
|
def metadata_from_lock(library: Library, document: dict) -> dict:
|
||||||
|
entries = document["libraries"]
|
||||||
|
entry = entries.get(library.key)
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
raise UpdateError(f"Lock file has no entry for {library.key}")
|
||||||
|
|
||||||
|
package = str(entry.get("package") or "").strip()
|
||||||
|
version = str(entry.get("version") or "").strip()
|
||||||
|
tarball = str(entry.get("tarball") or "").strip()
|
||||||
|
integrity = str(entry.get("integrity") or "").strip()
|
||||||
|
shasum = str(entry.get("shasum") or "").strip()
|
||||||
|
repository = str(entry.get("repository") or "").strip()
|
||||||
|
|
||||||
|
if package != library.package:
|
||||||
|
raise UpdateError(
|
||||||
|
f"Lock entry {library.key} points to {package or 'no package'}, expected {library.package}"
|
||||||
|
)
|
||||||
|
metadata = {
|
||||||
|
"name": package,
|
||||||
|
"version": version,
|
||||||
|
"repository": repository,
|
||||||
|
"dist": {
|
||||||
|
"tarball": tarball,
|
||||||
|
"integrity": integrity,
|
||||||
|
"shasum": shasum,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
validate_metadata(library, metadata)
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
|
||||||
|
def lock_entry_from_metadata(library: Library, metadata: dict) -> dict:
|
||||||
|
version, dist, repository = validate_metadata(library, metadata)
|
||||||
|
return {
|
||||||
|
"package": library.package,
|
||||||
|
"version": version,
|
||||||
|
"tarball": str(dist.get("tarball") or ""),
|
||||||
|
"integrity": str(dist.get("integrity") or ""),
|
||||||
|
"shasum": str(dist.get("shasum") or ""),
|
||||||
|
"repository": repository,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def write_lock_file(path: Path, document: dict) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
payload = json.dumps(document, indent=2, sort_keys=True, ensure_ascii=True) + "\n"
|
||||||
|
descriptor, temporary_name = tempfile.mkstemp(
|
||||||
|
prefix=f".{path.name}.", suffix=".tmp", dir=path.parent
|
||||||
|
)
|
||||||
|
temporary_path = Path(temporary_name)
|
||||||
|
try:
|
||||||
|
with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as output:
|
||||||
|
output.write(payload)
|
||||||
|
output.flush()
|
||||||
|
os.fsync(output.fileno())
|
||||||
|
os.replace(temporary_path, path)
|
||||||
|
except Exception:
|
||||||
|
temporary_path.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def parse_version_overrides(values: Iterable[str]) -> dict[str, str]:
|
||||||
|
overrides: dict[str, str] = {}
|
||||||
|
for value in values:
|
||||||
|
key, separator, version = value.partition("=")
|
||||||
|
key = key.strip()
|
||||||
|
version = version.strip()
|
||||||
|
if not separator or key not in LIBRARIES_BY_KEY or not version:
|
||||||
|
choices = ", ".join(sorted(LIBRARIES_BY_KEY))
|
||||||
|
raise UpdateError(
|
||||||
|
f"Invalid --version value {value!r}; expected LIBRARY=VERSION ({choices})"
|
||||||
|
)
|
||||||
|
if key in overrides:
|
||||||
|
raise UpdateError(f"Duplicate --version value for {key}")
|
||||||
|
overrides[key] = version
|
||||||
|
return overrides
|
||||||
|
|
||||||
|
|
||||||
|
def update_one(
|
||||||
|
library: Library,
|
||||||
|
metadata: dict,
|
||||||
|
libs_root: Path,
|
||||||
|
*,
|
||||||
|
timeout: int,
|
||||||
|
force: bool,
|
||||||
|
check: bool,
|
||||||
|
) -> bool:
|
||||||
|
target, _, _ = validate_metadata(library, metadata)
|
||||||
|
destination = libs_root / library.destination
|
||||||
|
current = installed_version(destination)
|
||||||
|
complete = is_complete(library, destination)
|
||||||
|
|
||||||
|
if complete and current == target and not force:
|
||||||
|
print(f"{library.key}: up to date ({target})")
|
||||||
|
return True
|
||||||
|
|
||||||
|
state = "missing" if not complete else f"{current or 'unknown'} -> {target}"
|
||||||
|
if check:
|
||||||
|
print(f"{library.key}: update required ({state})")
|
||||||
|
return False
|
||||||
|
|
||||||
|
print(f"{library.key}: downloading {target} ({state})")
|
||||||
|
install_library(library, metadata, destination, timeout)
|
||||||
|
print(f"{library.key}: installed {target}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def check_updates(
|
||||||
|
libraries: list[Library],
|
||||||
|
libs_root: Path,
|
||||||
|
lock_path: Path,
|
||||||
|
timeout: int,
|
||||||
|
) -> bool:
|
||||||
|
document = load_lock_file(lock_path, required=False)
|
||||||
|
entries = document["libraries"]
|
||||||
|
success = True
|
||||||
|
|
||||||
|
for library in libraries:
|
||||||
|
baseline = ""
|
||||||
|
source = "installed copy"
|
||||||
|
if library.key in entries:
|
||||||
|
baseline = str(metadata_from_lock(library, document).get("version") or "")
|
||||||
|
source = "lock file"
|
||||||
|
if not baseline:
|
||||||
|
baseline = installed_version(libs_root / library.destination)
|
||||||
|
|
||||||
|
latest_metadata = package_metadata(library.package, timeout)
|
||||||
|
latest, _, _ = validate_metadata(library, latest_metadata)
|
||||||
|
if not baseline:
|
||||||
|
print(f"{library.key}: no locked or installed version; latest is {latest}")
|
||||||
|
success = False
|
||||||
|
elif baseline == latest:
|
||||||
|
print(f"{library.key}: {source} is current ({latest})")
|
||||||
|
else:
|
||||||
|
print(f"{library.key}: update available ({baseline} -> {latest}, based on {source})")
|
||||||
|
success = False
|
||||||
|
return success
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
script_root = Path(__file__).resolve().parent.parent
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--root", type=Path, default=script_root, help="RustPad repository root")
|
||||||
|
parser.add_argument(
|
||||||
|
"--library",
|
||||||
|
action="append",
|
||||||
|
choices=[item.key for item in LIBRARIES],
|
||||||
|
default=[],
|
||||||
|
help="limit the operation to one library; may be repeated",
|
||||||
|
)
|
||||||
|
parser.add_argument("--force", action="store_true", help="download again even when the version is current")
|
||||||
|
parser.add_argument("--check", action="store_true", help="only verify that installed assets match the target versions")
|
||||||
|
parser.add_argument("--strict", action="store_true", help="fail when the registry cannot be reached")
|
||||||
|
parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT, help="network timeout in seconds")
|
||||||
|
parser.add_argument(
|
||||||
|
"--lock-file",
|
||||||
|
type=Path,
|
||||||
|
default=DEFAULT_LOCK_FILE,
|
||||||
|
help=f"lock-file path relative to --root (default: {DEFAULT_LOCK_FILE})",
|
||||||
|
)
|
||||||
|
modes = parser.add_mutually_exclusive_group()
|
||||||
|
modes.add_argument(
|
||||||
|
"--locked",
|
||||||
|
action="store_true",
|
||||||
|
help="install exact versions and tarballs from the lock file without querying npm metadata",
|
||||||
|
)
|
||||||
|
modes.add_argument(
|
||||||
|
"--update-lock",
|
||||||
|
action="store_true",
|
||||||
|
help="resolve target versions, install them, and atomically update the lock file",
|
||||||
|
)
|
||||||
|
modes.add_argument(
|
||||||
|
"--check-updates",
|
||||||
|
action="store_true",
|
||||||
|
help="compare locked or installed versions with the current npm latest versions",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--version",
|
||||||
|
action="append",
|
||||||
|
default=[],
|
||||||
|
metavar="LIBRARY=VERSION",
|
||||||
|
help="resolve an exact npm version instead of latest; may be repeated",
|
||||||
|
)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def validate_arguments(args: argparse.Namespace, overrides: dict[str, str]) -> None:
|
||||||
|
if args.timeout < 1:
|
||||||
|
raise UpdateError("--timeout must be at least 1 second")
|
||||||
|
if args.locked and overrides:
|
||||||
|
raise UpdateError("--version cannot be combined with --locked")
|
||||||
|
if args.check_updates and (args.check or args.force or overrides):
|
||||||
|
raise UpdateError("--check-updates cannot be combined with --check, --force, or --version")
|
||||||
|
if args.update_lock and args.check:
|
||||||
|
raise UpdateError("--update-lock cannot be combined with --check")
|
||||||
|
selected = set(args.library)
|
||||||
|
if selected:
|
||||||
|
outside_selection = sorted(set(overrides) - selected)
|
||||||
|
if outside_selection:
|
||||||
|
raise UpdateError(
|
||||||
|
"--version was provided for an unselected library: "
|
||||||
|
+ ", ".join(outside_selection)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
try:
|
||||||
|
overrides = parse_version_overrides(args.version)
|
||||||
|
validate_arguments(args, overrides)
|
||||||
|
|
||||||
|
root = args.root.resolve()
|
||||||
|
libs_root = root / "static" / "libs"
|
||||||
|
lock_path = resolve_path(root, args.lock_file)
|
||||||
|
libraries = selected_libraries(args.library)
|
||||||
|
|
||||||
|
if args.check_updates:
|
||||||
|
return 0 if check_updates(libraries, libs_root, lock_path, args.timeout) else 1
|
||||||
|
|
||||||
|
if args.locked or args.update_lock:
|
||||||
|
lock_document = load_lock_file(lock_path, required=args.locked)
|
||||||
|
else:
|
||||||
|
lock_document = {"schema": LOCK_SCHEMA, "libraries": {}}
|
||||||
|
targets: dict[str, dict] = {}
|
||||||
|
skipped: set[str] = set()
|
||||||
|
effective_strict = args.strict or args.check or args.update_lock
|
||||||
|
|
||||||
|
for library in libraries:
|
||||||
|
if args.locked:
|
||||||
|
targets[library.key] = metadata_from_lock(library, lock_document)
|
||||||
|
continue
|
||||||
|
|
||||||
|
requested_version = overrides.get(library.key, "latest")
|
||||||
|
try:
|
||||||
|
targets[library.key] = package_metadata(
|
||||||
|
library.package,
|
||||||
|
args.timeout,
|
||||||
|
requested_version,
|
||||||
|
)
|
||||||
|
validate_metadata(library, targets[library.key])
|
||||||
|
except UpdateError as error:
|
||||||
|
destination = libs_root / library.destination
|
||||||
|
complete = is_complete(library, destination)
|
||||||
|
current = installed_version(destination)
|
||||||
|
if complete and not effective_strict:
|
||||||
|
print(
|
||||||
|
f"warning: {error}; keeping {library.key} {current or 'local copy'}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
skipped.add(library.key)
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
|
||||||
|
success = True
|
||||||
|
for library in libraries:
|
||||||
|
if library.key in skipped:
|
||||||
|
continue
|
||||||
|
success = update_one(
|
||||||
|
library,
|
||||||
|
targets[library.key],
|
||||||
|
libs_root,
|
||||||
|
timeout=args.timeout,
|
||||||
|
force=args.force,
|
||||||
|
check=args.check,
|
||||||
|
) and success
|
||||||
|
|
||||||
|
if args.update_lock and success:
|
||||||
|
entries = dict(lock_document["libraries"])
|
||||||
|
for library in libraries:
|
||||||
|
entries[library.key] = lock_entry_from_metadata(library, targets[library.key])
|
||||||
|
updated_document = {
|
||||||
|
"schema": LOCK_SCHEMA,
|
||||||
|
"libraries": entries,
|
||||||
|
}
|
||||||
|
write_lock_file(lock_path, updated_document)
|
||||||
|
print(f"lock: updated {lock_path}")
|
||||||
|
|
||||||
|
return 0 if success else 1
|
||||||
|
except UpdateError as error:
|
||||||
|
print(f"error: {error}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
except OSError as error:
|
||||||
|
print(f"error: {error}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
+49
-14
@@ -18,16 +18,33 @@ pub struct AccessTokenRequest {
|
|||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct AccessTokenResponse {
|
pub struct AccessTokenResponse {
|
||||||
access_token: String,
|
granted: bool,
|
||||||
expires_at: String,
|
expires_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create_resource_access_token(
|
pub async fn create_resource_access_token(
|
||||||
State(state): State<SharedState>,
|
State(state): State<SharedState>,
|
||||||
|
headers: HeaderMap,
|
||||||
Json(payload): Json<AccessTokenRequest>,
|
Json(payload): Json<AccessTokenRequest>,
|
||||||
) -> Result<Json<AccessTokenResponse>, ApiError> {
|
) -> Result<Response, ApiError> {
|
||||||
let kind = payload.kind.trim();
|
let kind = payload.kind.trim();
|
||||||
let slug = payload.slug.trim();
|
let slug = payload.slug.trim();
|
||||||
|
let client_key = crate::security::client_key(&headers);
|
||||||
|
let client_limit_key = format!("resource-password-client:{client_key}");
|
||||||
|
let limit_key = format!("resource-password:{client_key}:{kind}:{slug}");
|
||||||
|
let window = std::time::Duration::from_secs(15 * 60);
|
||||||
|
state
|
||||||
|
.check_rate_limit(client_limit_key, 50, window)
|
||||||
|
.await
|
||||||
|
.map_err(|seconds| ApiError::rate_limited(&format!(
|
||||||
|
"Too many password attempts. Try again in {seconds} seconds."
|
||||||
|
)))?;
|
||||||
|
state
|
||||||
|
.check_rate_limit(limit_key.clone(), 10, window)
|
||||||
|
.await
|
||||||
|
.map_err(|seconds| ApiError::rate_limited(&format!(
|
||||||
|
"Too many password attempts. Try again in {seconds} seconds."
|
||||||
|
)))?;
|
||||||
match kind {
|
match kind {
|
||||||
"workspace" => {
|
"workspace" => {
|
||||||
let workspace = db::find_workspace(&state.db, slug)
|
let workspace = db::find_workspace(&state.db, slug)
|
||||||
@@ -63,13 +80,23 @@ pub async fn create_resource_access_token(
|
|||||||
.bind(&expires_at)
|
.bind(&expires_at)
|
||||||
.execute(state.db.pool())
|
.execute(state.db.pool())
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Json(AccessTokenResponse {
|
state.clear_rate_limit(&limit_key).await;
|
||||||
access_token: token,
|
let cookie = crate::security::resource_cookie(
|
||||||
|
kind,
|
||||||
|
slug,
|
||||||
|
&token,
|
||||||
|
state.anonymous_access_token_ttl_days,
|
||||||
|
);
|
||||||
|
let mut response = Json(AccessTokenResponse {
|
||||||
|
granted: true,
|
||||||
expires_at,
|
expires_at,
|
||||||
}))
|
})
|
||||||
|
.into_response();
|
||||||
|
response.headers_mut().insert(header::SET_COOKIE, cookie);
|
||||||
|
Ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn verify_resource_access_token(
|
pub(crate) async fn verify_password_access_token(
|
||||||
state: &SharedState,
|
state: &SharedState,
|
||||||
kind: &str,
|
kind: &str,
|
||||||
slug: &str,
|
slug: &str,
|
||||||
@@ -78,13 +105,6 @@ pub async fn verify_resource_access_token(
|
|||||||
let Some(token) = token.map(str::trim).filter(|value| !value.is_empty()) else {
|
let Some(token) = token.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
};
|
};
|
||||||
if crate::auth::resource_permission(state, kind, slug, Some(token))
|
|
||||||
.await
|
|
||||||
.map_err(|error| ApiError::forbidden(&error.message))?
|
|
||||||
.is_some()
|
|
||||||
{
|
|
||||||
return Ok(true);
|
|
||||||
}
|
|
||||||
let count: i64 = sqlx::query_scalar(queries::get(
|
let count: i64 = sqlx::query_scalar(queries::get(
|
||||||
state.db.kind(),
|
state.db.kind(),
|
||||||
queries::RESOURCE_ACCESS_TOKENS_VALID_COUNT,
|
queries::RESOURCE_ACCESS_TOKENS_VALID_COUNT,
|
||||||
@@ -95,7 +115,22 @@ pub async fn verify_resource_access_token(
|
|||||||
.bind(Utc::now().to_rfc3339())
|
.bind(Utc::now().to_rfc3339())
|
||||||
.fetch_one(state.db.pool())
|
.fetch_one(state.db.pool())
|
||||||
.await?;
|
.await?;
|
||||||
Ok(count > 0)
|
if count == 0 {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Password-derived access must stop working when the resource no longer
|
||||||
|
// has a password. This also invalidates tokens created by older versions
|
||||||
|
// for private resources that never had a password configured.
|
||||||
|
match kind {
|
||||||
|
"workspace" => Ok(db::find_workspace(&state.db, slug)
|
||||||
|
.await?
|
||||||
|
.is_some_and(|workspace| workspace.password_hash.is_some())),
|
||||||
|
"pad" => Ok(db::find_pad(&state.db, slug)
|
||||||
|
.await?
|
||||||
|
.is_some_and(|pad| pad.password_hash.is_some())),
|
||||||
|
_ => Ok(false),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn hash_access_token(token: &str) -> String {
|
pub(super) fn hash_access_token(token: &str) -> String {
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ impl ApiError {
|
|||||||
message: message.into(),
|
message: message.into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
pub(crate) fn rate_limited(message: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
status: StatusCode::TOO_MANY_REQUESTS,
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
pub(crate) fn payload_too_large(max_bytes: usize) -> Self {
|
pub(crate) fn payload_too_large(max_bytes: usize) -> Self {
|
||||||
let max_mb = max_bytes / (1024 * 1024);
|
let max_mb = max_bytes / (1024 * 1024);
|
||||||
Self {
|
Self {
|
||||||
|
|||||||
+302
-75
@@ -16,6 +16,7 @@ pub async fn upload_pad_file(
|
|||||||
Path(slug): Path<String>,
|
Path(slug): Path<String>,
|
||||||
mut multipart: Multipart,
|
mut multipart: Multipart,
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
let upload_max_size_bytes = require_upload_permission(&state, &headers).await?;
|
||||||
let mut password: Option<String> = None;
|
let mut password: Option<String> = None;
|
||||||
let mut access_token: Option<String> = None;
|
let mut access_token: Option<String> = None;
|
||||||
let mut file: Option<(String, Vec<u8>)> = None;
|
let mut file: Option<(String, Vec<u8>)> = None;
|
||||||
@@ -45,8 +46,8 @@ pub async fn upload_pad_file(
|
|||||||
.bytes()
|
.bytes()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| ApiError::bad_request("Failed to read the file"))?;
|
.map_err(|_| ApiError::bad_request("Failed to read the file"))?;
|
||||||
if bytes.len() > state.upload_max_size_bytes {
|
if bytes.len() > upload_max_size_bytes {
|
||||||
return Err(ApiError::payload_too_large(state.upload_max_size_bytes));
|
return Err(ApiError::payload_too_large(upload_max_size_bytes));
|
||||||
}
|
}
|
||||||
file = Some((filename, bytes.to_vec()));
|
file = Some((filename, bytes.to_vec()));
|
||||||
}
|
}
|
||||||
@@ -55,8 +56,9 @@ pub async fn upload_pad_file(
|
|||||||
&state,
|
&state,
|
||||||
&slug,
|
&slug,
|
||||||
password.as_deref(),
|
password.as_deref(),
|
||||||
access_token.as_deref(),
|
resource_request_token(&headers, "pad", &slug, access_token.as_deref()),
|
||||||
bearer_token(&headers),
|
bearer_token(&headers),
|
||||||
|
&headers,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let level = if db::verify_pad_password(&pad, password.as_deref())
|
let level = if db::verify_pad_password(&pad, password.as_deref())
|
||||||
@@ -64,11 +66,12 @@ pub async fn upload_pad_file(
|
|||||||
{
|
{
|
||||||
AccessLevel::Write
|
AccessLevel::Write
|
||||||
} else {
|
} else {
|
||||||
combined_token_access_level(
|
request_access_level(
|
||||||
&state,
|
&state,
|
||||||
|
&headers,
|
||||||
"pad",
|
"pad",
|
||||||
&slug,
|
&slug,
|
||||||
access_token.as_deref(),
|
resource_request_token(&headers, "pad", &slug, access_token.as_deref()),
|
||||||
bearer_token(&headers),
|
bearer_token(&headers),
|
||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
@@ -97,7 +100,11 @@ pub async fn upload_pad_file(
|
|||||||
stored = format!("{stem}-{}{}", db::random_suffix(6), ext);
|
stored = format!("{stem}-{}{}", db::random_suffix(6), ext);
|
||||||
key = crate::storage::object_key("pads", pad.id, &file_token, &stored);
|
key = crate::storage::object_key("pads", pad.id, &file_token, &stored);
|
||||||
}
|
}
|
||||||
let url = format!("/f/{}/{}", file_token, stored);
|
let stored_url = crate::file_urls::stored_file_path(&file_token, &stored);
|
||||||
|
let public_url = crate::file_urls::public_file_url(
|
||||||
|
state.files_public_url.as_deref(),
|
||||||
|
&stored_url,
|
||||||
|
);
|
||||||
let mime = mime_guess::from_path(&stored)
|
let mime = mime_guess::from_path(&stored)
|
||||||
.first_or_octet_stream()
|
.first_or_octet_stream()
|
||||||
.to_string();
|
.to_string();
|
||||||
@@ -107,15 +114,23 @@ pub async fn upload_pad_file(
|
|||||||
.put(&key, bytes.clone().into(), &mime, &cache_control)
|
.put(&key, bytes.clone().into(), &mime, &cache_control)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| ApiError::internal("Failed to save the file"))?;
|
.map_err(|_| ApiError::internal("Failed to save the file"))?;
|
||||||
db::register_pad_file(&state.db, pad.id, &stored, &url, &mime, bytes.len() as i64).await?;
|
db::register_pad_file(
|
||||||
Ok(Json(serde_json::json!({"name": stored, "url": url, "mime_type": mime})))
|
&state.db,
|
||||||
|
pad.id,
|
||||||
|
&stored,
|
||||||
|
&stored_url,
|
||||||
|
&mime,
|
||||||
|
bytes.len() as i64,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(serde_json::json!({"name": stored, "url": public_url, "mime_type": mime})))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn content_references_file(content: &str, filename: &str, url: &str) -> bool {
|
pub(super) fn content_references_file(content: &str, filename: &str, url: &str) -> bool {
|
||||||
if content.contains(url) {
|
if content.contains(url) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
for marker in ["[file=", "[image=", "[img="] {
|
for marker in ["[file=", "[image=", "[img=", "[video="] {
|
||||||
let mut remaining = content;
|
let mut remaining = content;
|
||||||
while let Some(index) = remaining.find(marker) {
|
while let Some(index) = remaining.find(marker) {
|
||||||
let after = &remaining[index + marker.len()..];
|
let after = &remaining[index + marker.len()..];
|
||||||
@@ -134,6 +149,26 @@ pub(super) fn content_references_file(content: &str, filename: &str, url: &str)
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(super) fn content_references_stored_file(
|
||||||
|
content: &str,
|
||||||
|
filename: &str,
|
||||||
|
stored_url: &str,
|
||||||
|
public_base: Option<&str>,
|
||||||
|
) -> bool {
|
||||||
|
if content_references_file(content, filename, stored_url) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let canonical = crate::file_urls::canonical_file_path(stored_url);
|
||||||
|
if canonical
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|url| url != stored_url && content.contains(url))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let public_url = crate::file_urls::public_file_url(public_base, stored_url);
|
||||||
|
public_url != stored_url && content.contains(&public_url)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn pad_files(
|
pub async fn pad_files(
|
||||||
State(state): State<SharedState>,
|
State(state): State<SharedState>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
@@ -144,13 +179,19 @@ pub async fn pad_files(
|
|||||||
&state,
|
&state,
|
||||||
&slug,
|
&slug,
|
||||||
payload.password.as_deref(),
|
payload.password.as_deref(),
|
||||||
payload.access_token.as_deref(),
|
resource_request_token(&headers, "pad", &slug, payload.access_token.as_deref()),
|
||||||
bearer_token(&headers),
|
bearer_token(&headers),
|
||||||
|
&headers,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let mut files = db::list_pad_files(&state.db, pad.id).await?;
|
let mut files = db::list_pad_files(&state.db, pad.id).await?;
|
||||||
for file in &mut files {
|
for file in &mut files {
|
||||||
let attached = content_references_file(&pad.content, &file.filename, &file.url);
|
let attached = content_references_stored_file(
|
||||||
|
&pad.content,
|
||||||
|
&file.filename,
|
||||||
|
&file.url,
|
||||||
|
state.files_public_url.as_deref(),
|
||||||
|
);
|
||||||
if attached != file.is_attached {
|
if attached != file.is_attached {
|
||||||
db::set_pad_file_attached(&state.db, file.id, attached).await?;
|
db::set_pad_file_attached(&state.db, file.id, attached).await?;
|
||||||
file.is_attached = attached;
|
file.is_attached = attached;
|
||||||
@@ -161,6 +202,10 @@ pub async fn pad_files(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
file.created_at = db::normalize_timestamp(&file.created_at);
|
file.created_at = db::normalize_timestamp(&file.created_at);
|
||||||
|
file.url = crate::file_urls::public_file_url(
|
||||||
|
state.files_public_url.as_deref(),
|
||||||
|
&file.url,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Ok(Json(files))
|
Ok(Json(files))
|
||||||
}
|
}
|
||||||
@@ -175,14 +220,23 @@ pub async fn delete_pad_file(
|
|||||||
&state,
|
&state,
|
||||||
&slug,
|
&slug,
|
||||||
payload.password.as_deref(),
|
payload.password.as_deref(),
|
||||||
payload.access_token.as_deref(),
|
resource_request_token(&headers, "pad", &slug, payload.access_token.as_deref()),
|
||||||
bearer_token(&headers),
|
bearer_token(&headers),
|
||||||
|
&headers,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
if !crate::auth::is_resource_owner(&state, "pad", &pad.slug, bearer_token(&headers))
|
let account_owner = crate::auth::is_resource_owner(
|
||||||
.await
|
&state,
|
||||||
.unwrap_or(false)
|
"pad",
|
||||||
{
|
&pad.slug,
|
||||||
|
bearer_token(&headers),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_or(false);
|
||||||
|
let guest_owner = pad_creator_is_requester(&headers, &pad);
|
||||||
|
let password_write_access =
|
||||||
|
has_password_write_access(&state, &headers, "pad", &pad.slug).await?;
|
||||||
|
if !account_owner && !guest_owner && !password_write_access {
|
||||||
return Err(ApiError::forbidden("Only the note owner can delete files"));
|
return Err(ApiError::forbidden("Only the note owner can delete files"));
|
||||||
}
|
}
|
||||||
let file = db::find_pad_file(&state.db, pad.id, file_id)
|
let file = db::find_pad_file(&state.db, pad.id, file_id)
|
||||||
@@ -201,6 +255,7 @@ pub async fn upload_note_file(
|
|||||||
Path((workspace_slug, note_slug)): Path<(String, String)>,
|
Path((workspace_slug, note_slug)): Path<(String, String)>,
|
||||||
mut multipart: Multipart,
|
mut multipart: Multipart,
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
let upload_max_size_bytes = require_upload_permission(&state, &headers).await?;
|
||||||
let mut password: Option<String> = None;
|
let mut password: Option<String> = None;
|
||||||
let mut access_token: Option<String> = None;
|
let mut access_token: Option<String> = None;
|
||||||
let mut file: Option<(String, Vec<u8>)> = None;
|
let mut file: Option<(String, Vec<u8>)> = None;
|
||||||
@@ -230,8 +285,8 @@ pub async fn upload_note_file(
|
|||||||
.bytes()
|
.bytes()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| ApiError::bad_request("Failed to read the file"))?;
|
.map_err(|_| ApiError::bad_request("Failed to read the file"))?;
|
||||||
if bytes.len() > state.upload_max_size_bytes {
|
if bytes.len() > upload_max_size_bytes {
|
||||||
return Err(ApiError::payload_too_large(state.upload_max_size_bytes));
|
return Err(ApiError::payload_too_large(upload_max_size_bytes));
|
||||||
}
|
}
|
||||||
file = Some((filename, bytes.to_vec()));
|
file = Some((filename, bytes.to_vec()));
|
||||||
}
|
}
|
||||||
@@ -241,8 +296,9 @@ pub async fn upload_note_file(
|
|||||||
&workspace_slug,
|
&workspace_slug,
|
||||||
¬e_slug,
|
¬e_slug,
|
||||||
password.as_deref(),
|
password.as_deref(),
|
||||||
access_token.as_deref(),
|
resource_request_token(&headers, "workspace", &workspace_slug, access_token.as_deref()),
|
||||||
bearer_token(&headers),
|
bearer_token(&headers),
|
||||||
|
&headers,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -251,11 +307,12 @@ pub async fn upload_note_file(
|
|||||||
{
|
{
|
||||||
AccessLevel::Write
|
AccessLevel::Write
|
||||||
} else {
|
} else {
|
||||||
combined_token_access_level(
|
request_access_level(
|
||||||
&state,
|
&state,
|
||||||
|
&headers,
|
||||||
"workspace",
|
"workspace",
|
||||||
&workspace_slug,
|
&workspace_slug,
|
||||||
access_token.as_deref(),
|
resource_request_token(&headers, "workspace", &workspace_slug, access_token.as_deref()),
|
||||||
bearer_token(&headers),
|
bearer_token(&headers),
|
||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
@@ -284,7 +341,11 @@ pub async fn upload_note_file(
|
|||||||
stored = format!("{stem}-{}{}", db::random_suffix(6), ext);
|
stored = format!("{stem}-{}{}", db::random_suffix(6), ext);
|
||||||
key = crate::storage::object_key("notes", note.id, &file_token, &stored);
|
key = crate::storage::object_key("notes", note.id, &file_token, &stored);
|
||||||
}
|
}
|
||||||
let url = format!("/f/{}/{}", file_token, stored);
|
let stored_url = crate::file_urls::stored_file_path(&file_token, &stored);
|
||||||
|
let public_url = crate::file_urls::public_file_url(
|
||||||
|
state.files_public_url.as_deref(),
|
||||||
|
&stored_url,
|
||||||
|
);
|
||||||
let mime = mime_guess::from_path(&stored)
|
let mime = mime_guess::from_path(&stored)
|
||||||
.first_or_octet_stream()
|
.first_or_octet_stream()
|
||||||
.to_string();
|
.to_string();
|
||||||
@@ -294,8 +355,16 @@ pub async fn upload_note_file(
|
|||||||
.put(&key, bytes.clone().into(), &mime, &cache_control)
|
.put(&key, bytes.clone().into(), &mime, &cache_control)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| ApiError::internal("Failed to save the file"))?;
|
.map_err(|_| ApiError::internal("Failed to save the file"))?;
|
||||||
db::register_note_file(&state.db, note.id, &stored, &url, &mime, bytes.len() as i64).await?;
|
db::register_note_file(
|
||||||
Ok(Json(serde_json::json!({"name": stored, "url": url, "mime_type": mime})))
|
&state.db,
|
||||||
|
note.id,
|
||||||
|
&stored,
|
||||||
|
&stored_url,
|
||||||
|
&mime,
|
||||||
|
bytes.len() as i64,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(serde_json::json!({"name": stored, "url": public_url, "mime_type": mime})))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_note(
|
pub async fn delete_note(
|
||||||
@@ -309,8 +378,9 @@ pub async fn delete_note(
|
|||||||
&workspace_slug,
|
&workspace_slug,
|
||||||
¬e_slug,
|
¬e_slug,
|
||||||
payload.password.as_deref(),
|
payload.password.as_deref(),
|
||||||
payload.access_token.as_deref(),
|
resource_request_token(&headers, "workspace", &workspace_slug, payload.access_token.as_deref()),
|
||||||
bearer_token(&headers),
|
bearer_token(&headers),
|
||||||
|
&headers,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -319,19 +389,20 @@ pub async fn delete_note(
|
|||||||
{
|
{
|
||||||
AccessLevel::Write
|
AccessLevel::Write
|
||||||
} else {
|
} else {
|
||||||
combined_token_access_level(
|
request_access_level(
|
||||||
&state,
|
&state,
|
||||||
|
&headers,
|
||||||
"workspace",
|
"workspace",
|
||||||
&workspace_slug,
|
&workspace_slug,
|
||||||
payload.access_token.as_deref(),
|
resource_request_token(&headers, "workspace", &workspace_slug, payload.access_token.as_deref()),
|
||||||
bearer_token(&headers),
|
bearer_token(&headers),
|
||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
};
|
};
|
||||||
require_write(level)?;
|
require_write(level)?;
|
||||||
if note.protected {
|
if note.protected && !requester_owns_note(&state, &headers, &workspace, ¬e).await? {
|
||||||
return Err(ApiError::bad_request(
|
return Err(ApiError::forbidden(
|
||||||
"This note is protected and cannot be deleted",
|
"This note is protected. Only its owner can delete it.",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
for file in db::list_note_files(&state.db, note.id).await? {
|
for file in db::list_note_files(&state.db, note.id).await? {
|
||||||
@@ -360,13 +431,19 @@ pub async fn note_files(
|
|||||||
&workspace_slug,
|
&workspace_slug,
|
||||||
¬e_slug,
|
¬e_slug,
|
||||||
payload.password.as_deref(),
|
payload.password.as_deref(),
|
||||||
payload.access_token.as_deref(),
|
resource_request_token(&headers, "workspace", &workspace_slug, payload.access_token.as_deref()),
|
||||||
bearer_token(&headers),
|
bearer_token(&headers),
|
||||||
|
&headers,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let mut files = db::list_note_files(&state.db, note.id).await?;
|
let mut files = db::list_note_files(&state.db, note.id).await?;
|
||||||
for file in &mut files {
|
for file in &mut files {
|
||||||
let attached = content_references_file(¬e.content, &file.filename, &file.url);
|
let attached = content_references_stored_file(
|
||||||
|
¬e.content,
|
||||||
|
&file.filename,
|
||||||
|
&file.url,
|
||||||
|
state.files_public_url.as_deref(),
|
||||||
|
);
|
||||||
if attached != file.is_attached {
|
if attached != file.is_attached {
|
||||||
db::set_note_file_attached(&state.db, file.id, attached).await?;
|
db::set_note_file_attached(&state.db, file.id, attached).await?;
|
||||||
file.is_attached = attached;
|
file.is_attached = attached;
|
||||||
@@ -377,6 +454,10 @@ pub async fn note_files(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
file.created_at = db::normalize_timestamp(&file.created_at);
|
file.created_at = db::normalize_timestamp(&file.created_at);
|
||||||
|
file.url = crate::file_urls::public_file_url(
|
||||||
|
state.files_public_url.as_deref(),
|
||||||
|
&file.url,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Ok(Json(files))
|
Ok(Json(files))
|
||||||
}
|
}
|
||||||
@@ -392,31 +473,35 @@ pub async fn delete_note_file(
|
|||||||
&workspace_slug,
|
&workspace_slug,
|
||||||
¬e_slug,
|
¬e_slug,
|
||||||
payload.password.as_deref(),
|
payload.password.as_deref(),
|
||||||
payload.access_token.as_deref(),
|
resource_request_token(&headers, "workspace", &workspace_slug, payload.access_token.as_deref()),
|
||||||
bearer_token(&headers),
|
bearer_token(&headers),
|
||||||
|
&headers,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let workspace_owner = crate::auth::is_resource_owner(
|
let level = if db::verify_workspace_password(&workspace, payload.password.as_deref())
|
||||||
&state,
|
|| (workspace.is_private == 0 && workspace.password_hash.is_none())
|
||||||
"workspace",
|
{
|
||||||
&workspace.slug,
|
AccessLevel::Write
|
||||||
bearer_token(&headers),
|
} else {
|
||||||
)
|
request_access_level(
|
||||||
.await
|
&state,
|
||||||
.unwrap_or(false);
|
&headers,
|
||||||
let note_owner = crate::auth::optional_user(&state, &headers)
|
"workspace",
|
||||||
.await
|
&workspace_slug,
|
||||||
.ok()
|
resource_request_token(
|
||||||
.flatten()
|
&headers,
|
||||||
.and_then(|user| {
|
"workspace",
|
||||||
note.created_by
|
&workspace_slug,
|
||||||
.as_deref()
|
payload.access_token.as_deref(),
|
||||||
.map(|creator| creator == user.nickname)
|
),
|
||||||
})
|
bearer_token(&headers),
|
||||||
.unwrap_or(false);
|
)
|
||||||
if !workspace_owner && !note_owner {
|
.await?
|
||||||
|
};
|
||||||
|
require_write(level)?;
|
||||||
|
if note.protected && !requester_owns_note(&state, &headers, &workspace, ¬e).await? {
|
||||||
return Err(ApiError::forbidden(
|
return Err(ApiError::forbidden(
|
||||||
"Only the note owner or workspace owner can delete files",
|
"This note is protected. Only its owner can delete files.",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let file = db::find_note_file(&state.db, note.id, file_id)
|
let file = db::find_note_file(&state.db, note.id, file_id)
|
||||||
@@ -429,34 +514,47 @@ pub async fn delete_note_file(
|
|||||||
Ok(Json(serde_json::json!({"ok": true})))
|
Ok(Json(serde_json::json!({"ok": true})))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn download_file(
|
async fn require_upload_permission(
|
||||||
State(state): State<SharedState>,
|
state: &SharedState,
|
||||||
Path((token, filename)): Path<(String, String)>,
|
headers: &HeaderMap,
|
||||||
) -> Result<Response, ApiError> {
|
) -> Result<usize, ApiError> {
|
||||||
serve_token_file(&state, &token, &filename).await
|
upload_limit_for_request(state, headers)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| ApiError::forbidden("File uploads are disabled for guests."))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn download_legacy_file(
|
#[derive(Debug, Default, Deserialize)]
|
||||||
State(state): State<SharedState>,
|
pub struct FileDownloadQuery {
|
||||||
Path((directory, filename)): Path<(String, String)>,
|
#[serde(default)]
|
||||||
) -> Result<Response, ApiError> {
|
download: Option<String>,
|
||||||
let Some((id_part, token)) = directory.split_once('_') else {
|
}
|
||||||
return Err(ApiError::not_found_file());
|
|
||||||
};
|
impl FileDownloadQuery {
|
||||||
let id: i64 = id_part.parse().map_err(|_| ApiError::not_found_file())?;
|
fn force_download(&self) -> bool {
|
||||||
let owner = db::find_file_owner(&state.db, token)
|
self.download.as_deref().is_some_and(|value| {
|
||||||
.await?
|
matches!(
|
||||||
.ok_or_else(ApiError::not_found_file)?;
|
value.trim().to_ascii_lowercase().as_str(),
|
||||||
if owner.id != id {
|
"1" | "true" | "yes" | "download"
|
||||||
return Err(ApiError::not_found_file());
|
)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
serve_token_file(&state, token, &filename).await
|
}
|
||||||
|
|
||||||
|
pub async fn download_file(
|
||||||
|
State(state): State<SharedState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Path((token, filename)): Path<(String, String)>,
|
||||||
|
Query(query): Query<FileDownloadQuery>,
|
||||||
|
) -> Result<Response, ApiError> {
|
||||||
|
serve_token_file(&state, &headers, &token, &filename, query.force_download()).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn serve_token_file(
|
async fn serve_token_file(
|
||||||
state: &SharedState,
|
state: &SharedState,
|
||||||
|
headers: &HeaderMap,
|
||||||
token: &str,
|
token: &str,
|
||||||
filename: &str,
|
filename: &str,
|
||||||
|
force_download: bool,
|
||||||
) -> Result<Response, ApiError> {
|
) -> Result<Response, ApiError> {
|
||||||
let safe = sanitize_filename(filename);
|
let safe = sanitize_filename(filename);
|
||||||
if safe != filename {
|
if safe != filename {
|
||||||
@@ -476,13 +574,72 @@ async fn serve_token_file(
|
|||||||
.get_local_with_legacy(&key, &legacy_key)
|
.get_local_with_legacy(&key, &legacy_key)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| ApiError::not_found_file())?;
|
.map_err(|_| ApiError::not_found_file())?;
|
||||||
let mime = mime_guess::from_path(&safe).first_or_octet_stream();
|
let total_len = bytes.len();
|
||||||
let mut response = bytes.into_response();
|
let guessed_mime = mime_guess::from_path(&safe).first_or_octet_stream();
|
||||||
|
let safe_inline = is_safe_inline_image_mime(guessed_mime.essence_str())
|
||||||
|
|| is_safe_inline_video_mime(guessed_mime.essence_str());
|
||||||
|
let served_mime = if safe_inline {
|
||||||
|
guessed_mime.as_ref()
|
||||||
|
} else {
|
||||||
|
"application/octet-stream"
|
||||||
|
};
|
||||||
|
let disposition = if safe_inline && !force_download {
|
||||||
|
"inline"
|
||||||
|
} else {
|
||||||
|
"attachment"
|
||||||
|
};
|
||||||
|
|
||||||
|
let requested_range = headers
|
||||||
|
.get(header::RANGE)
|
||||||
|
.and_then(|value| value.to_str().ok());
|
||||||
|
let range = match requested_range {
|
||||||
|
Some(value) => match parse_byte_range(value, total_len) {
|
||||||
|
Ok(range) => range,
|
||||||
|
Err(()) => return Ok(range_not_satisfiable(total_len)),
|
||||||
|
},
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let (body, status, content_range) = if let Some((start, end)) = range {
|
||||||
|
(
|
||||||
|
bytes.slice(start..end),
|
||||||
|
StatusCode::PARTIAL_CONTENT,
|
||||||
|
Some(format!("bytes {start}-{}/{}", end - 1, total_len)),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(bytes, StatusCode::OK, None)
|
||||||
|
};
|
||||||
|
let body_len = body.len();
|
||||||
|
let mut response = body.into_response();
|
||||||
|
*response.status_mut() = status;
|
||||||
response.headers_mut().insert(
|
response.headers_mut().insert(
|
||||||
header::CONTENT_TYPE,
|
header::CONTENT_TYPE,
|
||||||
HeaderValue::from_str(mime.as_ref())
|
HeaderValue::from_str(served_mime)
|
||||||
.unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream")),
|
.unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream")),
|
||||||
);
|
);
|
||||||
|
response.headers_mut().insert(
|
||||||
|
header::CONTENT_DISPOSITION,
|
||||||
|
HeaderValue::from_str(&format!("{disposition}; filename=\"{safe}\""))
|
||||||
|
.expect("sanitized attachment filename"),
|
||||||
|
);
|
||||||
|
response.headers_mut().insert(
|
||||||
|
header::ACCEPT_RANGES,
|
||||||
|
HeaderValue::from_static("bytes"),
|
||||||
|
);
|
||||||
|
response.headers_mut().insert(
|
||||||
|
header::CONTENT_LENGTH,
|
||||||
|
HeaderValue::from_str(&body_len.to_string()).expect("valid content length"),
|
||||||
|
);
|
||||||
|
if let Some(content_range) = content_range {
|
||||||
|
response.headers_mut().insert(
|
||||||
|
header::CONTENT_RANGE,
|
||||||
|
HeaderValue::from_str(&content_range).expect("valid content range"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
response.headers_mut().insert(
|
||||||
|
HeaderName::from_static("content-security-policy"),
|
||||||
|
HeaderValue::from_static("default-src 'none'; sandbox"),
|
||||||
|
);
|
||||||
response.headers_mut().insert(
|
response.headers_mut().insert(
|
||||||
header::X_CONTENT_TYPE_OPTIONS,
|
header::X_CONTENT_TYPE_OPTIONS,
|
||||||
HeaderValue::from_static("nosniff"),
|
HeaderValue::from_static("nosniff"),
|
||||||
@@ -501,6 +658,72 @@ async fn serve_token_file(
|
|||||||
Ok(response)
|
Ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn range_not_satisfiable(total_len: usize) -> Response {
|
||||||
|
let mut response = StatusCode::RANGE_NOT_SATISFIABLE.into_response();
|
||||||
|
response.headers_mut().insert(
|
||||||
|
header::CONTENT_RANGE,
|
||||||
|
HeaderValue::from_str(&format!("bytes */{total_len}")).expect("valid content range"),
|
||||||
|
);
|
||||||
|
response.headers_mut().insert(
|
||||||
|
header::ACCEPT_RANGES,
|
||||||
|
HeaderValue::from_static("bytes"),
|
||||||
|
);
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_byte_range(value: &str, total_len: usize) -> Result<Option<(usize, usize)>, ()> {
|
||||||
|
if total_len == 0 {
|
||||||
|
return Err(());
|
||||||
|
}
|
||||||
|
let range = value.trim().strip_prefix("bytes=").ok_or(())?;
|
||||||
|
if range.contains(',') {
|
||||||
|
return Err(());
|
||||||
|
}
|
||||||
|
let (start, end) = range.split_once('-').ok_or(())?;
|
||||||
|
if start.is_empty() {
|
||||||
|
let suffix_len = end.parse::<usize>().map_err(|_| ())?;
|
||||||
|
if suffix_len == 0 {
|
||||||
|
return Err(());
|
||||||
|
}
|
||||||
|
let start = total_len.saturating_sub(suffix_len);
|
||||||
|
return Ok(Some((start, total_len)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let start = start.parse::<usize>().map_err(|_| ())?;
|
||||||
|
if start >= total_len {
|
||||||
|
return Err(());
|
||||||
|
}
|
||||||
|
let end_inclusive = if end.is_empty() {
|
||||||
|
total_len - 1
|
||||||
|
} else {
|
||||||
|
end.parse::<usize>().map_err(|_| ())?.min(total_len - 1)
|
||||||
|
};
|
||||||
|
if end_inclusive < start {
|
||||||
|
return Err(());
|
||||||
|
}
|
||||||
|
Ok(Some((start, end_inclusive + 1)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_safe_inline_image_mime(value: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
value,
|
||||||
|
"image/png"
|
||||||
|
| "image/jpeg"
|
||||||
|
| "image/gif"
|
||||||
|
| "image/webp"
|
||||||
|
| "image/avif"
|
||||||
|
| "image/bmp"
|
||||||
|
| "image/x-icon"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_safe_inline_video_mime(value: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
value,
|
||||||
|
"video/mp4" | "video/webm" | "video/ogg" | "video/quicktime" | "video/x-m4v"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn sanitize_filename(value: &str) -> String {
|
fn sanitize_filename(value: &str) -> String {
|
||||||
let name = std::path::Path::new(value)
|
let name = std::path::Path::new(value)
|
||||||
.file_name()
|
.file_name()
|
||||||
@@ -522,3 +745,7 @@ fn sanitize_filename(value: &str) -> String {
|
|||||||
clean.chars().take(160).collect()
|
clean.chars().take(160).collect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../tests/api_files.rs"]
|
||||||
|
mod tests;
|
||||||
|
|||||||
+743
-143
File diff suppressed because it is too large
Load Diff
+358
-69
@@ -14,6 +14,15 @@ pub struct CreatePadRequest {
|
|||||||
name: String,
|
name: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
password: Option<String>,
|
password: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
content: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct SetPadPasswordRequest {
|
||||||
|
password: String,
|
||||||
|
#[serde(default)]
|
||||||
|
client_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
@@ -27,6 +36,7 @@ pub struct PadInfo {
|
|||||||
slug: String,
|
slug: String,
|
||||||
title: String,
|
title: String,
|
||||||
protected: bool,
|
protected: bool,
|
||||||
|
access_level: String,
|
||||||
allow_public_task_updates: bool,
|
allow_public_task_updates: bool,
|
||||||
public_page_unprotected: bool,
|
public_page_unprotected: bool,
|
||||||
public_page_enabled: bool,
|
public_page_enabled: bool,
|
||||||
@@ -34,6 +44,8 @@ pub struct PadInfo {
|
|||||||
created_at: String,
|
created_at: String,
|
||||||
updated_at: String,
|
updated_at: String,
|
||||||
can_delete_files: bool,
|
can_delete_files: bool,
|
||||||
|
can_upload_files: bool,
|
||||||
|
upload_max_size_bytes: Option<usize>,
|
||||||
global_color: Option<String>,
|
global_color: Option<String>,
|
||||||
note_color: Option<String>,
|
note_color: Option<String>,
|
||||||
authorship_mode: String,
|
authorship_mode: String,
|
||||||
@@ -42,11 +54,13 @@ pub struct PadInfo {
|
|||||||
editor_line_numbers: bool,
|
editor_line_numbers: bool,
|
||||||
preview_line_numbers: bool,
|
preview_line_numbers: bool,
|
||||||
line_links: bool,
|
line_links: bool,
|
||||||
|
toolbar_collapsed: bool,
|
||||||
font_family: String,
|
font_family: String,
|
||||||
font_size: i64,
|
font_size: i64,
|
||||||
personal_editor_settings: bool,
|
personal_editor_settings: bool,
|
||||||
can_save_editor_settings: bool,
|
can_save_editor_settings: bool,
|
||||||
can_manage_authorship: bool,
|
can_manage_authorship: bool,
|
||||||
|
can_set_password: bool,
|
||||||
files: Vec<MarkdownFileReference>,
|
files: Vec<MarkdownFileReference>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,6 +71,7 @@ pub async fn create_pad(
|
|||||||
) -> Result<(StatusCode, Json<CreatePadResponse>), ApiError> {
|
) -> Result<(StatusCode, Json<CreatePadResponse>), ApiError> {
|
||||||
let title = validate_name(&payload.name, "Note name")?;
|
let title = validate_name(&payload.name, "Note name")?;
|
||||||
let password = validate_password(payload.password.as_deref())?;
|
let password = validate_password(payload.password.as_deref())?;
|
||||||
|
let initial_content = validate_initial_content(payload.content.as_deref())?;
|
||||||
let base = slugify(title);
|
let base = slugify(title);
|
||||||
if base.is_empty() {
|
if base.is_empty() {
|
||||||
return Err(ApiError::bad_request(
|
return Err(ApiError::bad_request(
|
||||||
@@ -64,17 +79,26 @@ pub async fn create_pad(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
let slug = unique_pad_slug(&state, &base).await?;
|
let slug = unique_pad_slug(&state, &base).await?;
|
||||||
let pad = db::create_pad(&state.db, &slug, title, password).await?;
|
let account_user = crate::auth::optional_user(&state, &headers)
|
||||||
if let Some(user) = crate::auth::optional_user(&state, &headers)
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::forbidden(&e.message))?
|
.map_err(|e| ApiError::forbidden(&e.message))?;
|
||||||
{
|
let author = account_user.as_ref().map(|user| user.nickname.clone());
|
||||||
|
let created_by_guest_id = if account_user.is_none() {
|
||||||
|
requester_guest_id(&headers)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let pad = db::create_pad(&state.db, &slug, title, password, created_by_guest_id).await?;
|
||||||
|
if let Some(user) = account_user.as_ref() {
|
||||||
sqlx::query(queries::get(state.db.kind(), queries::USER_ATTACH_PAD))
|
sqlx::query(queries::get(state.db.kind(), queries::USER_ATTACH_PAD))
|
||||||
.bind(user.id)
|
.bind(user.id)
|
||||||
.bind(&pad.slug)
|
.bind(&pad.slug)
|
||||||
.execute(state.db.pool())
|
.execute(state.db.pool())
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
if let Some(content) = initial_content {
|
||||||
|
db::save_pad_revision(&state.db, pad.id, content, author.as_deref(), "[]").await?;
|
||||||
|
}
|
||||||
Ok((
|
Ok((
|
||||||
StatusCode::CREATED,
|
StatusCode::CREATED,
|
||||||
Json(CreatePadResponse {
|
Json(CreatePadResponse {
|
||||||
@@ -92,34 +116,36 @@ pub async fn pad_info(
|
|||||||
let pad = db::find_pad(&state.db, &slug)
|
let pad = db::find_pad(&state.db, &slug)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(ApiError::not_found_note)?;
|
.ok_or_else(ApiError::not_found_note)?;
|
||||||
ensure_private_resource_access(
|
ensure_private_resource_access(&state, &headers, "pad", &pad.slug, pad.is_private).await?;
|
||||||
|
let access_level = effective_header_access_level(
|
||||||
&state,
|
&state,
|
||||||
|
&headers,
|
||||||
"pad",
|
"pad",
|
||||||
&pad.slug,
|
&pad.slug,
|
||||||
pad.is_private,
|
pad.is_private,
|
||||||
bearer_token(&headers),
|
pad.password_hash.is_some(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let (global_color, note_color) = editor_colors(&state, &headers, "pad", &slug).await?;
|
let (global_color, note_color) = editor_colors(&state, &headers, "pad", &slug).await?;
|
||||||
let (editor_preferences, personal_editor_settings) = user_editor_preferences(
|
let (editor_preferences, personal_editor_settings) =
|
||||||
&state,
|
user_editor_preferences(&state, &headers, db::EditorPreferenceResource::Pad(pad.id))
|
||||||
&headers,
|
.await?;
|
||||||
db::EditorPreferenceResource::Pad(pad.id),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
let resource_editor_settings =
|
let resource_editor_settings =
|
||||||
db::load_resource_editor_settings(&state.db, "pad", &slug).await?;
|
db::load_resource_editor_settings(&state.db, "pad", &slug).await?;
|
||||||
let can_manage_authorship = crate::auth::is_resource_owner(
|
let account_owner =
|
||||||
&state,
|
crate::auth::is_resource_owner(&state, "pad", &slug, user_session_token(&headers))
|
||||||
"pad",
|
.await
|
||||||
&slug,
|
.unwrap_or(false);
|
||||||
user_session_token(&headers),
|
let guest_owner = pad_creator_is_requester(&headers, &pad);
|
||||||
)
|
let password_write_access = has_password_write_access(&state, &headers, "pad", &slug).await?;
|
||||||
.await
|
let can_manage_authorship =
|
||||||
.unwrap_or(false);
|
can_manage_resource_settings(account_owner, guest_owner, password_write_access);
|
||||||
let can_save_editor_settings = personal_editor_settings
|
let upload_max_size_bytes = resource_upload_limit(&state, &headers, "pad", &slug).await?;
|
||||||
|
let can_upload_files = upload_max_size_bytes.is_some();
|
||||||
|
let can_save_editor_settings = (personal_editor_settings || can_manage_authorship)
|
||||||
&& has_write_permission(&state, &headers, "pad", &slug).await?;
|
&& has_write_permission(&state, &headers, "pad", &slug).await?;
|
||||||
if pad.is_private == 0
|
if pad.is_private == 0
|
||||||
|
&& pad.password_hash.is_some()
|
||||||
&& !db::pad_public_page_disabled(&state.db, pad.id).await?
|
&& !db::pad_public_page_disabled(&state.db, pad.id).await?
|
||||||
&& !db::pad_public_page_enabled(&state.db, pad.id).await?
|
&& !db::pad_public_page_enabled(&state.db, pad.id).await?
|
||||||
{
|
{
|
||||||
@@ -129,6 +155,7 @@ pub async fn pad_info(
|
|||||||
slug: pad.slug,
|
slug: pad.slug,
|
||||||
title: pad.title,
|
title: pad.title,
|
||||||
protected: pad.password_hash.is_some(),
|
protected: pad.password_hash.is_some(),
|
||||||
|
access_level: access_level_name(access_level).into(),
|
||||||
allow_public_task_updates: db::pad_public_task_updates(&state.db, pad.id).await?,
|
allow_public_task_updates: db::pad_public_task_updates(&state.db, pad.id).await?,
|
||||||
public_page_unprotected: db::pad_public_page_unprotected(&state.db, pad.id).await?,
|
public_page_unprotected: db::pad_public_page_unprotected(&state.db, pad.id).await?,
|
||||||
public_page_enabled: db::pad_public_page_enabled(&state.db, pad.id).await?,
|
public_page_enabled: db::pad_public_page_enabled(&state.db, pad.id).await?,
|
||||||
@@ -136,6 +163,8 @@ pub async fn pad_info(
|
|||||||
created_at: db::normalize_timestamp(&pad.created_at),
|
created_at: db::normalize_timestamp(&pad.created_at),
|
||||||
updated_at: db::normalize_timestamp(&pad.updated_at),
|
updated_at: db::normalize_timestamp(&pad.updated_at),
|
||||||
can_delete_files: can_manage_authorship,
|
can_delete_files: can_manage_authorship,
|
||||||
|
can_upload_files,
|
||||||
|
upload_max_size_bytes,
|
||||||
global_color,
|
global_color,
|
||||||
note_color,
|
note_color,
|
||||||
authorship_mode: resource_editor_settings.authorship_mode,
|
authorship_mode: resource_editor_settings.authorship_mode,
|
||||||
@@ -144,15 +173,51 @@ pub async fn pad_info(
|
|||||||
editor_line_numbers: editor_preferences.editor_line_numbers,
|
editor_line_numbers: editor_preferences.editor_line_numbers,
|
||||||
preview_line_numbers: editor_preferences.preview_line_numbers,
|
preview_line_numbers: editor_preferences.preview_line_numbers,
|
||||||
line_links: editor_preferences.line_links,
|
line_links: editor_preferences.line_links,
|
||||||
|
toolbar_collapsed: editor_preferences.toolbar_collapsed,
|
||||||
font_family: editor_preferences.font_family,
|
font_family: editor_preferences.font_family,
|
||||||
font_size: editor_preferences.font_size,
|
font_size: editor_preferences.font_size,
|
||||||
personal_editor_settings,
|
personal_editor_settings,
|
||||||
can_save_editor_settings,
|
can_save_editor_settings,
|
||||||
can_manage_authorship,
|
can_manage_authorship,
|
||||||
|
can_set_password: can_set_resource_password(
|
||||||
|
pad.password_hash.is_some(),
|
||||||
|
account_owner,
|
||||||
|
guest_owner,
|
||||||
|
),
|
||||||
files: markdown_file_references(&state, Some(pad.id), None, None).await?,
|
files: markdown_file_references(&state, Some(pad.id), None, None).await?,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn set_pad_password(
|
||||||
|
State(state): State<SharedState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Path(slug): Path<String>,
|
||||||
|
Json(payload): Json<SetPadPasswordRequest>,
|
||||||
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
let pad = db::find_pad(&state.db, &slug)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(ApiError::not_found_note)?;
|
||||||
|
if pad.password_hash.is_some() {
|
||||||
|
return Err(ApiError::bad_request("This note already has a password."));
|
||||||
|
}
|
||||||
|
let account_owner = crate::auth::is_resource_owner(
|
||||||
|
&state, "pad", &slug, user_session_token(&headers),
|
||||||
|
).await.unwrap_or(false);
|
||||||
|
let guest_owner = pad_creator_is_requester(&headers, &pad);
|
||||||
|
if !can_set_resource_password(pad.password_hash.is_some(), account_owner, guest_owner) {
|
||||||
|
return Err(ApiError::forbidden("Only the note owner can set its password."));
|
||||||
|
}
|
||||||
|
let except_client_id =
|
||||||
|
crate::websocket::clean_collaboration_client_id(payload.client_id);
|
||||||
|
let password = validate_password(Some(payload.password.as_str()))?
|
||||||
|
.ok_or_else(|| ApiError::bad_request("Password is required."))?;
|
||||||
|
db::set_pad_password(&state.db, &slug, password).await?;
|
||||||
|
state
|
||||||
|
.notify_pad_password_required(&slug, except_client_id)
|
||||||
|
.await;
|
||||||
|
Ok(Json(serde_json::json!({"ok": true, "protected": true})))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn set_pad_editor_settings(
|
pub async fn set_pad_editor_settings(
|
||||||
State(state): State<SharedState>,
|
State(state): State<SharedState>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
@@ -162,6 +227,11 @@ pub async fn set_pad_editor_settings(
|
|||||||
let pad = db::find_pad(&state.db, &slug)
|
let pad = db::find_pad(&state.db, &slug)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(ApiError::not_found_note)?;
|
.ok_or_else(ApiError::not_found_note)?;
|
||||||
|
let creator_can_manage_authorship = can_manage_resource_settings(
|
||||||
|
false,
|
||||||
|
pad_creator_is_requester(&headers, &pad),
|
||||||
|
has_password_write_access(&state, &headers, "pad", &slug).await?,
|
||||||
|
);
|
||||||
save_editor_settings(
|
save_editor_settings(
|
||||||
&state,
|
&state,
|
||||||
&headers,
|
&headers,
|
||||||
@@ -170,6 +240,7 @@ pub async fn set_pad_editor_settings(
|
|||||||
"pad",
|
"pad",
|
||||||
&slug,
|
&slug,
|
||||||
db::EditorPreferenceResource::Pad(pad.id),
|
db::EditorPreferenceResource::Pad(pad.id),
|
||||||
|
creator_can_manage_authorship,
|
||||||
payload,
|
payload,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -238,8 +309,9 @@ pub async fn publish_pad_page(
|
|||||||
&state,
|
&state,
|
||||||
&slug,
|
&slug,
|
||||||
payload.password.as_deref(),
|
payload.password.as_deref(),
|
||||||
payload.access_token.as_deref(),
|
resource_request_token(&headers, "pad", &slug, payload.access_token.as_deref()),
|
||||||
bearer_token(&headers),
|
bearer_token(&headers),
|
||||||
|
&headers,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let level = if db::verify_pad_password(&pad, payload.password.as_deref())
|
let level = if db::verify_pad_password(&pad, payload.password.as_deref())
|
||||||
@@ -247,17 +319,23 @@ pub async fn publish_pad_page(
|
|||||||
{
|
{
|
||||||
AccessLevel::Write
|
AccessLevel::Write
|
||||||
} else {
|
} else {
|
||||||
combined_token_access_level(
|
request_access_level(
|
||||||
&state,
|
&state,
|
||||||
|
&headers,
|
||||||
"pad",
|
"pad",
|
||||||
&slug,
|
&slug,
|
||||||
payload.access_token.as_deref(),
|
resource_request_token(&headers, "pad", &slug, payload.access_token.as_deref()),
|
||||||
bearer_token(&headers),
|
bearer_token(&headers),
|
||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
};
|
};
|
||||||
require_write(level)?;
|
require_write(level)?;
|
||||||
let enabled = payload.enabled.unwrap_or(true);
|
let enabled = payload.enabled.unwrap_or(true);
|
||||||
|
if enabled && pad.password_hash.is_none() {
|
||||||
|
return Err(ApiError::bad_request(
|
||||||
|
"Set a resource password before enabling the published page.",
|
||||||
|
));
|
||||||
|
}
|
||||||
if !enabled {
|
if !enabled {
|
||||||
db::unpublish_pad(&state.db, pad.id).await?;
|
db::unpublish_pad(&state.db, pad.id).await?;
|
||||||
db::set_pad_public_page_disabled(&state.db, pad.id, true).await?;
|
db::set_pad_public_page_disabled(&state.db, pad.id, true).await?;
|
||||||
@@ -287,8 +365,14 @@ pub async fn publish_note_page(
|
|||||||
&workspace_slug,
|
&workspace_slug,
|
||||||
¬e_slug,
|
¬e_slug,
|
||||||
payload.password.as_deref(),
|
payload.password.as_deref(),
|
||||||
payload.access_token.as_deref(),
|
resource_request_token(
|
||||||
|
&headers,
|
||||||
|
"workspace",
|
||||||
|
&workspace_slug,
|
||||||
|
payload.access_token.as_deref(),
|
||||||
|
),
|
||||||
bearer_token(&headers),
|
bearer_token(&headers),
|
||||||
|
&headers,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let level = if db::verify_workspace_password(&workspace, payload.password.as_deref())
|
let level = if db::verify_workspace_password(&workspace, payload.password.as_deref())
|
||||||
@@ -296,17 +380,28 @@ pub async fn publish_note_page(
|
|||||||
{
|
{
|
||||||
AccessLevel::Write
|
AccessLevel::Write
|
||||||
} else {
|
} else {
|
||||||
combined_token_access_level(
|
request_access_level(
|
||||||
&state,
|
&state,
|
||||||
|
&headers,
|
||||||
"workspace",
|
"workspace",
|
||||||
&workspace_slug,
|
&workspace_slug,
|
||||||
payload.access_token.as_deref(),
|
resource_request_token(
|
||||||
|
&headers,
|
||||||
|
"workspace",
|
||||||
|
&workspace_slug,
|
||||||
|
payload.access_token.as_deref(),
|
||||||
|
),
|
||||||
bearer_token(&headers),
|
bearer_token(&headers),
|
||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
};
|
};
|
||||||
require_write(level)?;
|
require_write(level)?;
|
||||||
let enabled = payload.enabled.unwrap_or(true);
|
let enabled = payload.enabled.unwrap_or(true);
|
||||||
|
if enabled && workspace.password_hash.is_none() {
|
||||||
|
return Err(ApiError::bad_request(
|
||||||
|
"Set a workspace password before enabling the published page.",
|
||||||
|
));
|
||||||
|
}
|
||||||
if !enabled {
|
if !enabled {
|
||||||
db::unpublish_note(&state.db, note.id).await?;
|
db::unpublish_note(&state.db, note.id).await?;
|
||||||
db::set_note_public_page_disabled(&state.db, note.id, true).await?;
|
db::set_note_public_page_disabled(&state.db, note.id, true).await?;
|
||||||
@@ -339,11 +434,8 @@ async fn ensure_public_page_access(
|
|||||||
page: &db::PublishedPage,
|
page: &db::PublishedPage,
|
||||||
) -> Result<(), ApiError> {
|
) -> Result<(), ApiError> {
|
||||||
let password = page_password(headers);
|
let password = page_password(headers);
|
||||||
let bearer = bearer_token(headers);
|
|
||||||
if let Some(pad_id) = page.pad_id {
|
if let Some(pad_id) = page.pad_id {
|
||||||
if db::pad_public_page_unprotected(&state.db, pad_id).await? {
|
let page_unprotected = db::pad_public_page_unprotected(&state.db, pad_id).await?;
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let sql = match state.db.kind() {
|
let sql = match state.db.kind() {
|
||||||
crate::database::DatabaseKind::Postgres => "SELECT slug FROM pads WHERE id = $1",
|
crate::database::DatabaseKind::Postgres => "SELECT slug FROM pads WHERE id = $1",
|
||||||
_ => "SELECT slug FROM pads WHERE id = ?",
|
_ => "SELECT slug FROM pads WHERE id = ?",
|
||||||
@@ -358,10 +450,18 @@ async fn ensure_public_page_access(
|
|||||||
let pad = db::find_pad(&state.db, &slug)
|
let pad = db::find_pad(&state.db, &slug)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(ApiError::not_found_note)?;
|
.ok_or_else(ApiError::not_found_note)?;
|
||||||
if db::verify_pad_password(&pad, password) {
|
if pad.password_hash.is_none() {
|
||||||
|
return Err(ApiError::forbidden(
|
||||||
|
"This published page is unavailable until a resource password is set.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if page_unprotected || has_header_resource_access(state, headers, "pad", &slug).await? {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
if verify_resource_access_token(state, "pad", &slug, bearer).await? {
|
let password_ok = db::verify_pad_password(&pad, password);
|
||||||
|
check_resource_password_attempt(state, headers, "pad", &slug, password, password_ok)
|
||||||
|
.await?;
|
||||||
|
if password_ok {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
return if pad.password_hash.is_some() {
|
return if pad.password_hash.is_some() {
|
||||||
@@ -371,9 +471,7 @@ async fn ensure_public_page_access(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
if let Some(note_id) = page.note_id {
|
if let Some(note_id) = page.note_id {
|
||||||
if db::note_public_page_unprotected(&state.db, note_id).await? {
|
let page_unprotected = db::note_public_page_unprotected(&state.db, note_id).await?;
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let sql = match state.db.kind() {
|
let sql = match state.db.kind() {
|
||||||
crate::database::DatabaseKind::Postgres => {
|
crate::database::DatabaseKind::Postgres => {
|
||||||
"SELECT w.slug FROM notes n JOIN workspaces w ON w.id = n.workspace_id WHERE n.id = $1"
|
"SELECT w.slug FROM notes n JOIN workspaces w ON w.id = n.workspace_id WHERE n.id = $1"
|
||||||
@@ -392,10 +490,20 @@ async fn ensure_public_page_access(
|
|||||||
let workspace = db::find_workspace(&state.db, &slug)
|
let workspace = db::find_workspace(&state.db, &slug)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(ApiError::not_found_workspace)?;
|
.ok_or_else(ApiError::not_found_workspace)?;
|
||||||
if db::verify_workspace_password(&workspace, password) {
|
if workspace.password_hash.is_none() {
|
||||||
|
return Err(ApiError::forbidden(
|
||||||
|
"This published page is unavailable until a workspace password is set.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if page_unprotected
|
||||||
|
|| has_header_resource_access(state, headers, "workspace", &slug).await?
|
||||||
|
{
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
if verify_resource_access_token(state, "workspace", &slug, bearer).await? {
|
let password_ok = db::verify_workspace_password(&workspace, password);
|
||||||
|
check_resource_password_attempt(state, headers, "workspace", &slug, password, password_ok)
|
||||||
|
.await?;
|
||||||
|
if password_ok {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
return if workspace.password_hash.is_some() {
|
return if workspace.password_hash.is_some() {
|
||||||
@@ -416,13 +524,8 @@ pub async fn public_page(
|
|||||||
.await?
|
.await?
|
||||||
.ok_or_else(ApiError::not_found_note)?;
|
.ok_or_else(ApiError::not_found_note)?;
|
||||||
ensure_public_page_access(&state, &headers, &page).await?;
|
ensure_public_page_access(&state, &headers, &page).await?;
|
||||||
let files = markdown_file_references(
|
let files =
|
||||||
&state,
|
markdown_file_references(&state, page.pad_id, page.note_id, Some(&page.content)).await?;
|
||||||
page.pad_id,
|
|
||||||
page.note_id,
|
|
||||||
Some(&page.content),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(Json(PublicPageResponse {
|
Ok(Json(PublicPageResponse {
|
||||||
title: page.title,
|
title: page.title,
|
||||||
content: page.content,
|
content: page.content,
|
||||||
@@ -432,6 +535,144 @@ pub async fn public_page(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn commit_public_pad_task_update(
|
||||||
|
state: &SharedState,
|
||||||
|
page: &db::PublishedPage,
|
||||||
|
source_line: usize,
|
||||||
|
checked: bool,
|
||||||
|
) -> Result<(), ApiError> {
|
||||||
|
let pad_id = page.pad_id.ok_or_else(ApiError::not_found_note)?;
|
||||||
|
let room_key = crate::state::AppState::pad_room_key(&page.resource_slug);
|
||||||
|
let channel = state.pad_channel(&page.resource_slug).await;
|
||||||
|
let collaboration_snapshot = db::pad_collaboration_snapshot(&state.db, pad_id).await?;
|
||||||
|
let collaborative_document = state
|
||||||
|
.collaborative_document(
|
||||||
|
&room_key,
|
||||||
|
collaboration_snapshot.content,
|
||||||
|
collaboration_snapshot.owner_map,
|
||||||
|
collaboration_snapshot.revision_id,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let mut document = collaborative_document.lock().await;
|
||||||
|
let Some(next_content) =
|
||||||
|
db::updated_public_task_content(&document.content, source_line, checked)
|
||||||
|
else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let base_revision_id = document.revision_id;
|
||||||
|
let operation =
|
||||||
|
collab::operation_from_edit(&document.content, &next_content, &document.owner_map);
|
||||||
|
let (content, owner_map) = collab::apply_operation_to_document(
|
||||||
|
&document.content,
|
||||||
|
&document.owner_map,
|
||||||
|
&operation,
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.map_err(|_| ApiError::bad_request("The task could not be updated"))?;
|
||||||
|
let (revision_id, updated_at) =
|
||||||
|
db::save_pad_revision(&state.db, pad_id, &content, Some("public"), &owner_map).await?;
|
||||||
|
let update_id = u64::try_from(revision_id).unwrap_or_default().max(1);
|
||||||
|
document.content.clone_from(&content);
|
||||||
|
document.owner_map.clone_from(&owner_map);
|
||||||
|
document.revision_id = revision_id;
|
||||||
|
document.record(AppliedOperation {
|
||||||
|
base_revision_id,
|
||||||
|
revision_id,
|
||||||
|
client_id: "public_task".into(),
|
||||||
|
update_id,
|
||||||
|
operation: operation.clone(),
|
||||||
|
owner_replacements: Vec::new(),
|
||||||
|
});
|
||||||
|
let _ = channel.send(RoomEvent::Document(NoteUpdate {
|
||||||
|
base_revision_id,
|
||||||
|
revision_id,
|
||||||
|
updated_at,
|
||||||
|
author: Some("public".into()),
|
||||||
|
client_id: "public_task".into(),
|
||||||
|
update_id,
|
||||||
|
operation,
|
||||||
|
owner_replacements: Vec::new(),
|
||||||
|
}));
|
||||||
|
drop(document);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn commit_public_note_task_update(
|
||||||
|
state: &SharedState,
|
||||||
|
page: &db::PublishedPage,
|
||||||
|
source_line: usize,
|
||||||
|
checked: bool,
|
||||||
|
) -> Result<(), ApiError> {
|
||||||
|
let note_id = page.note_id.ok_or_else(ApiError::not_found_note)?;
|
||||||
|
let workspace_id = page.workspace_id.ok_or_else(ApiError::not_found_note)?;
|
||||||
|
let workspace_slug = page
|
||||||
|
.workspace_slug
|
||||||
|
.as_deref()
|
||||||
|
.ok_or_else(ApiError::not_found_note)?;
|
||||||
|
let room_key = crate::state::AppState::note_room_key(workspace_slug, &page.resource_slug);
|
||||||
|
let channel = state
|
||||||
|
.note_channel(workspace_slug, &page.resource_slug)
|
||||||
|
.await;
|
||||||
|
let collaboration_snapshot = db::note_collaboration_snapshot(&state.db, note_id).await?;
|
||||||
|
let collaborative_document = state
|
||||||
|
.collaborative_document(
|
||||||
|
&room_key,
|
||||||
|
collaboration_snapshot.content,
|
||||||
|
collaboration_snapshot.owner_map,
|
||||||
|
collaboration_snapshot.revision_id,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let mut document = collaborative_document.lock().await;
|
||||||
|
let Some(next_content) =
|
||||||
|
db::updated_public_task_content(&document.content, source_line, checked)
|
||||||
|
else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let base_revision_id = document.revision_id;
|
||||||
|
let operation =
|
||||||
|
collab::operation_from_edit(&document.content, &next_content, &document.owner_map);
|
||||||
|
let (content, owner_map) = collab::apply_operation_to_document(
|
||||||
|
&document.content,
|
||||||
|
&document.owner_map,
|
||||||
|
&operation,
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.map_err(|_| ApiError::bad_request("The task could not be updated"))?;
|
||||||
|
let (revision_id, updated_at) = db::save_revision(
|
||||||
|
&state.db,
|
||||||
|
note_id,
|
||||||
|
workspace_id,
|
||||||
|
&content,
|
||||||
|
Some("public"),
|
||||||
|
&owner_map,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let update_id = u64::try_from(revision_id).unwrap_or_default().max(1);
|
||||||
|
document.content.clone_from(&content);
|
||||||
|
document.owner_map.clone_from(&owner_map);
|
||||||
|
document.revision_id = revision_id;
|
||||||
|
document.record(AppliedOperation {
|
||||||
|
base_revision_id,
|
||||||
|
revision_id,
|
||||||
|
client_id: "public_task".into(),
|
||||||
|
update_id,
|
||||||
|
operation: operation.clone(),
|
||||||
|
owner_replacements: Vec::new(),
|
||||||
|
});
|
||||||
|
let _ = channel.send(RoomEvent::Document(NoteUpdate {
|
||||||
|
base_revision_id,
|
||||||
|
revision_id,
|
||||||
|
updated_at,
|
||||||
|
author: Some("public".into()),
|
||||||
|
client_id: "public_task".into(),
|
||||||
|
update_id,
|
||||||
|
operation,
|
||||||
|
owner_replacements: Vec::new(),
|
||||||
|
}));
|
||||||
|
drop(document);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn update_public_task(
|
pub async fn update_public_task(
|
||||||
State(state): State<SharedState>,
|
State(state): State<SharedState>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
@@ -447,16 +688,18 @@ pub async fn update_public_task(
|
|||||||
"Task updates are disabled for this page",
|
"Task updates are disabled for this page",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let page = db::update_public_task(&state.db, &token, payload.source_line, payload.checked)
|
if current.pad_id.is_some() {
|
||||||
|
commit_public_pad_task_update(&state, ¤t, payload.source_line, payload.checked)
|
||||||
|
.await?;
|
||||||
|
} else {
|
||||||
|
commit_public_note_task_update(&state, ¤t, payload.source_line, payload.checked)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
let page = db::find_published_page(&state.db, &token)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(ApiError::not_found_note)?;
|
.ok_or_else(ApiError::not_found_note)?;
|
||||||
let files = markdown_file_references(
|
let files =
|
||||||
&state,
|
markdown_file_references(&state, page.pad_id, page.note_id, Some(&page.content)).await?;
|
||||||
page.pad_id,
|
|
||||||
page.note_id,
|
|
||||||
Some(&page.content),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(Json(PublicPageResponse {
|
Ok(Json(PublicPageResponse {
|
||||||
title: page.title,
|
title: page.title,
|
||||||
content: page.content,
|
content: page.content,
|
||||||
@@ -476,8 +719,9 @@ pub async fn pad_history(
|
|||||||
&state,
|
&state,
|
||||||
&slug,
|
&slug,
|
||||||
payload.password.as_deref(),
|
payload.password.as_deref(),
|
||||||
payload.access_token.as_deref(),
|
resource_request_token(&headers, "pad", &slug, payload.access_token.as_deref()),
|
||||||
bearer_token(&headers),
|
bearer_token(&headers),
|
||||||
|
&headers,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let revisions = db::list_pad_revisions(&state.db, pad.id)
|
let revisions = db::list_pad_revisions(&state.db, pad.id)
|
||||||
@@ -501,8 +745,9 @@ pub async fn pad_restore(
|
|||||||
&state,
|
&state,
|
||||||
&slug,
|
&slug,
|
||||||
payload.password.as_deref(),
|
payload.password.as_deref(),
|
||||||
payload.access_token.as_deref(),
|
resource_request_token(&headers, "pad", &slug, payload.access_token.as_deref()),
|
||||||
bearer_token(&headers),
|
bearer_token(&headers),
|
||||||
|
&headers,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let level = if db::verify_pad_password(&pad, payload.password.as_deref())
|
let level = if db::verify_pad_password(&pad, payload.password.as_deref())
|
||||||
@@ -510,11 +755,12 @@ pub async fn pad_restore(
|
|||||||
{
|
{
|
||||||
AccessLevel::Write
|
AccessLevel::Write
|
||||||
} else {
|
} else {
|
||||||
combined_token_access_level(
|
request_access_level(
|
||||||
&state,
|
&state,
|
||||||
|
&headers,
|
||||||
"pad",
|
"pad",
|
||||||
&slug,
|
&slug,
|
||||||
payload.access_token.as_deref(),
|
resource_request_token(&headers, "pad", &slug, payload.access_token.as_deref()),
|
||||||
bearer_token(&headers),
|
bearer_token(&headers),
|
||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
@@ -533,19 +779,59 @@ pub async fn pad_restore(
|
|||||||
.fetch_optional(state.db.pool())
|
.fetch_optional(state.db.pool())
|
||||||
.await?;
|
.await?;
|
||||||
let owner_map = owner_map.unwrap_or_else(|| "[]".into());
|
let owner_map = owner_map.unwrap_or_else(|| "[]".into());
|
||||||
|
let room_key = crate::state::AppState::pad_room_key(&slug);
|
||||||
|
let channel = state.pad_channel(&slug).await;
|
||||||
|
let collaboration_snapshot = db::pad_collaboration_snapshot(&state.db, pad.id).await?;
|
||||||
|
let collaborative_document = state
|
||||||
|
.collaborative_document(
|
||||||
|
&room_key,
|
||||||
|
collaboration_snapshot.content,
|
||||||
|
collaboration_snapshot.owner_map,
|
||||||
|
collaboration_snapshot.revision_id,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let mut document = collaborative_document.lock().await;
|
||||||
|
let base_revision_id = document.revision_id;
|
||||||
|
let restored_owners = collab::owner_spans_from_map(&content, &owner_map);
|
||||||
|
let operation = collab::replace_operation(
|
||||||
|
document.content.encode_utf16().count(),
|
||||||
|
content,
|
||||||
|
restored_owners,
|
||||||
|
);
|
||||||
|
let (content, owner_map) = collab::apply_operation_to_document(
|
||||||
|
&document.content,
|
||||||
|
&document.owner_map,
|
||||||
|
&operation,
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.map_err(|_| ApiError::bad_request("The selected revision could not be restored"))?;
|
||||||
let (revision_id, updated_at) =
|
let (revision_id, updated_at) =
|
||||||
db::save_pad_revision(&state.db, pad.id, &content, Some("restore"), &owner_map).await?;
|
db::save_pad_revision(&state.db, pad.id, &content, Some("restore"), &owner_map).await?;
|
||||||
|
let update_id = u64::try_from(revision_id).unwrap_or_default().max(1);
|
||||||
|
let applied = AppliedOperation {
|
||||||
|
base_revision_id,
|
||||||
|
revision_id,
|
||||||
|
client_id: "server_restore".into(),
|
||||||
|
update_id,
|
||||||
|
operation: operation.clone(),
|
||||||
|
owner_replacements: Vec::new(),
|
||||||
|
};
|
||||||
|
document.content.clone_from(&content);
|
||||||
|
document.owner_map.clone_from(&owner_map);
|
||||||
|
document.revision_id = revision_id;
|
||||||
|
document.record(applied);
|
||||||
let update = NoteUpdate {
|
let update = NoteUpdate {
|
||||||
content,
|
base_revision_id,
|
||||||
revision_id,
|
revision_id,
|
||||||
updated_at,
|
updated_at,
|
||||||
author: Some("restore".into()),
|
author: Some("restore".into()),
|
||||||
owner_map,
|
client_id: "server_restore".into(),
|
||||||
|
update_id,
|
||||||
|
operation,
|
||||||
|
owner_replacements: Vec::new(),
|
||||||
};
|
};
|
||||||
let _ = state
|
let _ = channel.send(RoomEvent::Document(update));
|
||||||
.pad_channel(&slug)
|
drop(document);
|
||||||
.await
|
|
||||||
.send(RoomEvent::Document(update));
|
|
||||||
Ok(Json(serde_json::json!({"ok": true})))
|
Ok(Json(serde_json::json!({"ok": true})))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -555,19 +841,22 @@ pub(super) async fn authorized_pad(
|
|||||||
password: Option<&str>,
|
password: Option<&str>,
|
||||||
access_token: Option<&str>,
|
access_token: Option<&str>,
|
||||||
bearer: Option<&str>,
|
bearer: Option<&str>,
|
||||||
|
headers: &HeaderMap,
|
||||||
) -> Result<db::Pad, ApiError> {
|
) -> Result<db::Pad, ApiError> {
|
||||||
let pad = db::find_pad(&state.db, slug)
|
let pad = db::find_pad(&state.db, slug)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(ApiError::not_found_note)?;
|
.ok_or_else(ApiError::not_found_note)?;
|
||||||
let token_level = combined_token_access_level(state, "pad", slug, access_token, bearer).await?;
|
let token_level =
|
||||||
|
request_access_level(state, headers, "pad", slug, access_token, bearer).await?;
|
||||||
if pad.is_private != 0 && token_level == AccessLevel::None {
|
if pad.is_private != 0 && token_level == AccessLevel::None {
|
||||||
return Err(ApiError::not_found_note());
|
return Err(ApiError::not_found_note());
|
||||||
}
|
}
|
||||||
if pad.password_hash.is_some()
|
if pad.password_hash.is_some() && token_level < AccessLevel::Write {
|
||||||
&& !db::verify_pad_password(&pad, password)
|
let password_ok = db::verify_pad_password(&pad, password);
|
||||||
&& token_level == AccessLevel::None
|
check_resource_password_attempt(state, headers, "pad", slug, password, password_ok).await?;
|
||||||
{
|
if token_level == AccessLevel::None && !password_ok {
|
||||||
return Err(ApiError::forbidden("Password required or incorrect."));
|
return Err(ApiError::forbidden("Password required or incorrect."));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(pad)
|
Ok(pad)
|
||||||
}
|
}
|
||||||
|
|||||||
+146
-56
@@ -10,24 +10,43 @@
|
|||||||
mod pages;
|
mod pages;
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
Router,
|
Json, Router,
|
||||||
extract::{DefaultBodyLimit, Request},
|
extract::{DefaultBodyLimit, Request},
|
||||||
http::{HeaderName, HeaderValue, StatusCode, header},
|
http::{HeaderMap, HeaderValue, Method, StatusCode, header},
|
||||||
middleware::{self, Next},
|
middleware::{self, Next},
|
||||||
response::Response,
|
response::{IntoResponse, Response},
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
};
|
};
|
||||||
use pages::*;
|
use pages::*;
|
||||||
use tower::{ServiceBuilder, service_fn};
|
use tower::{ServiceBuilder, service_fn};
|
||||||
use tower_http::{services::ServeDir, set_header::SetResponseHeaderLayer, trace::TraceLayer};
|
use tower_http::{
|
||||||
|
services::ServeDir,
|
||||||
|
set_header::SetResponseHeaderLayer,
|
||||||
|
trace::{MakeSpan, TraceLayer},
|
||||||
|
};
|
||||||
|
use tracing::Span;
|
||||||
|
|
||||||
use crate::{api, auth, state::SharedState, websocket};
|
use crate::{api, auth, state::SharedState, websocket};
|
||||||
use std::convert::Infallible;
|
use std::convert::Infallible;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct PathOnlyMakeSpan;
|
||||||
|
|
||||||
|
impl<B> MakeSpan<B> for PathOnlyMakeSpan {
|
||||||
|
fn make_span(&mut self, request: &axum::http::Request<B>) -> Span {
|
||||||
|
tracing::info_span!(
|
||||||
|
"http_request",
|
||||||
|
method = %request.method(),
|
||||||
|
path = %request.uri().path(),
|
||||||
|
version = ?request.version(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn router(
|
pub fn router(
|
||||||
state: SharedState,
|
state: SharedState,
|
||||||
static_dir: &str,
|
static_dir: &str,
|
||||||
upload_max_size_bytes: usize,
|
upload_body_limit_bytes: usize,
|
||||||
asset_cache_max_age_seconds: u64,
|
asset_cache_max_age_seconds: u64,
|
||||||
) -> Router {
|
) -> Router {
|
||||||
let asset_version = state.asset_version.clone();
|
let asset_version = state.asset_version.clone();
|
||||||
@@ -46,10 +65,9 @@ pub fn router(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let asset_cache_control = HeaderValue::from_str(&crate::cache::cache_control(
|
let asset_cache_control =
|
||||||
asset_cache_max_age_seconds,
|
HeaderValue::from_str(&crate::cache::cache_control(asset_cache_max_age_seconds))
|
||||||
))
|
.expect("valid asset cache-control header");
|
||||||
.expect("valid asset cache-control header");
|
|
||||||
|
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/", get(home))
|
.route("/", get(home))
|
||||||
@@ -63,12 +81,19 @@ pub fn router(
|
|||||||
.route("/health", get(health))
|
.route("/health", get(health))
|
||||||
.route("/robots.txt", get(robots_txt))
|
.route("/robots.txt", get(robots_txt))
|
||||||
.route("/favicon.ico", get(favicon))
|
.route("/favicon.ico", get(favicon))
|
||||||
|
.route("/favicon.svg", get(favicon_svg))
|
||||||
|
.route("/favicon-32.png", get(favicon_png))
|
||||||
|
.route("/apple-touch-icon.png", get(apple_touch_icon))
|
||||||
|
.route("/icons/favicon.ico", get(favicon))
|
||||||
|
.route("/icons/favicon.svg", get(favicon_svg))
|
||||||
|
.route("/icons/favicon-32.png", get(favicon_png))
|
||||||
|
.route("/icons/apple-touch-icon.png", get(apple_touch_icon))
|
||||||
.route("/f/{token}/{filename}", get(api::download_file))
|
.route("/f/{token}/{filename}", get(api::download_file))
|
||||||
.route(
|
|
||||||
"/files/{directory}/{filename}",
|
|
||||||
get(api::download_legacy_file),
|
|
||||||
)
|
|
||||||
.route("/api/auth/identity", post(auth::identity))
|
.route("/api/auth/identity", post(auth::identity))
|
||||||
|
.route(
|
||||||
|
"/api/security/csrf",
|
||||||
|
get(crate::security::csrf_token_endpoint),
|
||||||
|
)
|
||||||
.route("/api/access-token", post(api::create_resource_access_token))
|
.route("/api/access-token", post(api::create_resource_access_token))
|
||||||
.route("/api/auth/register", post(auth::register))
|
.route("/api/auth/register", post(auth::register))
|
||||||
.route("/api/auth/login", post(auth::login))
|
.route("/api/auth/login", post(auth::login))
|
||||||
@@ -133,6 +158,7 @@ pub fn router(
|
|||||||
post(api::set_pad_editor_settings),
|
post(api::set_pad_editor_settings),
|
||||||
)
|
)
|
||||||
.route("/api/pads/{slug}/publish", post(api::publish_pad_page))
|
.route("/api/pads/{slug}/publish", post(api::publish_pad_page))
|
||||||
|
.route("/api/pads/{slug}/password", post(api::set_pad_password))
|
||||||
.route("/api/pads/{slug}/restore", post(api::pad_restore))
|
.route("/api/pads/{slug}/restore", post(api::pad_restore))
|
||||||
.route(
|
.route(
|
||||||
"/api/pads/{slug}/files",
|
"/api/pads/{slug}/files",
|
||||||
@@ -144,6 +170,10 @@ pub fn router(
|
|||||||
)
|
)
|
||||||
.route("/api/workspaces", post(api::create_workspace))
|
.route("/api/workspaces", post(api::create_workspace))
|
||||||
.route("/api/workspaces/{workspace_slug}", get(api::workspace_info))
|
.route("/api/workspaces/{workspace_slug}", get(api::workspace_info))
|
||||||
|
.route(
|
||||||
|
"/api/workspaces/{workspace_slug}/password",
|
||||||
|
post(api::set_workspace_password),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/workspaces/{workspace_slug}/open",
|
"/api/workspaces/{workspace_slug}/open",
|
||||||
post(api::open_workspace),
|
post(api::open_workspace),
|
||||||
@@ -185,6 +215,10 @@ pub fn router(
|
|||||||
axum::routing::delete(api::delete_note_file),
|
axum::routing::delete(api::delete_note_file),
|
||||||
)
|
)
|
||||||
.route("/ws/p/{slug}", get(websocket::upgrade_pad))
|
.route("/ws/p/{slug}", get(websocket::upgrade_pad))
|
||||||
|
.route(
|
||||||
|
"/ws/watch/workspace/{workspace_slug}",
|
||||||
|
get(websocket::upgrade_workspace_watch),
|
||||||
|
)
|
||||||
.route("/ws/{workspace_slug}/{note_slug}", get(websocket::upgrade))
|
.route("/ws/{workspace_slug}/{note_slug}", get(websocket::upgrade))
|
||||||
.route("/static", get(static_not_found))
|
.route("/static", get(static_not_found))
|
||||||
.route("/static/{*path}", get(static_not_found))
|
.route("/static/{*path}", get(static_not_found))
|
||||||
@@ -199,53 +233,109 @@ pub fn router(
|
|||||||
)
|
)
|
||||||
.fallback(not_found)
|
.fallback(not_found)
|
||||||
.method_not_allowed_fallback(method_not_allowed)
|
.method_not_allowed_fallback(method_not_allowed)
|
||||||
.layer(DefaultBodyLimit::max(
|
.layer(DefaultBodyLimit::max(upload_body_limit_bytes))
|
||||||
upload_max_size_bytes.saturating_add(1024 * 1024),
|
.layer(TraceLayer::new_for_http().make_span_with(PathOnlyMakeSpan))
|
||||||
))
|
.layer(middleware::from_fn(require_csrf_token))
|
||||||
.layer(SetResponseHeaderLayer::if_not_present(
|
.layer(middleware::from_fn(apply_response_header_policy))
|
||||||
HeaderName::from_static("x-frame-options"),
|
|
||||||
HeaderValue::from_static("DENY"),
|
|
||||||
))
|
|
||||||
.layer(SetResponseHeaderLayer::if_not_present(
|
|
||||||
HeaderName::from_static("cross-origin-opener-policy"),
|
|
||||||
HeaderValue::from_static("same-origin"),
|
|
||||||
))
|
|
||||||
.layer(SetResponseHeaderLayer::if_not_present(
|
|
||||||
HeaderName::from_static("cross-origin-resource-policy"),
|
|
||||||
HeaderValue::from_static("same-origin"),
|
|
||||||
))
|
|
||||||
.layer(TraceLayer::new_for_http())
|
|
||||||
.layer(middleware::from_fn(add_non_asset_security_headers))
|
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn add_non_asset_security_headers(request: Request, next: Next) -> Response {
|
async fn require_csrf_token(request: Request, next: Next) -> Response {
|
||||||
let path = request.uri().path();
|
let method = request.method();
|
||||||
let is_asset = path.starts_with("/assets/");
|
let unsafe_method = method == Method::POST
|
||||||
let is_file = path.starts_with("/f/");
|
|| method == Method::PUT
|
||||||
let mut response = next.run(request).await;
|
|| method == Method::PATCH
|
||||||
|
|| method == Method::DELETE;
|
||||||
if is_file {
|
if unsafe_method && !crate::security::csrf_request_is_valid(request.headers()) {
|
||||||
let headers = response.headers_mut();
|
return (
|
||||||
headers.remove("x-frame-options");
|
StatusCode::FORBIDDEN,
|
||||||
headers.remove("cross-origin-opener-policy");
|
Json(serde_json::json!({
|
||||||
headers.remove("cross-origin-resource-policy");
|
"error": "Security token is missing or expired. Refresh the page and try again."
|
||||||
headers.remove("referrer-policy");
|
})),
|
||||||
headers.remove("permissions-policy");
|
)
|
||||||
} else if !is_asset {
|
.into_response();
|
||||||
let headers = response.headers_mut();
|
|
||||||
headers
|
|
||||||
.entry("x-content-type-options")
|
|
||||||
.or_insert(HeaderValue::from_static("nosniff"));
|
|
||||||
headers
|
|
||||||
.entry("referrer-policy")
|
|
||||||
.or_insert(HeaderValue::from_static("strict-origin-when-cross-origin"));
|
|
||||||
headers
|
|
||||||
.entry("permissions-policy")
|
|
||||||
.or_insert(HeaderValue::from_static(
|
|
||||||
"camera=(), microphone=(), geolocation=(), payment=(), usb=()",
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
next.run(request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn apply_response_header_policy(request: Request, next: Next) -> Response {
|
||||||
|
let policy = response_header_policy(request.uri().path());
|
||||||
|
let mut response = next.run(request).await;
|
||||||
|
apply_response_headers(policy, response.headers_mut());
|
||||||
response
|
response
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn apply_response_headers(policy: ResponseHeaderPolicy, headers: &mut HeaderMap) {
|
||||||
|
match policy {
|
||||||
|
ResponseHeaderPolicy::StaticAsset | ResponseHeaderPolicy::File => {
|
||||||
|
headers
|
||||||
|
.entry(header::X_CONTENT_TYPE_OPTIONS)
|
||||||
|
.or_insert(HeaderValue::from_static("nosniff"));
|
||||||
|
}
|
||||||
|
ResponseHeaderPolicy::Application => {
|
||||||
|
headers
|
||||||
|
.entry("x-frame-options")
|
||||||
|
.or_insert(HeaderValue::from_static("DENY"));
|
||||||
|
|
||||||
|
headers
|
||||||
|
.entry("cross-origin-opener-policy")
|
||||||
|
.or_insert(HeaderValue::from_static("same-origin"));
|
||||||
|
|
||||||
|
headers
|
||||||
|
.entry("cross-origin-resource-policy")
|
||||||
|
.or_insert(HeaderValue::from_static("same-origin"));
|
||||||
|
|
||||||
|
headers
|
||||||
|
.entry(header::X_CONTENT_TYPE_OPTIONS)
|
||||||
|
.or_insert(HeaderValue::from_static("nosniff"));
|
||||||
|
|
||||||
|
headers
|
||||||
|
.entry("referrer-policy")
|
||||||
|
.or_insert(HeaderValue::from_static("strict-origin-when-cross-origin"));
|
||||||
|
|
||||||
|
headers
|
||||||
|
.entry("permissions-policy")
|
||||||
|
.or_insert(HeaderValue::from_static(
|
||||||
|
"camera=(), microphone=(), geolocation=(), payment=(), usb=()",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
enum ResponseHeaderPolicy {
|
||||||
|
Application,
|
||||||
|
StaticAsset,
|
||||||
|
File,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn response_header_policy(path: &str) -> ResponseHeaderPolicy {
|
||||||
|
if is_file_path(path) {
|
||||||
|
ResponseHeaderPolicy::File
|
||||||
|
} else if is_asset_path(path) || is_icon_path(path) {
|
||||||
|
ResponseHeaderPolicy::StaticAsset
|
||||||
|
} else {
|
||||||
|
ResponseHeaderPolicy::Application
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_file_path(path: &str) -> bool {
|
||||||
|
path == "/f" || path.starts_with("/f/")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_asset_path(path: &str) -> bool {
|
||||||
|
path == "/assets" || path.starts_with("/assets/")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_icon_path(path: &str) -> bool {
|
||||||
|
path == "/icons"
|
||||||
|
|| path.starts_with("/icons/")
|
||||||
|
|| matches!(
|
||||||
|
path,
|
||||||
|
"/favicon.svg" | "/favicon.ico" | "/favicon-32.png" | "/apple-touch-icon.png"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../tests/app.rs"]
|
||||||
|
mod tests;
|
||||||
|
|||||||
+283
-29
@@ -8,12 +8,122 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{OriginalUri, Path, RawQuery, State},
|
||||||
http::{HeaderValue, StatusCode, header},
|
http::{HeaderMap, HeaderValue, StatusCode, Uri, header},
|
||||||
response::{Html, IntoResponse, Response},
|
response::{Html, IntoResponse, Response},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{assets, db, state::SharedState};
|
use crate::{assets, auth, db, state::SharedState};
|
||||||
|
|
||||||
|
fn decode_query_component(value: &str) -> Option<String> {
|
||||||
|
let bytes = value.as_bytes();
|
||||||
|
let mut decoded = Vec::with_capacity(bytes.len());
|
||||||
|
let mut index = 0;
|
||||||
|
while index < bytes.len() {
|
||||||
|
match bytes[index] {
|
||||||
|
b'%' if index + 2 < bytes.len() => {
|
||||||
|
let high = (bytes[index + 1] as char).to_digit(16)? as u8;
|
||||||
|
let low = (bytes[index + 2] as char).to_digit(16)? as u8;
|
||||||
|
decoded.push((high << 4) | low);
|
||||||
|
index += 3;
|
||||||
|
}
|
||||||
|
b'%' => return None,
|
||||||
|
b'+' => {
|
||||||
|
decoded.push(b' ');
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
byte => {
|
||||||
|
decoded.push(byte);
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String::from_utf8(decoded).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn share_token_from_query(query: Option<&str>) -> (bool, Option<String>) {
|
||||||
|
let Some(query) = query else {
|
||||||
|
return (false, None);
|
||||||
|
};
|
||||||
|
for field in query.split('&') {
|
||||||
|
let (name, value) = field.split_once('=').unwrap_or((field, ""));
|
||||||
|
if decode_query_component(name).as_deref() == Some("share") {
|
||||||
|
return (true, decode_query_component(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(false, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn canonical_resource_url(uri: &Uri) -> String {
|
||||||
|
let remaining_query = uri.query().map(|query| {
|
||||||
|
query
|
||||||
|
.split('&')
|
||||||
|
.filter(|field| {
|
||||||
|
let name = field.split_once('=').map_or(*field, |(name, _)| name);
|
||||||
|
decode_query_component(name).as_deref() != Some("share")
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("&")
|
||||||
|
});
|
||||||
|
match remaining_query.as_deref().filter(|query| !query.is_empty()) {
|
||||||
|
Some(query) => format!("{}?{query}", uri.path()),
|
||||||
|
None => uri.path().to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn share_session_redirect(
|
||||||
|
state: &SharedState,
|
||||||
|
headers: &HeaderMap,
|
||||||
|
uri: &Uri,
|
||||||
|
kind: &str,
|
||||||
|
slug: &str,
|
||||||
|
share: Option<&str>,
|
||||||
|
) -> Response {
|
||||||
|
let client_key = crate::security::client_key(headers);
|
||||||
|
let cookie = match share.map(str::trim).filter(|value| !value.is_empty()) {
|
||||||
|
Some(share) => match auth::create_share_session(
|
||||||
|
state,
|
||||||
|
kind,
|
||||||
|
slug,
|
||||||
|
share,
|
||||||
|
&client_key,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Some(session)) => Some(crate::security::share_session_cookie(
|
||||||
|
kind,
|
||||||
|
slug,
|
||||||
|
&session.token,
|
||||||
|
session.max_age_seconds,
|
||||||
|
)),
|
||||||
|
Ok(None) => None,
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!(error = %error.message, kind, slug, "failed to exchange share link for guest session");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut response = StatusCode::SEE_OTHER.into_response();
|
||||||
|
response.headers_mut().insert(
|
||||||
|
header::LOCATION,
|
||||||
|
HeaderValue::from_str(&canonical_resource_url(uri))
|
||||||
|
.expect("request URI is a valid redirect location"),
|
||||||
|
);
|
||||||
|
if let Some(cookie) = cookie {
|
||||||
|
response.headers_mut().insert(header::SET_COOKIE, cookie);
|
||||||
|
}
|
||||||
|
response.headers_mut().insert(
|
||||||
|
header::CACHE_CONTROL,
|
||||||
|
HeaderValue::from_static("no-store, max-age=0"),
|
||||||
|
);
|
||||||
|
response.headers_mut().insert(
|
||||||
|
"referrer-policy",
|
||||||
|
HeaderValue::from_static("no-referrer"),
|
||||||
|
);
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
fn render_editor_page(
|
fn render_editor_page(
|
||||||
state: &SharedState,
|
state: &SharedState,
|
||||||
@@ -26,8 +136,25 @@ fn render_editor_page(
|
|||||||
protected_resource_label: &str,
|
protected_resource_label: &str,
|
||||||
extra_shortcuts: &str,
|
extra_shortcuts: &str,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
|
let (resource_aria_label, resource_breadcrumb) = if resource_kind == "note" {
|
||||||
|
(
|
||||||
|
"RustPad Workspace Note",
|
||||||
|
format!(
|
||||||
|
r#"<a id="resource-parent-link" class="resource-brand__kind" href="{}">Workspace</a><span class="resource-brand__separator" aria-hidden="true">/</span><span class="resource-brand__kind resource-brand__kind--current" aria-current="page">Note</span>"#,
|
||||||
|
escape_html(parent_url)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
"RustPad Note",
|
||||||
|
r#"<span class="resource-brand__kind resource-brand__kind--current" aria-current="page">Note</span>"#.to_string(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
let html = include_str!("../../static/editor.html")
|
let html = include_str!("../../static/editor.html")
|
||||||
.replace("__RESOURCE_KIND__", resource_kind)
|
.replace("__RESOURCE_KIND__", resource_kind)
|
||||||
|
.replace("__RESOURCE_ARIA_LABEL__", resource_aria_label)
|
||||||
|
.replace("__RESOURCE_BREADCRUMB__", &resource_breadcrumb)
|
||||||
.replace("__DOCUMENT_TITLE__", &escape_html(document_title))
|
.replace("__DOCUMENT_TITLE__", &escape_html(document_title))
|
||||||
.replace("__PARENT_TITLE__", &escape_html(parent_title))
|
.replace("__PARENT_TITLE__", &escape_html(parent_title))
|
||||||
.replace("__PARENT_URL__", &escape_html(parent_url))
|
.replace("__PARENT_URL__", &escape_html(parent_url))
|
||||||
@@ -49,12 +176,74 @@ pub(super) async fn health() -> &'static str {
|
|||||||
"ok"
|
"ok"
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn favicon() -> StatusCode {
|
pub(super) async fn favicon() -> Response {
|
||||||
StatusCode::NO_CONTENT
|
static FAVICON: &[u8] = include_bytes!("../../static/icons/favicon.ico");
|
||||||
|
(
|
||||||
|
[
|
||||||
|
(
|
||||||
|
header::CONTENT_TYPE,
|
||||||
|
HeaderValue::from_static("image/x-icon"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
header::CACHE_CONTROL,
|
||||||
|
HeaderValue::from_static("public, max-age=604800, must-revalidate"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
FAVICON,
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn favicon_svg() -> Response {
|
||||||
|
static FAVICON: &str = include_str!("../../static/icons/favicon.svg");
|
||||||
|
(
|
||||||
|
[
|
||||||
|
(
|
||||||
|
header::CONTENT_TYPE,
|
||||||
|
HeaderValue::from_static("image/svg+xml; charset=utf-8"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
header::CACHE_CONTROL,
|
||||||
|
HeaderValue::from_static("public, max-age=604800, must-revalidate"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
FAVICON,
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn favicon_png() -> Response {
|
||||||
|
static FAVICON: &[u8] = include_bytes!("../../static/icons/favicon-32.png");
|
||||||
|
(
|
||||||
|
[
|
||||||
|
(header::CONTENT_TYPE, HeaderValue::from_static("image/png")),
|
||||||
|
(
|
||||||
|
header::CACHE_CONTROL,
|
||||||
|
HeaderValue::from_static("public, max-age=604800, must-revalidate"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
FAVICON,
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn apple_touch_icon() -> Response {
|
||||||
|
static ICON: &[u8] = include_bytes!("../../static/icons/apple-touch-icon.png");
|
||||||
|
(
|
||||||
|
[
|
||||||
|
(header::CONTENT_TYPE, HeaderValue::from_static("image/png")),
|
||||||
|
(
|
||||||
|
header::CACHE_CONTROL,
|
||||||
|
HeaderValue::from_static("public, max-age=604800, must-revalidate"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
ICON,
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn robots_txt() -> Response {
|
pub(super) async fn robots_txt() -> Response {
|
||||||
let mut response = "User-agent: *\nDisallow: /f/\nDisallow: /files/\n".into_response();
|
let mut response = "User-agent: *\nDisallow: /f/\n".into_response();
|
||||||
response.headers_mut().insert(
|
response.headers_mut().insert(
|
||||||
header::CONTENT_TYPE,
|
header::CONTENT_TYPE,
|
||||||
HeaderValue::from_static("text/plain; charset=utf-8"),
|
HeaderValue::from_static("text/plain; charset=utf-8"),
|
||||||
@@ -74,19 +263,39 @@ pub(super) async fn home(State(state): State<SharedState>) -> Response {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn pad(State(state): State<SharedState>, Path(slug): Path<String>) -> Response {
|
pub(super) async fn pad(
|
||||||
|
State(state): State<SharedState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Path(slug): Path<String>,
|
||||||
|
RawQuery(query): RawQuery,
|
||||||
|
OriginalUri(uri): OriginalUri,
|
||||||
|
) -> Response {
|
||||||
match db::find_pad(&state.db, &slug).await {
|
match db::find_pad(&state.db, &slug).await {
|
||||||
Ok(Some(pad)) => render_editor_page(
|
Ok(Some(pad)) => {
|
||||||
&state,
|
let (has_share, share) = share_token_from_query(query.as_deref());
|
||||||
"pad",
|
if has_share {
|
||||||
"pad",
|
return share_session_redirect(
|
||||||
&pad.title,
|
&state,
|
||||||
"RustPad",
|
&headers,
|
||||||
"/",
|
&uri,
|
||||||
"home-brand",
|
"pad",
|
||||||
"note",
|
&slug,
|
||||||
"<kbd>Alt+Enter</kbd><span>New line while editing Preview</span><kbd>Esc</kbd><span>Edit raw Markdown of current Preview line</span>",
|
share.as_deref(),
|
||||||
),
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
render_editor_page(
|
||||||
|
&state,
|
||||||
|
"pad",
|
||||||
|
"pad",
|
||||||
|
&pad.title,
|
||||||
|
"RustPad",
|
||||||
|
"/",
|
||||||
|
"home-brand",
|
||||||
|
"note",
|
||||||
|
"<kbd>Alt+Enter</kbd><span>New line while editing Preview</span><kbd>Esc</kbd><span>Edit raw Markdown of current Preview line</span>",
|
||||||
|
)
|
||||||
|
}
|
||||||
Ok(None) => error_response(
|
Ok(None) => error_response(
|
||||||
StatusCode::NOT_FOUND,
|
StatusCode::NOT_FOUND,
|
||||||
"404",
|
"404",
|
||||||
@@ -135,10 +344,25 @@ pub(super) async fn public_page(
|
|||||||
|
|
||||||
pub(super) async fn workspace(
|
pub(super) async fn workspace(
|
||||||
State(state): State<SharedState>,
|
State(state): State<SharedState>,
|
||||||
|
headers: HeaderMap,
|
||||||
Path(workspace_slug): Path<String>,
|
Path(workspace_slug): Path<String>,
|
||||||
|
RawQuery(query): RawQuery,
|
||||||
|
OriginalUri(uri): OriginalUri,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
match db::find_workspace(&state.db, &workspace_slug).await {
|
match db::find_workspace(&state.db, &workspace_slug).await {
|
||||||
Ok(Some(workspace)) => {
|
Ok(Some(workspace)) => {
|
||||||
|
let (has_share, share) = share_token_from_query(query.as_deref());
|
||||||
|
if has_share {
|
||||||
|
return share_session_redirect(
|
||||||
|
&state,
|
||||||
|
&headers,
|
||||||
|
&uri,
|
||||||
|
"workspace",
|
||||||
|
&workspace_slug,
|
||||||
|
share.as_deref(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
let html = include_str!("../../static/workspace.html").replace(
|
let html = include_str!("../../static/workspace.html").replace(
|
||||||
"__WORKSPACE_TITLE__",
|
"__WORKSPACE_TITLE__",
|
||||||
&escape_html(if workspace.is_private != 0 {
|
&escape_html(if workspace.is_private != 0 {
|
||||||
@@ -175,7 +399,10 @@ pub(super) async fn workspace(
|
|||||||
|
|
||||||
pub(super) async fn note(
|
pub(super) async fn note(
|
||||||
State(state): State<SharedState>,
|
State(state): State<SharedState>,
|
||||||
|
headers: HeaderMap,
|
||||||
Path((workspace_slug, note_slug)): Path<(String, String)>,
|
Path((workspace_slug, note_slug)): Path<(String, String)>,
|
||||||
|
RawQuery(query): RawQuery,
|
||||||
|
OriginalUri(uri): OriginalUri,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
let workspace = match db::find_workspace(&state.db, &workspace_slug).await {
|
let workspace = match db::find_workspace(&state.db, &workspace_slug).await {
|
||||||
Ok(Some(workspace)) => workspace,
|
Ok(Some(workspace)) => workspace,
|
||||||
@@ -197,17 +424,39 @@ pub(super) async fn note(
|
|||||||
};
|
};
|
||||||
|
|
||||||
match db::find_note(&state.db, workspace.id, ¬e_slug).await {
|
match db::find_note(&state.db, workspace.id, ¬e_slug).await {
|
||||||
Ok(Some(note)) => render_editor_page(
|
Ok(Some(note)) => {
|
||||||
&state,
|
let (has_share, share) = share_token_from_query(query.as_deref());
|
||||||
"note",
|
if has_share {
|
||||||
"note",
|
return share_session_redirect(
|
||||||
if workspace.is_private != 0 { "Note" } else { ¬e.title },
|
&state,
|
||||||
if workspace.is_private != 0 { "Workspace" } else { &workspace.title },
|
&headers,
|
||||||
&format!("/w/{workspace_slug}"),
|
&uri,
|
||||||
"",
|
"workspace",
|
||||||
"workspace",
|
&workspace_slug,
|
||||||
"",
|
share.as_deref(),
|
||||||
),
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
render_editor_page(
|
||||||
|
&state,
|
||||||
|
"note",
|
||||||
|
"note",
|
||||||
|
if workspace.is_private != 0 {
|
||||||
|
"Note"
|
||||||
|
} else {
|
||||||
|
¬e.title
|
||||||
|
},
|
||||||
|
if workspace.is_private != 0 {
|
||||||
|
"Workspace"
|
||||||
|
} else {
|
||||||
|
&workspace.title
|
||||||
|
},
|
||||||
|
&format!("/w/{workspace_slug}"),
|
||||||
|
"",
|
||||||
|
"workspace",
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
}
|
||||||
Ok(None) => error_response(
|
Ok(None) => error_response(
|
||||||
StatusCode::NOT_FOUND,
|
StatusCode::NOT_FOUND,
|
||||||
"404",
|
"404",
|
||||||
@@ -282,6 +531,7 @@ pub(super) fn error_response(
|
|||||||
asset_version: &str,
|
asset_version: &str,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
let html = include_str!("../../static/error.html")
|
let html = include_str!("../../static/error.html")
|
||||||
|
.replace("__APP_THEME_BOOTSTRAP__", assets::theme_bootstrap())
|
||||||
.replace(
|
.replace(
|
||||||
"__APP_STYLESHEET__",
|
"__APP_STYLESHEET__",
|
||||||
&assets::stylesheet_tag(asset_version, "styles"),
|
&assets::stylesheet_tag(asset_version, "styles"),
|
||||||
@@ -312,3 +562,7 @@ fn escape_html(value: &str) -> String {
|
|||||||
.replace('"', """)
|
.replace('"', """)
|
||||||
.replace('\'', "'")
|
.replace('\'', "'")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../tests/app_pages.rs"]
|
||||||
|
mod tests;
|
||||||
|
|||||||
+39
-9
@@ -17,9 +17,12 @@ const MODULES: &[&str] = &[
|
|||||||
"authorship",
|
"authorship",
|
||||||
"auth-ui",
|
"auth-ui",
|
||||||
"clipboard",
|
"clipboard",
|
||||||
|
"collaboration",
|
||||||
|
"collaboration-session",
|
||||||
"editor-format",
|
"editor-format",
|
||||||
"emoji-data",
|
"emoji-data",
|
||||||
"emoji-picker",
|
"emoji-picker",
|
||||||
|
"image-alias",
|
||||||
"image-upload",
|
"image-upload",
|
||||||
"logger",
|
"logger",
|
||||||
"line-links",
|
"line-links",
|
||||||
@@ -28,10 +31,14 @@ const MODULES: &[&str] = &[
|
|||||||
"note-api",
|
"note-api",
|
||||||
"note-editor",
|
"note-editor",
|
||||||
"note-files",
|
"note-files",
|
||||||
|
"preview-edit",
|
||||||
|
"render-queue",
|
||||||
"session",
|
"session",
|
||||||
"socket",
|
"socket",
|
||||||
"toast",
|
"toast",
|
||||||
|
"theme",
|
||||||
"url-state",
|
"url-state",
|
||||||
|
"vendor-libs",
|
||||||
"security",
|
"security",
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -45,9 +52,20 @@ pub fn render_html(
|
|||||||
entrypoint: &str,
|
entrypoint: &str,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
let urls = AssetUrls::new(asset_version);
|
let urls = AssetUrls::new(asset_version);
|
||||||
let frontend_config = frontend_config(frontend_log_level, upload_max_size_bytes, external_auth);
|
let frontend_config = frontend_config(
|
||||||
|
frontend_log_level,
|
||||||
|
upload_max_size_bytes,
|
||||||
|
external_auth,
|
||||||
|
asset_version,
|
||||||
|
);
|
||||||
|
let app_stylesheets = format!(
|
||||||
|
"{}{}",
|
||||||
|
urls.stylesheet("styles"),
|
||||||
|
urls.stylesheet_path("libs/rustpad-player/player.css"),
|
||||||
|
);
|
||||||
let html = template
|
let html = template
|
||||||
.replace("__APP_STYLESHEET__", &urls.stylesheet("styles"))
|
.replace("__APP_THEME_BOOTSTRAP__", theme_bootstrap())
|
||||||
|
.replace("__APP_STYLESHEET__", &app_stylesheets)
|
||||||
.replace("__APP_IMPORT_MAP__", &urls.import_map())
|
.replace("__APP_IMPORT_MAP__", &urls.import_map())
|
||||||
.replace("__APP_ENTRYPOINT__", &urls.entrypoint(entrypoint))
|
.replace("__APP_ENTRYPOINT__", &urls.entrypoint(entrypoint))
|
||||||
.replace(
|
.replace(
|
||||||
@@ -58,16 +76,25 @@ pub fn render_html(
|
|||||||
"false"
|
"false"
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.replace("</head>", &format!("{frontend_config}</head>"));
|
.replace(
|
||||||
|
"</head>",
|
||||||
|
&format!(
|
||||||
|
r#"<link rel="icon" href="/favicon.svg" type="image/svg+xml"><link rel="icon" href="/favicon.ico" sizes="any"><link rel="icon" href="/favicon-32.png" type="image/png" sizes="32x32"><link rel="apple-touch-icon" href="/apple-touch-icon.png">{frontend_config}</head>"#
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
let mut response = Html(html).into_response();
|
let mut response = Html(html).into_response();
|
||||||
response.headers_mut().insert(
|
response.headers_mut().insert(
|
||||||
header::CACHE_CONTROL,
|
header::CACHE_CONTROL,
|
||||||
HeaderValue::from_static("private, no-store"),
|
HeaderValue::from_static("private, no-cache, no-store"),
|
||||||
);
|
);
|
||||||
response
|
response
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn theme_bootstrap() -> &'static str {
|
||||||
|
r#"<script>(()=>{const key="rustpad:theme";let theme=matchMedia("(prefers-color-scheme: light)").matches?"light":"dark";try{const saved=localStorage.getItem(key);if(saved==="light"||saved==="dark")theme=saved}catch{}const root=document.documentElement;root.dataset.theme=theme;root.style.colorScheme=theme;const meta=document.querySelector('meta[name="color-scheme"]');if(meta)meta.content=theme})();</script>"#
|
||||||
|
}
|
||||||
|
|
||||||
pub fn stylesheet_tag(asset_version: &str, name: &str) -> String {
|
pub fn stylesheet_tag(asset_version: &str, name: &str) -> String {
|
||||||
AssetUrls::new(asset_version).stylesheet(name)
|
AssetUrls::new(asset_version).stylesheet(name)
|
||||||
}
|
}
|
||||||
@@ -76,12 +103,14 @@ fn frontend_config(
|
|||||||
frontend_log_level: &str,
|
frontend_log_level: &str,
|
||||||
upload_max_size_bytes: usize,
|
upload_max_size_bytes: usize,
|
||||||
external_auth: bool,
|
external_auth: bool,
|
||||||
|
asset_version: &str,
|
||||||
) -> String {
|
) -> String {
|
||||||
format!(
|
format!(
|
||||||
r#"<script>window.__RUSTPAD_CONFIG__=Object.freeze({{frontendLogLevel:"{}",uploadMaxSizeBytes:{},externalAuth:{}}});</script>"#,
|
r#"<script>window.__RUSTPAD_CONFIG__=Object.freeze({{frontendLogLevel:"{}",uploadMaxSizeBytes:{},externalAuth:{},assetVersion:"{}"}});</script>"#,
|
||||||
escape_js_string(frontend_log_level),
|
escape_js_string(frontend_log_level),
|
||||||
upload_max_size_bytes,
|
upload_max_size_bytes,
|
||||||
external_auth,
|
external_auth,
|
||||||
|
escape_js_string(asset_version),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,10 +128,11 @@ impl<'a> AssetUrls<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn stylesheet(&self, name: &str) -> String {
|
fn stylesheet(&self, name: &str) -> String {
|
||||||
format!(
|
self.stylesheet_path(&format!("css/{name}.css"))
|
||||||
r#"<link rel="stylesheet" href="{}">"#,
|
}
|
||||||
self.url(&format!("css/{name}.css"))
|
|
||||||
)
|
fn stylesheet_path(&self, path: &str) -> String {
|
||||||
|
format!(r#"<link rel="stylesheet" href="{}">"#, self.url(path))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn entrypoint(&self, name: &str) -> String {
|
fn entrypoint(&self, name: &str) -> String {
|
||||||
|
|||||||
+743
-123
File diff suppressed because it is too large
Load Diff
+826
@@ -0,0 +1,826 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Mateusz Gruszczynski @linuxiarz.pl
|
||||||
|
* Source-Available Code / Dual-Licensed.
|
||||||
|
*
|
||||||
|
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||||
|
* Commercial or production use requires a valid paid license.
|
||||||
|
* See LICENSE file in repository root for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::{
|
||||||
|
collections::{HashMap, VecDeque},
|
||||||
|
error::Error,
|
||||||
|
fmt,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const MAX_OPERATION_COMPONENTS: usize = 4096;
|
||||||
|
const MAX_OPERATION_OWNER_SPANS: usize = 8192;
|
||||||
|
const MAX_OPERATION_INSERT_BYTES: usize = 2_000_000;
|
||||||
|
const MAX_OWNER_LENGTH: usize = 120;
|
||||||
|
const MAX_OPERATION_HISTORY: usize = 512;
|
||||||
|
const MAX_OPERATION_HISTORY_BYTES: usize = 8 * 1024 * 1024;
|
||||||
|
const AUTHORSHIP_VERSION: u8 = 2;
|
||||||
|
const OWNER_COLOR_SEPARATOR: char = '\u{001f}';
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct OwnerSpan {
|
||||||
|
pub start: usize,
|
||||||
|
pub end: usize,
|
||||||
|
pub owner: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
|
pub enum OperationComponent {
|
||||||
|
Retain {
|
||||||
|
count: usize,
|
||||||
|
},
|
||||||
|
Delete {
|
||||||
|
count: usize,
|
||||||
|
},
|
||||||
|
Insert {
|
||||||
|
text: String,
|
||||||
|
#[serde(default)]
|
||||||
|
owners: Vec<OwnerSpan>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct TextOperation {
|
||||||
|
#[serde(default)]
|
||||||
|
pub components: Vec<OperationComponent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct OwnerReplacement {
|
||||||
|
pub owner: String,
|
||||||
|
pub replacement: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct AppliedOperation {
|
||||||
|
pub base_revision_id: i64,
|
||||||
|
pub revision_id: i64,
|
||||||
|
pub client_id: String,
|
||||||
|
pub update_id: u64,
|
||||||
|
pub operation: TextOperation,
|
||||||
|
pub owner_replacements: Vec<OwnerReplacement>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct CollaborativeDocument {
|
||||||
|
pub content: String,
|
||||||
|
pub owner_map: String,
|
||||||
|
pub revision_id: i64,
|
||||||
|
history: VecDeque<AppliedOperation>,
|
||||||
|
history_bytes: usize,
|
||||||
|
acknowledged_updates: HashMap<String, u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CollaborativeDocument {
|
||||||
|
pub fn new(content: String, owner_map: String, revision_id: i64) -> Self {
|
||||||
|
Self {
|
||||||
|
content,
|
||||||
|
owner_map,
|
||||||
|
revision_id,
|
||||||
|
history: VecDeque::new(),
|
||||||
|
history_bytes: 0,
|
||||||
|
acknowledged_updates: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn transform_from(
|
||||||
|
&self,
|
||||||
|
base_revision_id: i64,
|
||||||
|
operation: &TextOperation,
|
||||||
|
client_id: &str,
|
||||||
|
update_id: u64,
|
||||||
|
) -> Result<TextOperation, OperationError> {
|
||||||
|
let mut transformed = normalize_operation(operation)?;
|
||||||
|
if base_revision_id == self.revision_id {
|
||||||
|
return Ok(transformed);
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(start) = self
|
||||||
|
.history
|
||||||
|
.iter()
|
||||||
|
.position(|entry| entry.base_revision_id == base_revision_id)
|
||||||
|
else {
|
||||||
|
return Err(OperationError::RevisionUnavailable);
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut expected_revision = base_revision_id;
|
||||||
|
for applied in self.history.iter().skip(start) {
|
||||||
|
if applied.base_revision_id != expected_revision {
|
||||||
|
return Err(OperationError::RevisionUnavailable);
|
||||||
|
}
|
||||||
|
let incoming_has_priority =
|
||||||
|
operation_key_before(client_id, update_id, &applied.client_id, applied.update_id);
|
||||||
|
transformed =
|
||||||
|
transform_operation(&transformed, &applied.operation, incoming_has_priority)?;
|
||||||
|
expected_revision = applied.revision_id;
|
||||||
|
if expected_revision == self.revision_id {
|
||||||
|
return Ok(transformed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(OperationError::RevisionUnavailable)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn acknowledge(&mut self, client_id: &str, update_id: u64) {
|
||||||
|
self.acknowledged_updates
|
||||||
|
.entry(client_id.to_owned())
|
||||||
|
.and_modify(|acknowledged| *acknowledged = (*acknowledged).max(update_id))
|
||||||
|
.or_insert(update_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_applied_update(&self, client_id: &str, update_id: u64) -> bool {
|
||||||
|
self.acknowledged_updates
|
||||||
|
.get(client_id)
|
||||||
|
.is_some_and(|acknowledged| update_id <= *acknowledged)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn acknowledged_updates(&self, client_id: &str) -> Vec<u64> {
|
||||||
|
self.acknowledged_updates
|
||||||
|
.get(client_id)
|
||||||
|
.copied()
|
||||||
|
.into_iter()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn operations_after(&self, revision_id: i64) -> Option<Vec<AppliedOperation>> {
|
||||||
|
if revision_id == self.revision_id {
|
||||||
|
return Some(Vec::new());
|
||||||
|
}
|
||||||
|
let start = self
|
||||||
|
.history
|
||||||
|
.iter()
|
||||||
|
.position(|entry| entry.base_revision_id == revision_id)?;
|
||||||
|
let mut expected_revision = revision_id;
|
||||||
|
let mut operations = Vec::new();
|
||||||
|
for applied in self.history.iter().skip(start) {
|
||||||
|
if applied.base_revision_id != expected_revision {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
operations.push(applied.clone());
|
||||||
|
expected_revision = applied.revision_id;
|
||||||
|
if expected_revision == self.revision_id {
|
||||||
|
return Some(operations);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record(&mut self, operation: AppliedOperation) {
|
||||||
|
self.acknowledge(&operation.client_id, operation.update_id);
|
||||||
|
self.history_bytes = self
|
||||||
|
.history_bytes
|
||||||
|
.saturating_add(applied_operation_size(&operation));
|
||||||
|
self.history.push_back(operation);
|
||||||
|
while self.history.len() > MAX_OPERATION_HISTORY
|
||||||
|
|| self.history_bytes > MAX_OPERATION_HISTORY_BYTES
|
||||||
|
{
|
||||||
|
let Some(removed) = self.history.pop_front() else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
self.history_bytes = self
|
||||||
|
.history_bytes
|
||||||
|
.saturating_sub(applied_operation_size(&removed));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum ComponentKind {
|
||||||
|
Retain,
|
||||||
|
Delete,
|
||||||
|
Insert,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum OperationError {
|
||||||
|
InvalidComponent,
|
||||||
|
InvalidUtf16Boundary,
|
||||||
|
LengthMismatch,
|
||||||
|
TooManyComponents,
|
||||||
|
RevisionUnavailable,
|
||||||
|
Serialization,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for OperationError {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
let message = match self {
|
||||||
|
Self::InvalidComponent => "invalid text operation component",
|
||||||
|
Self::InvalidUtf16Boundary => "text operation splits a UTF-16 character",
|
||||||
|
Self::LengthMismatch => "text operation length does not match the document",
|
||||||
|
Self::TooManyComponents => "text operation contains too many components",
|
||||||
|
Self::RevisionUnavailable => "the base revision is no longer available",
|
||||||
|
Self::Serialization => "invalid authorship metadata",
|
||||||
|
};
|
||||||
|
formatter.write_str(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Error for OperationError {}
|
||||||
|
|
||||||
|
fn applied_operation_size(operation: &AppliedOperation) -> usize {
|
||||||
|
let components = operation
|
||||||
|
.operation
|
||||||
|
.components
|
||||||
|
.iter()
|
||||||
|
.map(|component| match component {
|
||||||
|
OperationComponent::Retain { .. } | OperationComponent::Delete { .. } => 24,
|
||||||
|
OperationComponent::Insert { text, owners } => {
|
||||||
|
32usize.saturating_add(text.len()).saturating_add(
|
||||||
|
owners
|
||||||
|
.iter()
|
||||||
|
.map(|span| 24usize.saturating_add(span.owner.len()))
|
||||||
|
.sum::<usize>(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sum::<usize>();
|
||||||
|
components
|
||||||
|
.saturating_add(operation.client_id.len())
|
||||||
|
.saturating_add(
|
||||||
|
operation
|
||||||
|
.owner_replacements
|
||||||
|
.iter()
|
||||||
|
.map(|replacement| replacement.owner.len() + replacement.replacement.len() + 16)
|
||||||
|
.sum::<usize>(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn operation_key_before(
|
||||||
|
left_client_id: &str,
|
||||||
|
left_update_id: u64,
|
||||||
|
right_client_id: &str,
|
||||||
|
right_update_id: u64,
|
||||||
|
) -> bool {
|
||||||
|
left_client_id < right_client_id
|
||||||
|
|| (left_client_id == right_client_id && left_update_id < right_update_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn component_kind(component: &OperationComponent) -> ComponentKind {
|
||||||
|
match component {
|
||||||
|
OperationComponent::Retain { .. } => ComponentKind::Retain,
|
||||||
|
OperationComponent::Delete { .. } => ComponentKind::Delete,
|
||||||
|
OperationComponent::Insert { .. } => ComponentKind::Insert,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn component_length(component: &OperationComponent) -> usize {
|
||||||
|
match component {
|
||||||
|
OperationComponent::Retain { count } | OperationComponent::Delete { count } => *count,
|
||||||
|
OperationComponent::Insert { text, .. } => text.encode_utf16().count(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_owner_spans(spans: &[OwnerSpan], length: usize) -> Vec<OwnerSpan> {
|
||||||
|
let mut sorted = spans
|
||||||
|
.iter()
|
||||||
|
.filter_map(|span| {
|
||||||
|
let start = span.start.min(length);
|
||||||
|
let end = span.end.min(length).max(start);
|
||||||
|
if span.owner.is_empty() || end <= start {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(OwnerSpan {
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
owner: span.owner.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
sorted.sort_by_key(|span| (span.start, span.end));
|
||||||
|
let mut result: Vec<OwnerSpan> = Vec::new();
|
||||||
|
for mut span in sorted {
|
||||||
|
if let Some(previous) = result.last_mut() {
|
||||||
|
if previous.owner == span.owner && span.start <= previous.end {
|
||||||
|
previous.end = previous.end.max(span.end);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if span.start < previous.end {
|
||||||
|
span.start = previous.end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if span.end > span.start {
|
||||||
|
result.push(span);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn slice_owner_spans(spans: &[OwnerSpan], start: usize, length: usize) -> Vec<OwnerSpan> {
|
||||||
|
let end = start.saturating_add(length);
|
||||||
|
let sliced = spans
|
||||||
|
.iter()
|
||||||
|
.filter_map(|span| {
|
||||||
|
let overlap_start = start.max(span.start);
|
||||||
|
let overlap_end = end.min(span.end);
|
||||||
|
(overlap_end > overlap_start).then(|| OwnerSpan {
|
||||||
|
start: overlap_start - start,
|
||||||
|
end: overlap_end - start,
|
||||||
|
owner: span.owner.clone(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
normalize_owner_spans(&sliced, length)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shift_owner_spans(spans: &[OwnerSpan], offset: usize) -> Vec<OwnerSpan> {
|
||||||
|
spans
|
||||||
|
.iter()
|
||||||
|
.map(|span| OwnerSpan {
|
||||||
|
start: span.start + offset,
|
||||||
|
end: span.end + offset,
|
||||||
|
owner: span.owner.clone(),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_component(
|
||||||
|
components: &mut Vec<OperationComponent>,
|
||||||
|
component: OperationComponent,
|
||||||
|
) -> Result<(), OperationError> {
|
||||||
|
match component {
|
||||||
|
OperationComponent::Retain { count } => {
|
||||||
|
if count == 0 {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if let Some(OperationComponent::Retain { count: previous }) = components.last_mut() {
|
||||||
|
*previous = previous
|
||||||
|
.checked_add(count)
|
||||||
|
.ok_or(OperationError::InvalidComponent)?;
|
||||||
|
} else {
|
||||||
|
components.push(OperationComponent::Retain { count });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OperationComponent::Delete { count } => {
|
||||||
|
if count == 0 {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if let Some(OperationComponent::Delete { count: previous }) = components.last_mut() {
|
||||||
|
*previous = previous
|
||||||
|
.checked_add(count)
|
||||||
|
.ok_or(OperationError::InvalidComponent)?;
|
||||||
|
} else {
|
||||||
|
components.push(OperationComponent::Delete { count });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OperationComponent::Insert { text, owners } => {
|
||||||
|
let length = text.encode_utf16().count();
|
||||||
|
if length == 0 {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let owners = normalize_owner_spans(&owners, length);
|
||||||
|
if let Some(OperationComponent::Insert {
|
||||||
|
text: previous_text,
|
||||||
|
owners: previous_owners,
|
||||||
|
}) = components.last_mut()
|
||||||
|
{
|
||||||
|
let offset = previous_text.encode_utf16().count();
|
||||||
|
previous_text.push_str(&text);
|
||||||
|
previous_owners.extend(shift_owner_spans(&owners, offset));
|
||||||
|
*previous_owners =
|
||||||
|
normalize_owner_spans(previous_owners, previous_text.encode_utf16().count());
|
||||||
|
} else {
|
||||||
|
components.push(OperationComponent::Insert { text, owners });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if components.len() > MAX_OPERATION_COMPONENTS {
|
||||||
|
return Err(OperationError::TooManyComponents);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn normalize_operation(operation: &TextOperation) -> Result<TextOperation, OperationError> {
|
||||||
|
if operation.components.len() > MAX_OPERATION_COMPONENTS {
|
||||||
|
return Err(OperationError::TooManyComponents);
|
||||||
|
}
|
||||||
|
let mut inserted_bytes = 0usize;
|
||||||
|
let mut owner_span_count = 0usize;
|
||||||
|
let mut components = Vec::with_capacity(operation.components.len());
|
||||||
|
for component in &operation.components {
|
||||||
|
if let OperationComponent::Insert { text, owners } = component {
|
||||||
|
inserted_bytes = inserted_bytes
|
||||||
|
.checked_add(text.len())
|
||||||
|
.ok_or(OperationError::InvalidComponent)?;
|
||||||
|
owner_span_count = owner_span_count
|
||||||
|
.checked_add(owners.len())
|
||||||
|
.ok_or(OperationError::InvalidComponent)?;
|
||||||
|
let text_length = text.encode_utf16().count();
|
||||||
|
if inserted_bytes > MAX_OPERATION_INSERT_BYTES
|
||||||
|
|| owner_span_count > MAX_OPERATION_OWNER_SPANS
|
||||||
|
|| owners.iter().any(|span| {
|
||||||
|
span.start > span.end
|
||||||
|
|| span.end > text_length
|
||||||
|
|| utf16_byte_index(text, span.start).is_err()
|
||||||
|
|| utf16_byte_index(text, span.end).is_err()
|
||||||
|
|| span.owner.chars().count() > MAX_OWNER_LENGTH
|
||||||
|
|| span.owner.chars().any(|character| {
|
||||||
|
character.is_control() && character != OWNER_COLOR_SEPARATOR
|
||||||
|
})
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return Err(OperationError::InvalidComponent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
append_component(&mut components, component.clone())?;
|
||||||
|
}
|
||||||
|
Ok(TextOperation { components })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn operation_base_length(operation: &TextOperation) -> Result<usize, OperationError> {
|
||||||
|
normalize_operation(operation)?
|
||||||
|
.components
|
||||||
|
.iter()
|
||||||
|
.try_fold(0usize, |length, component| {
|
||||||
|
let component_length = match component {
|
||||||
|
OperationComponent::Retain { count } | OperationComponent::Delete { count } => {
|
||||||
|
*count
|
||||||
|
}
|
||||||
|
OperationComponent::Insert { .. } => 0,
|
||||||
|
};
|
||||||
|
length
|
||||||
|
.checked_add(component_length)
|
||||||
|
.ok_or(OperationError::InvalidComponent)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn operation_from_edit(
|
||||||
|
previous_content: &str,
|
||||||
|
next_content: &str,
|
||||||
|
next_owner_map: &str,
|
||||||
|
) -> TextOperation {
|
||||||
|
let mut previous_prefix_bytes = 0usize;
|
||||||
|
let mut next_prefix_bytes = 0usize;
|
||||||
|
for (previous, next) in previous_content.chars().zip(next_content.chars()) {
|
||||||
|
if previous != next {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
previous_prefix_bytes += previous.len_utf8();
|
||||||
|
next_prefix_bytes += next.len_utf8();
|
||||||
|
}
|
||||||
|
|
||||||
|
let previous_remainder = &previous_content[previous_prefix_bytes..];
|
||||||
|
let next_remainder = &next_content[next_prefix_bytes..];
|
||||||
|
let mut previous_suffix_bytes = 0usize;
|
||||||
|
let mut next_suffix_bytes = 0usize;
|
||||||
|
for (previous, next) in previous_remainder
|
||||||
|
.chars()
|
||||||
|
.rev()
|
||||||
|
.zip(next_remainder.chars().rev())
|
||||||
|
{
|
||||||
|
if previous != next {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
previous_suffix_bytes += previous.len_utf8();
|
||||||
|
next_suffix_bytes += next.len_utf8();
|
||||||
|
}
|
||||||
|
|
||||||
|
let previous_middle_end = previous_content.len() - previous_suffix_bytes;
|
||||||
|
let next_middle_end = next_content.len() - next_suffix_bytes;
|
||||||
|
let previous_prefix = &previous_content[..previous_prefix_bytes];
|
||||||
|
let previous_middle = &previous_content[previous_prefix_bytes..previous_middle_end];
|
||||||
|
let next_middle = &next_content[next_prefix_bytes..next_middle_end];
|
||||||
|
let suffix = &previous_content[previous_middle_end..];
|
||||||
|
|
||||||
|
let prefix_length = previous_prefix.encode_utf16().count();
|
||||||
|
let deleted_length = previous_middle.encode_utf16().count();
|
||||||
|
let inserted_length = next_middle.encode_utf16().count();
|
||||||
|
let suffix_length = suffix.encode_utf16().count();
|
||||||
|
let next_authorship = parse_authorship(next_content, next_owner_map);
|
||||||
|
let inserted_owners = slice_owner_spans(&next_authorship.spans, prefix_length, inserted_length);
|
||||||
|
|
||||||
|
let mut components = Vec::new();
|
||||||
|
if prefix_length > 0 {
|
||||||
|
components.push(OperationComponent::Retain {
|
||||||
|
count: prefix_length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if deleted_length > 0 {
|
||||||
|
components.push(OperationComponent::Delete {
|
||||||
|
count: deleted_length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if !next_middle.is_empty() {
|
||||||
|
components.push(OperationComponent::Insert {
|
||||||
|
text: next_middle.to_owned(),
|
||||||
|
owners: inserted_owners,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if suffix_length > 0 {
|
||||||
|
components.push(OperationComponent::Retain {
|
||||||
|
count: suffix_length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
TextOperation { components }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn replace_operation(
|
||||||
|
base_length: usize,
|
||||||
|
content: String,
|
||||||
|
owners: Vec<OwnerSpan>,
|
||||||
|
) -> TextOperation {
|
||||||
|
let mut components = Vec::new();
|
||||||
|
if base_length > 0 {
|
||||||
|
components.push(OperationComponent::Delete { count: base_length });
|
||||||
|
}
|
||||||
|
if !content.is_empty() {
|
||||||
|
components.push(OperationComponent::Insert {
|
||||||
|
text: content,
|
||||||
|
owners,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
TextOperation { components }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct OperationCursor {
|
||||||
|
components: Vec<OperationComponent>,
|
||||||
|
index: usize,
|
||||||
|
offset: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OperationCursor {
|
||||||
|
fn new(operation: &TextOperation) -> Result<Self, OperationError> {
|
||||||
|
Ok(Self {
|
||||||
|
components: normalize_operation(operation)?.components,
|
||||||
|
index: 0,
|
||||||
|
offset: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current(&self) -> Option<&OperationComponent> {
|
||||||
|
self.components.get(self.index)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn kind(&self) -> Option<ComponentKind> {
|
||||||
|
self.current().map(component_kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remaining(&self) -> usize {
|
||||||
|
self.current()
|
||||||
|
.map(|component| component_length(component).saturating_sub(self.offset))
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn take(&mut self, count: usize) -> Result<OperationComponent, OperationError> {
|
||||||
|
let component = self
|
||||||
|
.current()
|
||||||
|
.cloned()
|
||||||
|
.ok_or(OperationError::InvalidComponent)?;
|
||||||
|
if count == 0 || count > self.remaining() {
|
||||||
|
return Err(OperationError::InvalidComponent);
|
||||||
|
}
|
||||||
|
let component_length = component_length(&component);
|
||||||
|
let part = match component {
|
||||||
|
OperationComponent::Retain { .. } => OperationComponent::Retain { count },
|
||||||
|
OperationComponent::Delete { .. } => OperationComponent::Delete { count },
|
||||||
|
OperationComponent::Insert { text, owners } => OperationComponent::Insert {
|
||||||
|
text: slice_utf16(&text, self.offset, count)?.to_owned(),
|
||||||
|
owners: slice_owner_spans(&owners, self.offset, count),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
self.offset += count;
|
||||||
|
if self.offset == component_length {
|
||||||
|
self.index += 1;
|
||||||
|
self.offset = 0;
|
||||||
|
}
|
||||||
|
Ok(part)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn take_remaining(&mut self) -> Result<OperationComponent, OperationError> {
|
||||||
|
let count = self.remaining();
|
||||||
|
self.take(count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn transform_operation(
|
||||||
|
left_operation: &TextOperation,
|
||||||
|
right_operation: &TextOperation,
|
||||||
|
left_before_right: bool,
|
||||||
|
) -> Result<TextOperation, OperationError> {
|
||||||
|
if operation_base_length(left_operation)? != operation_base_length(right_operation)? {
|
||||||
|
return Err(OperationError::LengthMismatch);
|
||||||
|
}
|
||||||
|
let mut left = OperationCursor::new(left_operation)?;
|
||||||
|
let mut right = OperationCursor::new(right_operation)?;
|
||||||
|
let mut left_prime = Vec::new();
|
||||||
|
|
||||||
|
while left.current().is_some() || right.current().is_some() {
|
||||||
|
if left.kind() == Some(ComponentKind::Insert)
|
||||||
|
&& (right.kind() != Some(ComponentKind::Insert) || left_before_right)
|
||||||
|
{
|
||||||
|
append_component(&mut left_prime, left.take_remaining()?)?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if right.kind() == Some(ComponentKind::Insert) {
|
||||||
|
let count = right.remaining();
|
||||||
|
right.take_remaining()?;
|
||||||
|
append_component(&mut left_prime, OperationComponent::Retain { count })?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (Some(left_kind), Some(right_kind)) = (left.kind(), right.kind()) else {
|
||||||
|
return Err(OperationError::InvalidComponent);
|
||||||
|
};
|
||||||
|
let count = left.remaining().min(right.remaining());
|
||||||
|
match (left_kind, right_kind) {
|
||||||
|
(ComponentKind::Retain, ComponentKind::Retain) => {
|
||||||
|
append_component(&mut left_prime, OperationComponent::Retain { count })?;
|
||||||
|
}
|
||||||
|
(ComponentKind::Delete, ComponentKind::Retain) => {
|
||||||
|
append_component(&mut left_prime, OperationComponent::Delete { count })?;
|
||||||
|
}
|
||||||
|
(ComponentKind::Retain, ComponentKind::Delete)
|
||||||
|
| (ComponentKind::Delete, ComponentKind::Delete) => {}
|
||||||
|
_ => return Err(OperationError::InvalidComponent),
|
||||||
|
}
|
||||||
|
left.take(count)?;
|
||||||
|
right.take(count)?;
|
||||||
|
}
|
||||||
|
Ok(TextOperation {
|
||||||
|
components: left_prime,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn utf16_byte_index(value: &str, offset: usize) -> Result<usize, OperationError> {
|
||||||
|
if offset == 0 {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
let mut current = 0usize;
|
||||||
|
for (byte_index, character) in value.char_indices() {
|
||||||
|
if current == offset {
|
||||||
|
return Ok(byte_index);
|
||||||
|
}
|
||||||
|
current += character.len_utf16();
|
||||||
|
if current > offset {
|
||||||
|
return Err(OperationError::InvalidUtf16Boundary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current == offset {
|
||||||
|
Ok(value.len())
|
||||||
|
} else {
|
||||||
|
Err(OperationError::LengthMismatch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn slice_utf16(value: &str, start: usize, length: usize) -> Result<&str, OperationError> {
|
||||||
|
let start_byte = utf16_byte_index(value, start)?;
|
||||||
|
let end_byte = utf16_byte_index(value, start.saturating_add(length))?;
|
||||||
|
value
|
||||||
|
.get(start_byte..end_byte)
|
||||||
|
.ok_or(OperationError::InvalidUtf16Boundary)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
struct AuthorshipModel {
|
||||||
|
#[serde(default = "authorship_version")]
|
||||||
|
version: u8,
|
||||||
|
#[serde(default)]
|
||||||
|
spans: Vec<OwnerSpan>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn authorship_version() -> u8 {
|
||||||
|
AUTHORSHIP_VERSION
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
enum RawAuthorship {
|
||||||
|
Model(AuthorshipModel),
|
||||||
|
LineOwners(Vec<String>),
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_authorship(content: &str, raw: &str) -> AuthorshipModel {
|
||||||
|
let length = content.encode_utf16().count();
|
||||||
|
match serde_json::from_str::<RawAuthorship>(raw) {
|
||||||
|
Ok(RawAuthorship::Model(model)) if model.version == AUTHORSHIP_VERSION => AuthorshipModel {
|
||||||
|
version: AUTHORSHIP_VERSION,
|
||||||
|
spans: normalize_owner_spans(&model.spans, length),
|
||||||
|
},
|
||||||
|
Ok(RawAuthorship::LineOwners(owners)) => {
|
||||||
|
let lines = content.split('\n').collect::<Vec<_>>();
|
||||||
|
let mut offset = 0usize;
|
||||||
|
let mut spans = Vec::new();
|
||||||
|
for (index, line) in lines.iter().enumerate() {
|
||||||
|
let line_length =
|
||||||
|
line.encode_utf16().count() + usize::from(index + 1 < lines.len());
|
||||||
|
let owner = owners.get(index).cloned().unwrap_or_default();
|
||||||
|
if !owner.is_empty() && line_length > 0 {
|
||||||
|
spans.push(OwnerSpan {
|
||||||
|
start: offset,
|
||||||
|
end: offset + line_length,
|
||||||
|
owner,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
offset += line_length;
|
||||||
|
}
|
||||||
|
AuthorshipModel {
|
||||||
|
version: AUTHORSHIP_VERSION,
|
||||||
|
spans: normalize_owner_spans(&spans, length),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => AuthorshipModel {
|
||||||
|
version: AUTHORSHIP_VERSION,
|
||||||
|
spans: Vec::new(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn copy_retained_spans(
|
||||||
|
target: &mut Vec<OwnerSpan>,
|
||||||
|
spans: &[OwnerSpan],
|
||||||
|
source_start: usize,
|
||||||
|
length: usize,
|
||||||
|
output_start: usize,
|
||||||
|
) {
|
||||||
|
let source_end = source_start + length;
|
||||||
|
for span in spans {
|
||||||
|
let start = source_start.max(span.start);
|
||||||
|
let end = source_end.min(span.end);
|
||||||
|
if end > start {
|
||||||
|
target.push(OwnerSpan {
|
||||||
|
start: output_start + start - source_start,
|
||||||
|
end: output_start + end - source_start,
|
||||||
|
owner: span.owner.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_operation_to_document(
|
||||||
|
content: &str,
|
||||||
|
owner_map: &str,
|
||||||
|
operation: &TextOperation,
|
||||||
|
owner_replacements: &[OwnerReplacement],
|
||||||
|
) -> Result<(String, String), OperationError> {
|
||||||
|
let operation = normalize_operation(operation)?;
|
||||||
|
let content_length = content.encode_utf16().count();
|
||||||
|
if operation_base_length(&operation)? != content_length {
|
||||||
|
return Err(OperationError::LengthMismatch);
|
||||||
|
}
|
||||||
|
|
||||||
|
let source_model = parse_authorship(content, owner_map);
|
||||||
|
let mut output_spans = Vec::new();
|
||||||
|
let mut source_offset = 0usize;
|
||||||
|
let mut output_offset = 0usize;
|
||||||
|
let mut output_content = String::new();
|
||||||
|
|
||||||
|
for component in &operation.components {
|
||||||
|
match component {
|
||||||
|
OperationComponent::Retain { count } => {
|
||||||
|
output_content.push_str(slice_utf16(content, source_offset, *count)?);
|
||||||
|
copy_retained_spans(
|
||||||
|
&mut output_spans,
|
||||||
|
&source_model.spans,
|
||||||
|
source_offset,
|
||||||
|
*count,
|
||||||
|
output_offset,
|
||||||
|
);
|
||||||
|
source_offset += *count;
|
||||||
|
output_offset += *count;
|
||||||
|
}
|
||||||
|
OperationComponent::Delete { count } => {
|
||||||
|
source_offset += *count;
|
||||||
|
}
|
||||||
|
OperationComponent::Insert { text, owners } => {
|
||||||
|
output_content.push_str(text);
|
||||||
|
output_spans.extend(shift_owner_spans(owners, output_offset));
|
||||||
|
output_offset += text.encode_utf16().count();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if source_offset != content_length {
|
||||||
|
return Err(OperationError::LengthMismatch);
|
||||||
|
}
|
||||||
|
|
||||||
|
for span in &mut output_spans {
|
||||||
|
let identity = span
|
||||||
|
.owner
|
||||||
|
.split(OWNER_COLOR_SEPARATOR)
|
||||||
|
.next()
|
||||||
|
.unwrap_or_default();
|
||||||
|
if let Some(replacement) = owner_replacements.iter().find(|replacement| {
|
||||||
|
replacement.owner == identity && !replacement.replacement.is_empty()
|
||||||
|
}) {
|
||||||
|
span.owner.clone_from(&replacement.replacement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let model = AuthorshipModel {
|
||||||
|
version: AUTHORSHIP_VERSION,
|
||||||
|
spans: normalize_owner_spans(&output_spans, output_offset),
|
||||||
|
};
|
||||||
|
let owner_map = serde_json::to_string(&model).map_err(|_| OperationError::Serialization)?;
|
||||||
|
Ok((output_content, owner_map))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn owner_spans_from_map(content: &str, owner_map: &str) -> Vec<OwnerSpan> {
|
||||||
|
parse_authorship(content, owner_map).spans
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "tests/collab.rs"]
|
||||||
|
mod tests;
|
||||||
+50
-8
@@ -55,9 +55,12 @@ pub struct Config {
|
|||||||
pub files_dir: String,
|
pub files_dir: String,
|
||||||
pub storage: crate::storage::StorageConfig,
|
pub storage: crate::storage::StorageConfig,
|
||||||
pub upload_max_size_bytes: usize,
|
pub upload_max_size_bytes: usize,
|
||||||
|
pub guest_upload_enabled: bool,
|
||||||
|
pub guest_upload_max_size_bytes: usize,
|
||||||
pub asset_version: String,
|
pub asset_version: String,
|
||||||
pub asset_cache_max_age_seconds: u64,
|
pub asset_cache_max_age_seconds: u64,
|
||||||
pub file_cache_max_age_seconds: u64,
|
pub file_cache_max_age_seconds: u64,
|
||||||
|
pub files_public_url: Option<String>,
|
||||||
pub smtp: Option<crate::state::SmtpConfig>,
|
pub smtp: Option<crate::state::SmtpConfig>,
|
||||||
pub registration_enabled: bool,
|
pub registration_enabled: bool,
|
||||||
pub account_confirmation_required: bool,
|
pub account_confirmation_required: bool,
|
||||||
@@ -76,13 +79,17 @@ impl Config {
|
|||||||
let host = values.get("APP_HOST", "127.0.0.1").parse()?;
|
let host = values.get("APP_HOST", "127.0.0.1").parse()?;
|
||||||
let port = values.get("APP_PORT", "3000").parse()?;
|
let port = values.get("APP_PORT", "3000").parse()?;
|
||||||
let database_max_connections = values.get("DATABASE_MAX_CONNECTIONS", "8").parse()?;
|
let database_max_connections = values.get("DATABASE_MAX_CONNECTIONS", "8").parse()?;
|
||||||
let upload_max_size_mb: usize = values.get("UPLOAD_MAX_SIZE_MB", "20").parse()?;
|
let upload_max_size_mb = values.positive_u64("UPLOAD_MAX_SIZE_MB", 20)?;
|
||||||
|
let guest_upload_enabled = values.bool("GUEST_UPLOAD_ENABLED", false)?;
|
||||||
|
let guest_upload_max_size_mb = values.positive_u64("GUEST_UPLOAD_MAX_SIZE_MB", 5)?;
|
||||||
let anonymous_access_token_ttl_days =
|
let anonymous_access_token_ttl_days =
|
||||||
values.positive_i64("ANONYMOUS_ACCESS_TOKEN_TTL_DAYS", 7)?;
|
values.positive_i64("ANONYMOUS_ACCESS_TOKEN_TTL_DAYS", 7)?;
|
||||||
let user_session_ttl_days = values.positive_i64("USER_SESSION_TTL_DAYS", 3)?;
|
let user_session_ttl_days = values.positive_i64("USER_SESSION_TTL_DAYS", 3)?;
|
||||||
let unconfirmed_account_ttl_days =
|
let unconfirmed_account_ttl_days =
|
||||||
values.positive_i64("UNCONFIRMED_ACCOUNT_TTL_DAYS", 3)?;
|
values.positive_i64("UNCONFIRMED_ACCOUNT_TTL_DAYS", 3)?;
|
||||||
let files_dir = values.get("FILES_DIR", "data/files");
|
let files_dir = values.get("FILES_DIR", "data/files");
|
||||||
|
let files_public_url =
|
||||||
|
crate::file_urls::normalize_public_base(values.optional("FILES_PUBLIC_URL"))?;
|
||||||
|
|
||||||
let storage = match values
|
let storage = match values
|
||||||
.get("STORAGE_DRIVER", "local")
|
.get("STORAGE_DRIVER", "local")
|
||||||
@@ -104,10 +111,6 @@ impl Config {
|
|||||||
_ => return Err("STORAGE_DRIVER must be local or s3".into()),
|
_ => return Err("STORAGE_DRIVER must be local or s3".into()),
|
||||||
};
|
};
|
||||||
|
|
||||||
if upload_max_size_mb == 0 {
|
|
||||||
return Err("UPLOAD_MAX_SIZE_MB must be greater than 0".into());
|
|
||||||
}
|
|
||||||
|
|
||||||
let authorization_type = AuthorizationType::from_values(&values)?;
|
let authorization_type = AuthorizationType::from_values(&values)?;
|
||||||
let ldap = match authorization_type {
|
let ldap = match authorization_type {
|
||||||
AuthorizationType::Local => None,
|
AuthorizationType::Local => None,
|
||||||
@@ -164,14 +167,18 @@ impl Config {
|
|||||||
static_dir: values.get("STATIC_DIR", "static"),
|
static_dir: values.get("STATIC_DIR", "static"),
|
||||||
files_dir,
|
files_dir,
|
||||||
storage,
|
storage,
|
||||||
upload_max_size_bytes: upload_max_size_mb
|
upload_max_size_bytes: megabytes_to_bytes("UPLOAD_MAX_SIZE_MB", upload_max_size_mb)?,
|
||||||
.checked_mul(1024 * 1024)
|
guest_upload_enabled,
|
||||||
.ok_or("UPLOAD_MAX_SIZE_MB is too large")?,
|
guest_upload_max_size_bytes: megabytes_to_bytes(
|
||||||
|
"GUEST_UPLOAD_MAX_SIZE_MB",
|
||||||
|
guest_upload_max_size_mb,
|
||||||
|
)?,
|
||||||
asset_version: env!("CARGO_PKG_VERSION").to_owned(),
|
asset_version: env!("CARGO_PKG_VERSION").to_owned(),
|
||||||
asset_cache_max_age_seconds: values
|
asset_cache_max_age_seconds: values
|
||||||
.nonnegative_u64("ASSET_CACHE_MAX_AGE_SECONDS", 600)?,
|
.nonnegative_u64("ASSET_CACHE_MAX_AGE_SECONDS", 600)?,
|
||||||
file_cache_max_age_seconds: values
|
file_cache_max_age_seconds: values
|
||||||
.nonnegative_u64("FILE_CACHE_MAX_AGE_SECONDS", 600)?,
|
.nonnegative_u64("FILE_CACHE_MAX_AGE_SECONDS", 600)?,
|
||||||
|
files_public_url,
|
||||||
smtp,
|
smtp,
|
||||||
registration_enabled: values.bool("REGISTRATION_ENABLED", false)?,
|
registration_enabled: values.bool("REGISTRATION_ENABLED", false)?,
|
||||||
account_confirmation_required: values.bool("ACCOUNT_CONFIRMATION_REQUIRED", false)?,
|
account_confirmation_required: values.bool("ACCOUNT_CONFIRMATION_REQUIRED", false)?,
|
||||||
@@ -209,4 +216,39 @@ impl Config {
|
|||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn upload_body_limit_bytes(&self) -> usize {
|
||||||
|
multipart_body_limit_bytes(
|
||||||
|
self.upload_max_size_bytes,
|
||||||
|
self.guest_upload_enabled,
|
||||||
|
self.guest_upload_max_size_bytes,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn multipart_body_limit_bytes(
|
||||||
|
user_limit_bytes: usize,
|
||||||
|
guest_upload_enabled: bool,
|
||||||
|
guest_limit_bytes: usize,
|
||||||
|
) -> usize {
|
||||||
|
let file_limit = if guest_upload_enabled {
|
||||||
|
user_limit_bytes.max(guest_limit_bytes)
|
||||||
|
} else {
|
||||||
|
user_limit_bytes
|
||||||
|
};
|
||||||
|
file_limit.saturating_add(1024 * 1024)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn megabytes_to_bytes(
|
||||||
|
name: &str,
|
||||||
|
megabytes: u64,
|
||||||
|
) -> Result<usize, Box<dyn std::error::Error>> {
|
||||||
|
let bytes = megabytes
|
||||||
|
.checked_mul(1024 * 1024)
|
||||||
|
.ok_or_else(|| format!("{name} is too large"))?;
|
||||||
|
usize::try_from(bytes).map_err(|_| format!("{name} is too large").into())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../tests/config.rs"]
|
||||||
|
mod tests;
|
||||||
|
|||||||
+2
-31
@@ -79,34 +79,5 @@ fn normalize_smtp_from(value: String) -> Result<String, Box<dyn std::error::Erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "../tests/config_smtp.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn detects_security_from_standard_ports() {
|
|
||||||
assert_eq!(smtp_security_for_port(25), SmtpSecurity::None);
|
|
||||||
assert_eq!(smtp_security_for_port(465), SmtpSecurity::Tls);
|
|
||||||
assert_eq!(smtp_security_for_port(587), SmtpSecurity::StartTls);
|
|
||||||
assert_eq!(smtp_security_for_port(2525), SmtpSecurity::None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn accepts_supported_security_modes() {
|
|
||||||
assert_eq!(parse_smtp_security("none").unwrap(), SmtpSecurity::None);
|
|
||||||
assert_eq!(
|
|
||||||
parse_smtp_security("starttls").unwrap(),
|
|
||||||
SmtpSecurity::StartTls
|
|
||||||
);
|
|
||||||
assert_eq!(parse_smtp_security("tls").unwrap(), SmtpSecurity::Tls);
|
|
||||||
assert!(parse_smtp_security("auto").is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn normalizes_smtp_from() {
|
|
||||||
assert_eq!(
|
|
||||||
normalize_smtp_from(" \"RustPad <rustpad@notes.example>\" ".to_owned()).unwrap(),
|
|
||||||
"RustPad <rustpad@notes.example>"
|
|
||||||
);
|
|
||||||
assert!(normalize_smtp_from("RustPad".to_owned()).is_err());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -16,8 +16,11 @@ const KNOWN_CONFIG_KEYS: &[&str] = &[
|
|||||||
"DATABASE_MAX_CONNECTIONS",
|
"DATABASE_MAX_CONNECTIONS",
|
||||||
"STATIC_DIR",
|
"STATIC_DIR",
|
||||||
"FILES_DIR",
|
"FILES_DIR",
|
||||||
|
"FILES_PUBLIC_URL",
|
||||||
"STORAGE_DRIVER",
|
"STORAGE_DRIVER",
|
||||||
"UPLOAD_MAX_SIZE_MB",
|
"UPLOAD_MAX_SIZE_MB",
|
||||||
|
"GUEST_UPLOAD_ENABLED",
|
||||||
|
"GUEST_UPLOAD_MAX_SIZE_MB",
|
||||||
"ASSET_CACHE_MAX_AGE_SECONDS",
|
"ASSET_CACHE_MAX_AGE_SECONDS",
|
||||||
"FILE_CACHE_MAX_AGE_SECONDS",
|
"FILE_CACHE_MAX_AGE_SECONDS",
|
||||||
"REGISTRATION_ENABLED",
|
"REGISTRATION_ENABLED",
|
||||||
|
|||||||
+12
-4
@@ -29,10 +29,18 @@ impl Database {
|
|||||||
sqlx::any::install_default_drivers();
|
sqlx::any::install_default_drivers();
|
||||||
let kind = DatabaseKind::from_url(url)?;
|
let kind = DatabaseKind::from_url(url)?;
|
||||||
debug!(?kind, max_connections, "initializing database pool");
|
debug!(?kind, max_connections, "initializing database pool");
|
||||||
let pool = AnyPoolOptions::new()
|
let mut options = AnyPoolOptions::new().max_connections(max_connections);
|
||||||
.max_connections(max_connections)
|
if kind == DatabaseKind::MySql {
|
||||||
.connect(url)
|
options = options.after_connect(|connection, _metadata| {
|
||||||
.await?;
|
Box::pin(async move {
|
||||||
|
sqlx::query("SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci")
|
||||||
|
.execute(connection)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let pool = options.connect(url).await?;
|
||||||
if kind == DatabaseKind::Sqlite {
|
if kind == DatabaseKind::Sqlite {
|
||||||
debug!("applying SQLite connection pragmas");
|
debug!("applying SQLite connection pragmas");
|
||||||
sqlx::query(queries::get(kind, queries::SQLITE_FOREIGN_KEYS_ON))
|
sqlx::query(queries::get(kind, queries::SQLITE_FOREIGN_KEYS_ON))
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ pub struct EditorPreferences {
|
|||||||
pub editor_line_numbers: bool,
|
pub editor_line_numbers: bool,
|
||||||
pub preview_line_numbers: bool,
|
pub preview_line_numbers: bool,
|
||||||
pub line_links: bool,
|
pub line_links: bool,
|
||||||
|
pub toolbar_collapsed: bool,
|
||||||
pub font_family: String,
|
pub font_family: String,
|
||||||
pub font_size: i64,
|
pub font_size: i64,
|
||||||
}
|
}
|
||||||
@@ -26,6 +27,7 @@ impl Default for EditorPreferences {
|
|||||||
editor_line_numbers: true,
|
editor_line_numbers: true,
|
||||||
preview_line_numbers: false,
|
preview_line_numbers: false,
|
||||||
line_links: false,
|
line_links: false,
|
||||||
|
toolbar_collapsed: false,
|
||||||
font_family: "mono".into(),
|
font_family: "mono".into(),
|
||||||
font_size: 14,
|
font_size: 14,
|
||||||
}
|
}
|
||||||
@@ -78,14 +80,11 @@ pub async fn load_editor_preferences(
|
|||||||
user_id: i64,
|
user_id: i64,
|
||||||
resource: EditorPreferenceResource,
|
resource: EditorPreferenceResource,
|
||||||
) -> Result<Option<EditorPreferences>, sqlx::Error> {
|
) -> Result<Option<EditorPreferences>, sqlx::Error> {
|
||||||
let Some(row) = sqlx::query(queries::get(
|
let Some(row) = sqlx::query(queries::get(pool.kind(), preference_select_query(resource)))
|
||||||
pool.kind(),
|
.bind(user_id)
|
||||||
preference_select_query(resource),
|
.bind(resource_id(resource))
|
||||||
))
|
.fetch_optional(pool.pool())
|
||||||
.bind(user_id)
|
.await?
|
||||||
.bind(resource_id(resource))
|
|
||||||
.fetch_optional(pool.pool())
|
|
||||||
.await?
|
|
||||||
else {
|
else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
@@ -95,14 +94,15 @@ pub async fn load_editor_preferences(
|
|||||||
editor_line_numbers: row.try_get::<i64, _>(1)? != 0,
|
editor_line_numbers: row.try_get::<i64, _>(1)? != 0,
|
||||||
preview_line_numbers: row.try_get::<i64, _>(2)? != 0,
|
preview_line_numbers: row.try_get::<i64, _>(2)? != 0,
|
||||||
line_links: row.try_get::<i64, _>(3)? != 0,
|
line_links: row.try_get::<i64, _>(3)? != 0,
|
||||||
font_family: crate::row_decode::text(&row, 4)?,
|
toolbar_collapsed: row.try_get::<i64, _>(4)? != 0,
|
||||||
font_size: row.try_get(5)?,
|
font_family: crate::row_decode::text(&row, 5)?,
|
||||||
|
font_size: row.try_get(6)?,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn save_editor_configuration(
|
pub async fn save_editor_configuration(
|
||||||
pool: &Database,
|
pool: &Database,
|
||||||
user_id: i64,
|
user_id: Option<i64>,
|
||||||
resource: EditorPreferenceResource,
|
resource: EditorPreferenceResource,
|
||||||
preferences: Option<&EditorPreferences>,
|
preferences: Option<&EditorPreferences>,
|
||||||
resource_settings: Option<(&str, &str, &ResourceEditorSettings)>,
|
resource_settings: Option<(&str, &str, &ResourceEditorSettings)>,
|
||||||
@@ -110,20 +110,21 @@ pub async fn save_editor_configuration(
|
|||||||
let mut tx = pool.pool().begin().await?;
|
let mut tx = pool.pool().begin().await?;
|
||||||
|
|
||||||
if let Some(preferences) = preferences {
|
if let Some(preferences) = preferences {
|
||||||
sqlx::query(queries::get(
|
let user_id = user_id.ok_or_else(|| {
|
||||||
pool.kind(),
|
sqlx::Error::Protocol("user id is required for personal editor preferences".into())
|
||||||
preference_upsert_query(resource),
|
})?;
|
||||||
))
|
sqlx::query(queries::get(pool.kind(), preference_upsert_query(resource)))
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.bind(resource_id(resource))
|
.bind(resource_id(resource))
|
||||||
.bind(preferences.compact_view)
|
.bind(preferences.compact_view)
|
||||||
.bind(preferences.editor_line_numbers)
|
.bind(preferences.editor_line_numbers)
|
||||||
.bind(preferences.preview_line_numbers)
|
.bind(preferences.preview_line_numbers)
|
||||||
.bind(preferences.line_links)
|
.bind(preferences.line_links)
|
||||||
.bind(&preferences.font_family)
|
.bind(preferences.toolbar_collapsed)
|
||||||
.bind(preferences.font_size)
|
.bind(&preferences.font_family)
|
||||||
.execute(&mut *tx)
|
.bind(preferences.font_size)
|
||||||
.await?;
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some((resource_kind, resource_slug, settings)) = resource_settings {
|
if let Some((resource_kind, resource_slug, settings)) = resource_settings {
|
||||||
|
|||||||
+188
-4
@@ -52,6 +52,7 @@ pub struct Workspace {
|
|||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
pub is_private: i64,
|
pub is_private: i64,
|
||||||
|
pub created_by_guest_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
@@ -68,6 +69,15 @@ pub struct Note {
|
|||||||
pub owner_map: String,
|
pub owner_map: String,
|
||||||
pub protected: bool,
|
pub protected: bool,
|
||||||
pub created_by: Option<String>,
|
pub created_by: Option<String>,
|
||||||
|
#[serde(skip_serializing)]
|
||||||
|
pub created_by_guest_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CollaborationSnapshot {
|
||||||
|
pub content: String,
|
||||||
|
pub owner_map: String,
|
||||||
|
pub revision_id: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, FromRow)]
|
#[derive(Debug, Clone, FromRow)]
|
||||||
@@ -82,6 +92,7 @@ struct SqliteNote {
|
|||||||
owner_map: String,
|
owner_map: String,
|
||||||
protected: i64,
|
protected: i64,
|
||||||
created_by: Option<String>,
|
created_by: Option<String>,
|
||||||
|
created_by_guest_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<SqliteNote> for Note {
|
impl From<SqliteNote> for Note {
|
||||||
@@ -97,6 +108,7 @@ impl From<SqliteNote> for Note {
|
|||||||
owner_map: value.owner_map,
|
owner_map: value.owner_map,
|
||||||
protected: value.protected != 0,
|
protected: value.protected != 0,
|
||||||
created_by: value.created_by,
|
created_by: value.created_by,
|
||||||
|
created_by_guest_id: value.created_by_guest_id,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -131,6 +143,7 @@ pub async fn create_workspace(
|
|||||||
slug: &str,
|
slug: &str,
|
||||||
title: &str,
|
title: &str,
|
||||||
password: Option<&str>,
|
password: Option<&str>,
|
||||||
|
created_by_guest_id: Option<&str>,
|
||||||
) -> Result<Workspace, sqlx::Error> {
|
) -> Result<Workspace, sqlx::Error> {
|
||||||
let password_hash = password
|
let password_hash = password
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
@@ -139,6 +152,7 @@ pub async fn create_workspace(
|
|||||||
.bind(slug)
|
.bind(slug)
|
||||||
.bind(title)
|
.bind(title)
|
||||||
.bind(password_hash)
|
.bind(password_hash)
|
||||||
|
.bind(created_by_guest_id)
|
||||||
.execute(pool.pool())
|
.execute(pool.pool())
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -148,12 +162,29 @@ pub async fn create_workspace(
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn set_workspace_password(
|
||||||
|
pool: &Database,
|
||||||
|
slug: &str,
|
||||||
|
password: &str,
|
||||||
|
) -> Result<(), sqlx::Error> {
|
||||||
|
let password_hash = hash_password(password);
|
||||||
|
sqlx::query(queries::get(
|
||||||
|
pool.kind(),
|
||||||
|
queries::USER_SET_WORKSPACE_PASSWORD,
|
||||||
|
))
|
||||||
|
.bind(password_hash)
|
||||||
|
.bind(slug)
|
||||||
|
.execute(pool.pool())
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn verify_workspace_password(workspace: &Workspace, password: Option<&str>) -> bool {
|
pub fn verify_workspace_password(workspace: &Workspace, password: Option<&str>) -> bool {
|
||||||
match (
|
match (
|
||||||
&workspace.password_hash,
|
&workspace.password_hash,
|
||||||
password.filter(|value| !value.is_empty()),
|
password.filter(|value| !value.is_empty()),
|
||||||
) {
|
) {
|
||||||
(None, _) => true,
|
(None, _) => false,
|
||||||
(Some(hash), Some(password)) => PasswordHash::new(hash)
|
(Some(hash), Some(password)) => PasswordHash::new(hash)
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|parsed| {
|
.and_then(|parsed| {
|
||||||
@@ -260,6 +291,7 @@ pub async fn create_note(
|
|||||||
title: &str,
|
title: &str,
|
||||||
protected: bool,
|
protected: bool,
|
||||||
created_by: Option<&str>,
|
created_by: Option<&str>,
|
||||||
|
created_by_guest_id: Option<&str>,
|
||||||
) -> Result<Note, sqlx::Error> {
|
) -> Result<Note, sqlx::Error> {
|
||||||
sqlx::query(queries::get(pool.kind(), queries::Q005))
|
sqlx::query(queries::get(pool.kind(), queries::Q005))
|
||||||
.bind(workspace_id)
|
.bind(workspace_id)
|
||||||
@@ -267,6 +299,7 @@ pub async fn create_note(
|
|||||||
.bind(title)
|
.bind(title)
|
||||||
.bind(protected)
|
.bind(protected)
|
||||||
.bind(created_by)
|
.bind(created_by)
|
||||||
|
.bind(created_by_guest_id)
|
||||||
.execute(pool.pool())
|
.execute(pool.pool())
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -310,6 +343,68 @@ pub async fn save_revision(
|
|||||||
Ok((revision_id, updated_at))
|
Ok((revision_id, updated_at))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn save_collaborative_revision(
|
||||||
|
pool: &Database,
|
||||||
|
note_id: i64,
|
||||||
|
workspace_id: i64,
|
||||||
|
content: &str,
|
||||||
|
author: Option<&str>,
|
||||||
|
owner_map: &str,
|
||||||
|
collaboration_client_id: &str,
|
||||||
|
collaboration_update_id: i64,
|
||||||
|
) -> Result<(i64, String), sqlx::Error> {
|
||||||
|
let mut tx = pool.pool().begin().await?;
|
||||||
|
sqlx::query(queries::get(pool.kind(), queries::Q006))
|
||||||
|
.bind(content)
|
||||||
|
.bind(owner_map)
|
||||||
|
.bind(note_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
sqlx::query(queries::get(pool.kind(), queries::Q007))
|
||||||
|
.bind(workspace_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
sqlx::query(queries::get(pool.kind(), queries::Q054))
|
||||||
|
.bind(note_id)
|
||||||
|
.bind(content)
|
||||||
|
.bind(author)
|
||||||
|
.bind(owner_map)
|
||||||
|
.bind(collaboration_client_id)
|
||||||
|
.bind(collaboration_update_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
let revision_id = inserted_id(pool.kind(), &mut tx, "note_revisions").await?;
|
||||||
|
let updated_at: String = sqlx::query_scalar(queries::get(pool.kind(), queries::Q009))
|
||||||
|
.bind(note_id)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok((revision_id, updated_at))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn latest_note_collaboration_update_id(
|
||||||
|
pool: &Database,
|
||||||
|
note_id: i64,
|
||||||
|
collaboration_client_id: &str,
|
||||||
|
) -> Result<Option<u64>, sqlx::Error> {
|
||||||
|
let update_id = sqlx::query_scalar::<_, Option<i64>>(queries::get(pool.kind(), queries::Q056))
|
||||||
|
.bind(note_id)
|
||||||
|
.bind(collaboration_client_id)
|
||||||
|
.fetch_one(pool.pool())
|
||||||
|
.await?;
|
||||||
|
Ok(update_id.and_then(|value| u64::try_from(value).ok()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn note_collaboration_snapshot(
|
||||||
|
pool: &Database,
|
||||||
|
note_id: i64,
|
||||||
|
) -> Result<CollaborationSnapshot, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, CollaborationSnapshot>(queries::get(pool.kind(), queries::Q058))
|
||||||
|
.bind(note_id)
|
||||||
|
.fetch_one(pool.pool())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn list_revisions(pool: &Database, note_id: i64) -> Result<Vec<Revision>, sqlx::Error> {
|
pub async fn list_revisions(pool: &Database, note_id: i64) -> Result<Vec<Revision>, sqlx::Error> {
|
||||||
sqlx::query_as::<_, Revision>(queries::get(pool.kind(), queries::Q010))
|
sqlx::query_as::<_, Revision>(queries::get(pool.kind(), queries::Q010))
|
||||||
.bind(note_id)
|
.bind(note_id)
|
||||||
@@ -386,8 +481,8 @@ pub struct Pad {
|
|||||||
pub password_hash: Option<String>,
|
pub password_hash: Option<String>,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
pub owner_map: String,
|
|
||||||
pub is_private: i64,
|
pub is_private: i64,
|
||||||
|
pub created_by_guest_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_pad(pool: &Database, slug: &str) -> Result<Option<Pad>, sqlx::Error> {
|
pub async fn find_pad(pool: &Database, slug: &str) -> Result<Option<Pad>, sqlx::Error> {
|
||||||
@@ -402,6 +497,7 @@ pub async fn create_pad(
|
|||||||
slug: &str,
|
slug: &str,
|
||||||
title: &str,
|
title: &str,
|
||||||
password: Option<&str>,
|
password: Option<&str>,
|
||||||
|
created_by_guest_id: Option<&str>,
|
||||||
) -> Result<Pad, sqlx::Error> {
|
) -> Result<Pad, sqlx::Error> {
|
||||||
let password_hash = password
|
let password_hash = password
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
@@ -410,6 +506,7 @@ pub async fn create_pad(
|
|||||||
.bind(slug)
|
.bind(slug)
|
||||||
.bind(title)
|
.bind(title)
|
||||||
.bind(password_hash)
|
.bind(password_hash)
|
||||||
|
.bind(created_by_guest_id)
|
||||||
.execute(pool.pool())
|
.execute(pool.pool())
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -419,12 +516,26 @@ pub async fn create_pad(
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn set_pad_password(
|
||||||
|
pool: &Database,
|
||||||
|
slug: &str,
|
||||||
|
password: &str,
|
||||||
|
) -> Result<(), sqlx::Error> {
|
||||||
|
let password_hash = hash_password(password);
|
||||||
|
sqlx::query(queries::get(pool.kind(), queries::USER_SET_PAD_PASSWORD))
|
||||||
|
.bind(password_hash)
|
||||||
|
.bind(slug)
|
||||||
|
.execute(pool.pool())
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn verify_pad_password(pad: &Pad, password: Option<&str>) -> bool {
|
pub fn verify_pad_password(pad: &Pad, password: Option<&str>) -> bool {
|
||||||
match (
|
match (
|
||||||
&pad.password_hash,
|
&pad.password_hash,
|
||||||
password.filter(|value| !value.is_empty()),
|
password.filter(|value| !value.is_empty()),
|
||||||
) {
|
) {
|
||||||
(None, _) => true,
|
(None, _) => false,
|
||||||
(Some(hash), Some(password)) => PasswordHash::new(hash)
|
(Some(hash), Some(password)) => PasswordHash::new(hash)
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|parsed| {
|
.and_then(|parsed| {
|
||||||
@@ -467,6 +578,63 @@ pub async fn save_pad_revision(
|
|||||||
Ok((revision_id, updated_at))
|
Ok((revision_id, updated_at))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn save_collaborative_pad_revision(
|
||||||
|
pool: &Database,
|
||||||
|
pad_id: i64,
|
||||||
|
content: &str,
|
||||||
|
author: Option<&str>,
|
||||||
|
owner_map: &str,
|
||||||
|
collaboration_client_id: &str,
|
||||||
|
collaboration_update_id: i64,
|
||||||
|
) -> Result<(i64, String), sqlx::Error> {
|
||||||
|
let mut tx = pool.pool().begin().await?;
|
||||||
|
sqlx::query(queries::get(pool.kind(), queries::Q013))
|
||||||
|
.bind(content)
|
||||||
|
.bind(owner_map)
|
||||||
|
.bind(pad_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
sqlx::query(queries::get(pool.kind(), queries::Q055))
|
||||||
|
.bind(pad_id)
|
||||||
|
.bind(content)
|
||||||
|
.bind(author)
|
||||||
|
.bind(owner_map)
|
||||||
|
.bind(collaboration_client_id)
|
||||||
|
.bind(collaboration_update_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
let revision_id = inserted_id(pool.kind(), &mut tx, "revisions").await?;
|
||||||
|
let updated_at: String = sqlx::query_scalar(queries::get(pool.kind(), queries::Q015))
|
||||||
|
.bind(pad_id)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok((revision_id, updated_at))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn latest_pad_collaboration_update_id(
|
||||||
|
pool: &Database,
|
||||||
|
pad_id: i64,
|
||||||
|
collaboration_client_id: &str,
|
||||||
|
) -> Result<Option<u64>, sqlx::Error> {
|
||||||
|
let update_id = sqlx::query_scalar::<_, Option<i64>>(queries::get(pool.kind(), queries::Q057))
|
||||||
|
.bind(pad_id)
|
||||||
|
.bind(collaboration_client_id)
|
||||||
|
.fetch_one(pool.pool())
|
||||||
|
.await?;
|
||||||
|
Ok(update_id.and_then(|value| u64::try_from(value).ok()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn pad_collaboration_snapshot(
|
||||||
|
pool: &Database,
|
||||||
|
pad_id: i64,
|
||||||
|
) -> Result<CollaborationSnapshot, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, CollaborationSnapshot>(queries::get(pool.kind(), queries::Q059))
|
||||||
|
.bind(pad_id)
|
||||||
|
.fetch_one(pool.pool())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn list_pad_revisions(
|
pub async fn list_pad_revisions(
|
||||||
pool: &Database,
|
pool: &Database,
|
||||||
pad_id: i64,
|
pad_id: i64,
|
||||||
@@ -477,6 +645,16 @@ pub async fn list_pad_revisions(
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl<'r> sqlx::FromRow<'r, AnyRow> for CollaborationSnapshot {
|
||||||
|
fn from_row(row: &'r AnyRow) -> Result<Self, sqlx::Error> {
|
||||||
|
Ok(Self {
|
||||||
|
content: crate::row_decode::text(row, "content")?,
|
||||||
|
owner_map: crate::row_decode::text(row, "owner_map")?,
|
||||||
|
revision_id: row.try_get("revision_id")?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl<'r> sqlx::FromRow<'r, AnyRow> for Workspace {
|
impl<'r> sqlx::FromRow<'r, AnyRow> for Workspace {
|
||||||
fn from_row(row: &'r AnyRow) -> Result<Self, sqlx::Error> {
|
fn from_row(row: &'r AnyRow) -> Result<Self, sqlx::Error> {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
@@ -487,6 +665,7 @@ impl<'r> sqlx::FromRow<'r, AnyRow> for Workspace {
|
|||||||
created_at: crate::row_decode::text(row, "created_at")?,
|
created_at: crate::row_decode::text(row, "created_at")?,
|
||||||
updated_at: crate::row_decode::text(row, "updated_at")?,
|
updated_at: crate::row_decode::text(row, "updated_at")?,
|
||||||
is_private: row.try_get("is_private")?,
|
is_private: row.try_get("is_private")?,
|
||||||
|
created_by_guest_id: crate::row_decode::optional_text(row, "created_by_guest_id")?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -504,6 +683,7 @@ impl<'r> sqlx::FromRow<'r, AnyRow> for Note {
|
|||||||
owner_map: crate::row_decode::text(row, "owner_map")?,
|
owner_map: crate::row_decode::text(row, "owner_map")?,
|
||||||
protected: protected != 0,
|
protected: protected != 0,
|
||||||
created_by: crate::row_decode::optional_text(row, "created_by")?,
|
created_by: crate::row_decode::optional_text(row, "created_by")?,
|
||||||
|
created_by_guest_id: crate::row_decode::optional_text(row, "created_by_guest_id")?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -528,8 +708,12 @@ impl<'r> sqlx::FromRow<'r, AnyRow> for Pad {
|
|||||||
password_hash: crate::row_decode::optional_text(row, "password_hash")?,
|
password_hash: crate::row_decode::optional_text(row, "password_hash")?,
|
||||||
created_at: crate::row_decode::text(row, "created_at")?,
|
created_at: crate::row_decode::text(row, "created_at")?,
|
||||||
updated_at: crate::row_decode::text(row, "updated_at")?,
|
updated_at: crate::row_decode::text(row, "updated_at")?,
|
||||||
owner_map: crate::row_decode::text(row, "owner_map")?,
|
|
||||||
is_private: row.try_get("is_private")?,
|
is_private: row.try_get("is_private")?,
|
||||||
|
created_by_guest_id: crate::row_decode::optional_text(row, "created_by_guest_id")?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../tests/db.rs"]
|
||||||
|
mod password_verification_tests;
|
||||||
|
|||||||
+44
-42
@@ -15,6 +15,10 @@ pub struct PublishedPage {
|
|||||||
pub pad_id: Option<i64>,
|
pub pad_id: Option<i64>,
|
||||||
pub note_id: Option<i64>,
|
pub note_id: Option<i64>,
|
||||||
pub allow_task_updates: bool,
|
pub allow_task_updates: bool,
|
||||||
|
pub resource_slug: String,
|
||||||
|
pub workspace_id: Option<i64>,
|
||||||
|
pub workspace_slug: Option<String>,
|
||||||
|
pub owner_map: String,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub content: String,
|
pub content: String,
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
@@ -26,6 +30,10 @@ struct PublishedPageRow {
|
|||||||
pad_id: Option<i64>,
|
pad_id: Option<i64>,
|
||||||
note_id: Option<i64>,
|
note_id: Option<i64>,
|
||||||
allow_task_updates: i64,
|
allow_task_updates: i64,
|
||||||
|
resource_slug: String,
|
||||||
|
workspace_id: Option<i64>,
|
||||||
|
workspace_slug: Option<String>,
|
||||||
|
owner_map: String,
|
||||||
title: String,
|
title: String,
|
||||||
content: String,
|
content: String,
|
||||||
updated_at: String,
|
updated_at: String,
|
||||||
@@ -37,6 +45,10 @@ struct PostgresPublishedPageRow {
|
|||||||
pad_id: Option<i64>,
|
pad_id: Option<i64>,
|
||||||
note_id: Option<i64>,
|
note_id: Option<i64>,
|
||||||
allow_task_updates: bool,
|
allow_task_updates: bool,
|
||||||
|
resource_slug: String,
|
||||||
|
workspace_id: Option<i64>,
|
||||||
|
workspace_slug: Option<String>,
|
||||||
|
owner_map: String,
|
||||||
title: String,
|
title: String,
|
||||||
content: String,
|
content: String,
|
||||||
updated_at: String,
|
updated_at: String,
|
||||||
@@ -49,6 +61,10 @@ impl From<PostgresPublishedPageRow> for PublishedPage {
|
|||||||
pad_id: value.pad_id,
|
pad_id: value.pad_id,
|
||||||
note_id: value.note_id,
|
note_id: value.note_id,
|
||||||
allow_task_updates: value.allow_task_updates,
|
allow_task_updates: value.allow_task_updates,
|
||||||
|
resource_slug: value.resource_slug,
|
||||||
|
workspace_id: value.workspace_id,
|
||||||
|
workspace_slug: value.workspace_slug,
|
||||||
|
owner_map: value.owner_map,
|
||||||
title: value.title,
|
title: value.title,
|
||||||
content: value.content,
|
content: value.content,
|
||||||
updated_at: value.updated_at,
|
updated_at: value.updated_at,
|
||||||
@@ -62,6 +78,10 @@ impl From<PublishedPageRow> for PublishedPage {
|
|||||||
pad_id: value.pad_id,
|
pad_id: value.pad_id,
|
||||||
note_id: value.note_id,
|
note_id: value.note_id,
|
||||||
allow_task_updates: value.allow_task_updates != 0,
|
allow_task_updates: value.allow_task_updates != 0,
|
||||||
|
resource_slug: value.resource_slug,
|
||||||
|
workspace_id: value.workspace_id,
|
||||||
|
workspace_slug: value.workspace_slug,
|
||||||
|
owner_map: value.owner_map,
|
||||||
title: value.title,
|
title: value.title,
|
||||||
content: value.content,
|
content: value.content,
|
||||||
updated_at: value.updated_at,
|
updated_at: value.updated_at,
|
||||||
@@ -310,29 +330,23 @@ pub async fn set_note_public_page_unprotected(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_public_task(
|
pub fn updated_public_task_content(
|
||||||
pool: &Database,
|
content: &str,
|
||||||
token: &str,
|
|
||||||
source_line: usize,
|
source_line: usize,
|
||||||
checked: bool,
|
checked: bool,
|
||||||
) -> Result<Option<PublishedPage>, sqlx::Error> {
|
) -> Option<String> {
|
||||||
let Some(mut page) = find_published_page(pool, token).await? else {
|
if source_line == 0 {
|
||||||
return Ok(None);
|
return None;
|
||||||
};
|
|
||||||
if !page.allow_task_updates || source_line == 0 {
|
|
||||||
return Ok(Some(page));
|
|
||||||
}
|
}
|
||||||
let mut lines: Vec<String> = page.content.split('\n').map(str::to_owned).collect();
|
let mut lines: Vec<String> = content.split('\n').map(str::to_owned).collect();
|
||||||
let Some(line) = lines.get_mut(source_line - 1) else {
|
let line = lines.get_mut(source_line - 1)?;
|
||||||
return Ok(Some(page));
|
|
||||||
};
|
|
||||||
let bytes = line.as_bytes();
|
let bytes = line.as_bytes();
|
||||||
let mut i = 0usize;
|
let mut i = 0usize;
|
||||||
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
|
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
|
||||||
i += 1;
|
i += 1;
|
||||||
}
|
}
|
||||||
if i >= bytes.len() || !matches!(bytes[i], b'-' | b'*' | b'+') {
|
if i >= bytes.len() || !matches!(bytes[i], b'-' | b'*' | b'+') {
|
||||||
return Ok(Some(page));
|
return None;
|
||||||
}
|
}
|
||||||
i += 1;
|
i += 1;
|
||||||
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
|
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
|
||||||
@@ -343,24 +357,10 @@ pub async fn update_public_task(
|
|||||||
|| !matches!(bytes[i + 1], b' ' | b'x' | b'X')
|
|| !matches!(bytes[i + 1], b' ' | b'x' | b'X')
|
||||||
|| bytes[i + 2] != b']'
|
|| bytes[i + 2] != b']'
|
||||||
{
|
{
|
||||||
return Ok(Some(page));
|
return None;
|
||||||
}
|
}
|
||||||
line.replace_range(i + 1..i + 2, if checked { "x" } else { " " });
|
line.replace_range(i + 1..i + 2, if checked { "x" } else { " " });
|
||||||
page.content = lines.join("\n");
|
Some(lines.join("\n"))
|
||||||
if let Some(id) = page.pad_id {
|
|
||||||
sqlx::query(queries::get(pool.kind(), queries::Q042))
|
|
||||||
.bind(&page.content)
|
|
||||||
.bind(id)
|
|
||||||
.execute(pool.pool())
|
|
||||||
.await?;
|
|
||||||
} else if let Some(id) = page.note_id {
|
|
||||||
sqlx::query(queries::get(pool.kind(), queries::Q043))
|
|
||||||
.bind(&page.content)
|
|
||||||
.bind(id)
|
|
||||||
.execute(pool.pool())
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
find_published_page(pool, token).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn pad_file_token(pool: &Database, pad_id: i64) -> Result<String, sqlx::Error> {
|
pub async fn pad_file_token(pool: &Database, pad_id: i64) -> Result<String, sqlx::Error> {
|
||||||
@@ -416,6 +416,10 @@ impl<'r> sqlx::FromRow<'r, AnyRow> for PublishedPageRow {
|
|||||||
pad_id: row.try_get("pad_id")?,
|
pad_id: row.try_get("pad_id")?,
|
||||||
note_id: row.try_get("note_id")?,
|
note_id: row.try_get("note_id")?,
|
||||||
allow_task_updates: row.try_get("allow_task_updates")?,
|
allow_task_updates: row.try_get("allow_task_updates")?,
|
||||||
|
resource_slug: crate::row_decode::text(row, "resource_slug")?,
|
||||||
|
workspace_id: row.try_get("workspace_id")?,
|
||||||
|
workspace_slug: crate::row_decode::optional_text(row, "workspace_slug")?,
|
||||||
|
owner_map: crate::row_decode::text(row, "owner_map")?,
|
||||||
title: crate::row_decode::text(row, "title")?,
|
title: crate::row_decode::text(row, "title")?,
|
||||||
content: crate::row_decode::text(row, "content")?,
|
content: crate::row_decode::text(row, "content")?,
|
||||||
updated_at: crate::row_decode::text(row, "updated_at")?,
|
updated_at: crate::row_decode::text(row, "updated_at")?,
|
||||||
@@ -429,6 +433,10 @@ impl<'r> sqlx::FromRow<'r, AnyRow> for PostgresPublishedPageRow {
|
|||||||
pad_id: row.try_get("pad_id")?,
|
pad_id: row.try_get("pad_id")?,
|
||||||
note_id: row.try_get("note_id")?,
|
note_id: row.try_get("note_id")?,
|
||||||
allow_task_updates: row.try_get("allow_task_updates")?,
|
allow_task_updates: row.try_get("allow_task_updates")?,
|
||||||
|
resource_slug: crate::row_decode::text(row, "resource_slug")?,
|
||||||
|
workspace_id: row.try_get("workspace_id")?,
|
||||||
|
workspace_slug: crate::row_decode::optional_text(row, "workspace_slug")?,
|
||||||
|
owner_map: crate::row_decode::text(row, "owner_map")?,
|
||||||
title: crate::row_decode::text(row, "title")?,
|
title: crate::row_decode::text(row, "title")?,
|
||||||
content: crate::row_decode::text(row, "content")?,
|
content: crate::row_decode::text(row, "content")?,
|
||||||
updated_at: crate::row_decode::text(row, "updated_at")?,
|
updated_at: crate::row_decode::text(row, "updated_at")?,
|
||||||
@@ -437,17 +445,14 @@ impl<'r> sqlx::FromRow<'r, AnyRow> for PostgresPublishedPageRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn pad_public_page_disabled(pool: &Database, pad_id: i64) -> Result<bool, sqlx::Error> {
|
pub async fn pad_public_page_disabled(pool: &Database, pad_id: i64) -> Result<bool, sqlx::Error> {
|
||||||
let sql = match pool.kind() {
|
let query = queries::get(pool.kind(), queries::PAD_PUBLIC_PAGE_DISABLED);
|
||||||
DatabaseKind::Postgres => "SELECT public_page_disabled FROM pads WHERE id = $1",
|
|
||||||
_ => "SELECT public_page_disabled FROM pads WHERE id = ?",
|
|
||||||
};
|
|
||||||
if pool.kind() == DatabaseKind::Postgres {
|
if pool.kind() == DatabaseKind::Postgres {
|
||||||
return Ok(sqlx::query_scalar::<_, bool>(sql)
|
return Ok(sqlx::query_scalar::<_, bool>(query)
|
||||||
.bind(pad_id)
|
.bind(pad_id)
|
||||||
.fetch_one(pool.pool())
|
.fetch_one(pool.pool())
|
||||||
.await?);
|
.await?);
|
||||||
}
|
}
|
||||||
Ok(sqlx::query_scalar::<_, i64>(sql)
|
Ok(sqlx::query_scalar::<_, i64>(query)
|
||||||
.bind(pad_id)
|
.bind(pad_id)
|
||||||
.fetch_one(pool.pool())
|
.fetch_one(pool.pool())
|
||||||
.await?
|
.await?
|
||||||
@@ -455,17 +460,14 @@ pub async fn pad_public_page_disabled(pool: &Database, pad_id: i64) -> Result<bo
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn note_public_page_disabled(pool: &Database, note_id: i64) -> Result<bool, sqlx::Error> {
|
pub async fn note_public_page_disabled(pool: &Database, note_id: i64) -> Result<bool, sqlx::Error> {
|
||||||
let sql = match pool.kind() {
|
let query = queries::get(pool.kind(), queries::NOTE_PUBLIC_PAGE_DISABLED);
|
||||||
DatabaseKind::Postgres => "SELECT public_page_disabled FROM notes WHERE id = $1",
|
|
||||||
_ => "SELECT public_page_disabled FROM notes WHERE id = ?",
|
|
||||||
};
|
|
||||||
if pool.kind() == DatabaseKind::Postgres {
|
if pool.kind() == DatabaseKind::Postgres {
|
||||||
return Ok(sqlx::query_scalar::<_, bool>(sql)
|
return Ok(sqlx::query_scalar::<_, bool>(query)
|
||||||
.bind(note_id)
|
.bind(note_id)
|
||||||
.fetch_one(pool.pool())
|
.fetch_one(pool.pool())
|
||||||
.await?);
|
.await?);
|
||||||
}
|
}
|
||||||
Ok(sqlx::query_scalar::<_, i64>(sql)
|
Ok(sqlx::query_scalar::<_, i64>(query)
|
||||||
.bind(note_id)
|
.bind(note_id)
|
||||||
.fetch_one(pool.pool())
|
.fetch_one(pool.pool())
|
||||||
.await?
|
.await?
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
||||||
|
* Source-Available Code / Dual-Licensed.
|
||||||
|
*
|
||||||
|
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||||
|
* Commercial or production use requires a valid paid license.
|
||||||
|
* See LICENSE file in repository root for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use std::error::Error;
|
||||||
|
|
||||||
|
const FILE_ROUTE_PREFIX: &str = "/f/";
|
||||||
|
|
||||||
|
pub fn normalize_public_base(value: Option<String>) -> Result<Option<String>, Box<dyn Error>> {
|
||||||
|
let Some(value) = value else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let value = value.trim();
|
||||||
|
if value.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let candidate = if value.starts_with("http://") || value.starts_with("https://") {
|
||||||
|
value.trim_end_matches('/').to_owned()
|
||||||
|
} else {
|
||||||
|
format!("https://{}", value.trim_end_matches('/'))
|
||||||
|
};
|
||||||
|
let authority = candidate
|
||||||
|
.strip_prefix("https://")
|
||||||
|
.or_else(|| candidate.strip_prefix("http://"))
|
||||||
|
.ok_or("FILES_PUBLIC_URL must use http:// or https://")?;
|
||||||
|
|
||||||
|
if authority.is_empty()
|
||||||
|
|| authority.chars().any(|character| {
|
||||||
|
matches!(character, '/' | '\\' | '?' | '#' | '@') || character.is_whitespace()
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return Err(
|
||||||
|
"FILES_PUBLIC_URL must be a domain or HTTP(S) origin without a path, query, credentials, or fragment"
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Some(candidate))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn canonical_file_path(value: &str) -> Option<String> {
|
||||||
|
let value = value.trim();
|
||||||
|
let path = if value.starts_with('/') {
|
||||||
|
value
|
||||||
|
} else {
|
||||||
|
let (_, after_scheme) = value.split_once("://")?;
|
||||||
|
let path_start = after_scheme.find('/')?;
|
||||||
|
&after_scheme[path_start..]
|
||||||
|
};
|
||||||
|
let path = path
|
||||||
|
.split(|character| matches!(character, '?' | '#'))
|
||||||
|
.next()
|
||||||
|
.unwrap_or(path);
|
||||||
|
if !path.starts_with(FILE_ROUTE_PREFIX) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut parts = path.trim_start_matches('/').split('/');
|
||||||
|
let route = parts.next()?;
|
||||||
|
let token = parts.next()?;
|
||||||
|
let filename = parts.next()?;
|
||||||
|
if route != "f" || token.is_empty() || filename.is_empty() || parts.next().is_some() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(format!("/f/{token}/{filename}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stored_file_path(token: &str, filename: &str) -> String {
|
||||||
|
format!("/f/{token}/{filename}")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn public_file_url(public_base: Option<&str>, stored_url: &str) -> String {
|
||||||
|
let Some(canonical) = canonical_file_path(stored_url) else {
|
||||||
|
return stored_url.to_owned();
|
||||||
|
};
|
||||||
|
match public_base {
|
||||||
|
Some(base) => format!("{}{canonical}", base.trim_end_matches('/')),
|
||||||
|
None => canonical,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "tests/file_urls.rs"]
|
||||||
|
mod tests;
|
||||||
+45
-5
@@ -12,11 +12,14 @@ mod app;
|
|||||||
mod assets;
|
mod assets;
|
||||||
mod auth;
|
mod auth;
|
||||||
mod cache;
|
mod cache;
|
||||||
|
mod collab;
|
||||||
mod config;
|
mod config;
|
||||||
mod database;
|
mod database;
|
||||||
mod db;
|
mod db;
|
||||||
|
mod file_urls;
|
||||||
mod queries;
|
mod queries;
|
||||||
mod row_decode;
|
mod row_decode;
|
||||||
|
mod security;
|
||||||
mod state;
|
mod state;
|
||||||
mod storage;
|
mod storage;
|
||||||
mod websocket;
|
mod websocket;
|
||||||
@@ -39,6 +42,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
dotenvy::dotenv().ok();
|
dotenvy::dotenv().ok();
|
||||||
let cli = parse_command()?;
|
let cli = parse_command()?;
|
||||||
init_tracing();
|
init_tracing();
|
||||||
|
print_startup_credential();
|
||||||
|
|
||||||
let config = Config::load(cli.config.as_deref())?;
|
let config = Config::load(cli.config.as_deref())?;
|
||||||
if matches!(cli.command, Command::CheckConfig) {
|
if matches!(cli.command, Command::CheckConfig) {
|
||||||
@@ -52,8 +56,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
info!(
|
info!(
|
||||||
"RustPad version" = %config.asset_version,
|
|
||||||
Copyright="@linuxiarz.pl Mateusz Gruszczyński",
|
|
||||||
host = %config.host,
|
host = %config.host,
|
||||||
port = config.port,
|
port = config.port,
|
||||||
database_kind = %database_kind_label(&config.database_url),
|
database_kind = %database_kind_label(&config.database_url),
|
||||||
@@ -62,8 +64,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
files_dir = %config.files_dir,
|
files_dir = %config.files_dir,
|
||||||
storage_driver = match &config.storage { storage::StorageConfig::Local { .. } => "local", storage::StorageConfig::S3 { .. } => "s3" },
|
storage_driver = match &config.storage { storage::StorageConfig::Local { .. } => "local", storage::StorageConfig::S3 { .. } => "s3" },
|
||||||
upload_max_size_bytes = config.upload_max_size_bytes,
|
upload_max_size_bytes = config.upload_max_size_bytes,
|
||||||
|
guest_upload_enabled = config.guest_upload_enabled,
|
||||||
|
guest_upload_max_size_bytes = config.guest_upload_max_size_bytes,
|
||||||
asset_cache_max_age_seconds = config.asset_cache_max_age_seconds,
|
asset_cache_max_age_seconds = config.asset_cache_max_age_seconds,
|
||||||
file_cache_max_age_seconds = config.file_cache_max_age_seconds,
|
file_cache_max_age_seconds = config.file_cache_max_age_seconds,
|
||||||
|
files_public_url = config.files_public_url.as_deref().unwrap_or("application origin"),
|
||||||
registration_enabled = config.registration_enabled,
|
registration_enabled = config.registration_enabled,
|
||||||
account_confirmation_required = config.account_confirmation_required,
|
account_confirmation_required = config.account_confirmation_required,
|
||||||
share_confirmation_required = config.share_confirmation_required,
|
share_confirmation_required = config.share_confirmation_required,
|
||||||
@@ -72,7 +77,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
user_session_ttl_days = config.user_session_ttl_days,
|
user_session_ttl_days = config.user_session_ttl_days,
|
||||||
smtp_configured = config.smtp.is_some(),
|
smtp_configured = config.smtp.is_some(),
|
||||||
authorization_type = config.authorization_type.as_str(),
|
authorization_type = config.authorization_type.as_str(),
|
||||||
asset_version = %config.asset_version,
|
|
||||||
"configuration loaded"
|
"configuration loaded"
|
||||||
);
|
);
|
||||||
if let Some(path) = config
|
if let Some(path) = config
|
||||||
@@ -104,7 +108,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
config.asset_version.clone(),
|
config.asset_version.clone(),
|
||||||
storage,
|
storage,
|
||||||
config.upload_max_size_bytes,
|
config.upload_max_size_bytes,
|
||||||
|
config.guest_upload_enabled,
|
||||||
|
config.guest_upload_max_size_bytes,
|
||||||
config.file_cache_max_age_seconds,
|
config.file_cache_max_age_seconds,
|
||||||
|
config.files_public_url.clone(),
|
||||||
config.smtp.clone(),
|
config.smtp.clone(),
|
||||||
config.registration_enabled && config.ldap.is_none(),
|
config.registration_enabled && config.ldap.is_none(),
|
||||||
config.account_confirmation_required,
|
config.account_confirmation_required,
|
||||||
@@ -140,18 +147,36 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
tracing::error!(%error, "failed to remove expired unconfirmed accounts")
|
tracing::error!(%error, "failed to remove expired unconfirmed accounts")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
match sqlx::query(crate::queries::get(
|
||||||
|
cleanup_state.db.kind(),
|
||||||
|
crate::queries::SHARE_SESSIONS_DELETE_EXPIRED,
|
||||||
|
))
|
||||||
|
.bind(now)
|
||||||
|
.execute(cleanup_state.db.pool())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(result) if result.rows_affected() > 0 => info!(
|
||||||
|
deleted = result.rows_affected(),
|
||||||
|
"removed expired share-link sessions"
|
||||||
|
),
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(error) => {
|
||||||
|
tracing::error!(%error, "failed to remove expired share-link sessions")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
let app = app::router(
|
let app = app::router(
|
||||||
state,
|
state,
|
||||||
&config.static_dir,
|
&config.static_dir,
|
||||||
config.upload_max_size_bytes,
|
config.upload_body_limit_bytes(),
|
||||||
config.asset_cache_max_age_seconds,
|
config.asset_cache_max_age_seconds,
|
||||||
);
|
);
|
||||||
let address = SocketAddr::new(config.host, config.port);
|
let address = SocketAddr::new(config.host, config.port);
|
||||||
let listener = TcpListener::bind(address).await?;
|
let listener = TcpListener::bind(address).await?;
|
||||||
|
|
||||||
info!(%address, asset_version = %config.asset_version, "RustPad is running");
|
info!(%address, "RustPad is running");
|
||||||
axum::serve(listener, app)
|
axum::serve(listener, app)
|
||||||
.with_graceful_shutdown(shutdown_signal())
|
.with_graceful_shutdown(shutdown_signal())
|
||||||
.await?;
|
.await?;
|
||||||
@@ -159,6 +184,17 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn print_startup_credential() {
|
||||||
|
eprintln!("\n{}\n", startup_credential());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn startup_credential() -> String {
|
||||||
|
format!(
|
||||||
|
"RustPad {}\nCopyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl\nLicense: https://git.linuxiarz.pl/gru/rustpad/src/branch/master/LICENSE.md",
|
||||||
|
env!("CARGO_PKG_VERSION")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
enum Command {
|
enum Command {
|
||||||
Run,
|
Run,
|
||||||
@@ -264,6 +300,10 @@ async fn shutdown_signal() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "tests/main.rs"]
|
||||||
|
mod startup_tests;
|
||||||
|
|
||||||
async fn run_migrations(db: &Database) -> Result<(), sqlx::migrate::MigrateError> {
|
async fn run_migrations(db: &Database) -> Result<(), sqlx::migrate::MigrateError> {
|
||||||
let path = match db.kind() {
|
let path = match db.kind() {
|
||||||
DatabaseKind::Sqlite => std::path::Path::new("migrations/sqlite"),
|
DatabaseKind::Sqlite => std::path::Path::new("migrations/sqlite"),
|
||||||
|
|||||||
+33
-24
@@ -26,6 +26,7 @@ pub enum Query {
|
|||||||
AUTH_LATEST_CONFIRMATION_CREATED_AT,
|
AUTH_LATEST_CONFIRMATION_CREATED_AT,
|
||||||
AUTH_UPDATE_NICKNAME,
|
AUTH_UPDATE_NICKNAME,
|
||||||
AUTH_UPDATE_EDITOR_COLOR,
|
AUTH_UPDATE_EDITOR_COLOR,
|
||||||
|
AUTH_UPDATE_THEME,
|
||||||
AUTH_EDITOR_COLOR_BY_USER,
|
AUTH_EDITOR_COLOR_BY_USER,
|
||||||
RESOURCE_COLOR_BY_USER,
|
RESOURCE_COLOR_BY_USER,
|
||||||
RESOURCE_COLOR_DELETE,
|
RESOURCE_COLOR_DELETE,
|
||||||
@@ -87,6 +88,7 @@ pub enum Query {
|
|||||||
USER_SET_WORKSPACE_PRIVACY,
|
USER_SET_WORKSPACE_PRIVACY,
|
||||||
USER_SET_PAD_PRIVACY,
|
USER_SET_PAD_PRIVACY,
|
||||||
RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE,
|
RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE,
|
||||||
|
RESOURCE_ACCESS_TOKENS_DELETE_BY_TOKEN_HASH,
|
||||||
RESOURCE_ACCESS_TOKENS_INSERT,
|
RESOURCE_ACCESS_TOKENS_INSERT,
|
||||||
RESOURCE_ACCESS_TOKENS_VALID_COUNT,
|
RESOURCE_ACCESS_TOKENS_VALID_COUNT,
|
||||||
RESOURCE_PERMISSION_DELETE_USER,
|
RESOURCE_PERMISSION_DELETE_USER,
|
||||||
@@ -104,6 +106,13 @@ pub enum Query {
|
|||||||
SHARE_LINK_REVOKE,
|
SHARE_LINK_REVOKE,
|
||||||
RESOURCE_PERMISSION_BY_USER,
|
RESOURCE_PERMISSION_BY_USER,
|
||||||
SHARE_LINK_PERMISSION,
|
SHARE_LINK_PERMISSION,
|
||||||
|
SHARE_LINK_SESSION_SOURCE,
|
||||||
|
SHARE_SESSION_INSERT,
|
||||||
|
SHARE_SESSION_PERMISSION,
|
||||||
|
SHARE_SESSIONS_DELETE_BY_LINK,
|
||||||
|
SHARE_SESSIONS_DELETE_EXPIRED,
|
||||||
|
PAD_PUBLIC_PAGE_DISABLED,
|
||||||
|
NOTE_PUBLIC_PAGE_DISABLED,
|
||||||
Q001,
|
Q001,
|
||||||
Q002,
|
Q002,
|
||||||
Q003,
|
Q003,
|
||||||
@@ -147,14 +156,18 @@ pub enum Query {
|
|||||||
Q047,
|
Q047,
|
||||||
Q040,
|
Q040,
|
||||||
Q041,
|
Q041,
|
||||||
Q042,
|
|
||||||
Q043,
|
|
||||||
Q044,
|
Q044,
|
||||||
Q045,
|
Q045,
|
||||||
Q048,
|
Q048,
|
||||||
Q049,
|
Q049,
|
||||||
Q050,
|
Q050,
|
||||||
Q051,
|
Q051,
|
||||||
|
Q054,
|
||||||
|
Q055,
|
||||||
|
Q056,
|
||||||
|
Q057,
|
||||||
|
Q058,
|
||||||
|
Q059,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get(kind: DatabaseKind, query: Query) -> &'static str {
|
pub fn get(kind: DatabaseKind, query: Query) -> &'static str {
|
||||||
@@ -176,6 +189,7 @@ pub const POSTGRES_PAD_REVISION_LAST_INSERT_ID: Query = Query::POSTGRES_PAD_REVI
|
|||||||
pub const AUTH_LATEST_CONFIRMATION_CREATED_AT: Query = Query::AUTH_LATEST_CONFIRMATION_CREATED_AT;
|
pub const AUTH_LATEST_CONFIRMATION_CREATED_AT: Query = Query::AUTH_LATEST_CONFIRMATION_CREATED_AT;
|
||||||
pub const AUTH_UPDATE_NICKNAME: Query = Query::AUTH_UPDATE_NICKNAME;
|
pub const AUTH_UPDATE_NICKNAME: Query = Query::AUTH_UPDATE_NICKNAME;
|
||||||
pub const AUTH_UPDATE_EDITOR_COLOR: Query = Query::AUTH_UPDATE_EDITOR_COLOR;
|
pub const AUTH_UPDATE_EDITOR_COLOR: Query = Query::AUTH_UPDATE_EDITOR_COLOR;
|
||||||
|
pub const AUTH_UPDATE_THEME: Query = Query::AUTH_UPDATE_THEME;
|
||||||
pub const AUTH_EDITOR_COLOR_BY_USER: Query = Query::AUTH_EDITOR_COLOR_BY_USER;
|
pub const AUTH_EDITOR_COLOR_BY_USER: Query = Query::AUTH_EDITOR_COLOR_BY_USER;
|
||||||
pub const RESOURCE_COLOR_BY_USER: Query = Query::RESOURCE_COLOR_BY_USER;
|
pub const RESOURCE_COLOR_BY_USER: Query = Query::RESOURCE_COLOR_BY_USER;
|
||||||
pub const RESOURCE_COLOR_DELETE: Query = Query::RESOURCE_COLOR_DELETE;
|
pub const RESOURCE_COLOR_DELETE: Query = Query::RESOURCE_COLOR_DELETE;
|
||||||
@@ -240,6 +254,8 @@ pub const USER_SET_WORKSPACE_PRIVACY: Query = Query::USER_SET_WORKSPACE_PRIVACY;
|
|||||||
pub const USER_SET_PAD_PRIVACY: Query = Query::USER_SET_PAD_PRIVACY;
|
pub const USER_SET_PAD_PRIVACY: Query = Query::USER_SET_PAD_PRIVACY;
|
||||||
pub const RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE: Query =
|
pub const RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE: Query =
|
||||||
Query::RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE;
|
Query::RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE;
|
||||||
|
pub const RESOURCE_ACCESS_TOKENS_DELETE_BY_TOKEN_HASH: Query =
|
||||||
|
Query::RESOURCE_ACCESS_TOKENS_DELETE_BY_TOKEN_HASH;
|
||||||
pub const RESOURCE_ACCESS_TOKENS_INSERT: Query = Query::RESOURCE_ACCESS_TOKENS_INSERT;
|
pub const RESOURCE_ACCESS_TOKENS_INSERT: Query = Query::RESOURCE_ACCESS_TOKENS_INSERT;
|
||||||
pub const RESOURCE_ACCESS_TOKENS_VALID_COUNT: Query = Query::RESOURCE_ACCESS_TOKENS_VALID_COUNT;
|
pub const RESOURCE_ACCESS_TOKENS_VALID_COUNT: Query = Query::RESOURCE_ACCESS_TOKENS_VALID_COUNT;
|
||||||
pub const RESOURCE_PERMISSION_DELETE_USER: Query = Query::RESOURCE_PERMISSION_DELETE_USER;
|
pub const RESOURCE_PERMISSION_DELETE_USER: Query = Query::RESOURCE_PERMISSION_DELETE_USER;
|
||||||
@@ -257,6 +273,13 @@ pub const SHARE_LINK_UPDATE: Query = Query::SHARE_LINK_UPDATE;
|
|||||||
pub const SHARE_LINK_REVOKE: Query = Query::SHARE_LINK_REVOKE;
|
pub const SHARE_LINK_REVOKE: Query = Query::SHARE_LINK_REVOKE;
|
||||||
pub const RESOURCE_PERMISSION_BY_USER: Query = Query::RESOURCE_PERMISSION_BY_USER;
|
pub const RESOURCE_PERMISSION_BY_USER: Query = Query::RESOURCE_PERMISSION_BY_USER;
|
||||||
pub const SHARE_LINK_PERMISSION: Query = Query::SHARE_LINK_PERMISSION;
|
pub const SHARE_LINK_PERMISSION: Query = Query::SHARE_LINK_PERMISSION;
|
||||||
|
pub const SHARE_LINK_SESSION_SOURCE: Query = Query::SHARE_LINK_SESSION_SOURCE;
|
||||||
|
pub const SHARE_SESSION_INSERT: Query = Query::SHARE_SESSION_INSERT;
|
||||||
|
pub const SHARE_SESSION_PERMISSION: Query = Query::SHARE_SESSION_PERMISSION;
|
||||||
|
pub const SHARE_SESSIONS_DELETE_BY_LINK: Query = Query::SHARE_SESSIONS_DELETE_BY_LINK;
|
||||||
|
pub const SHARE_SESSIONS_DELETE_EXPIRED: Query = Query::SHARE_SESSIONS_DELETE_EXPIRED;
|
||||||
|
pub const PAD_PUBLIC_PAGE_DISABLED: Query = Query::PAD_PUBLIC_PAGE_DISABLED;
|
||||||
|
pub const NOTE_PUBLIC_PAGE_DISABLED: Query = Query::NOTE_PUBLIC_PAGE_DISABLED;
|
||||||
pub const Q001: Query = Query::Q001;
|
pub const Q001: Query = Query::Q001;
|
||||||
pub const Q002: Query = Query::Q002;
|
pub const Q002: Query = Query::Q002;
|
||||||
pub const Q003: Query = Query::Q003;
|
pub const Q003: Query = Query::Q003;
|
||||||
@@ -300,33 +323,19 @@ pub const Q046: Query = Query::Q046;
|
|||||||
pub const Q047: Query = Query::Q047;
|
pub const Q047: Query = Query::Q047;
|
||||||
pub const Q040: Query = Query::Q040;
|
pub const Q040: Query = Query::Q040;
|
||||||
pub const Q041: Query = Query::Q041;
|
pub const Q041: Query = Query::Q041;
|
||||||
pub const Q042: Query = Query::Q042;
|
|
||||||
pub const Q043: Query = Query::Q043;
|
|
||||||
pub const Q044: Query = Query::Q044;
|
pub const Q044: Query = Query::Q044;
|
||||||
pub const Q045: Query = Query::Q045;
|
pub const Q045: Query = Query::Q045;
|
||||||
pub const Q048: Query = Query::Q048;
|
pub const Q048: Query = Query::Q048;
|
||||||
pub const Q049: Query = Query::Q049;
|
pub const Q049: Query = Query::Q049;
|
||||||
pub const Q050: Query = Query::Q050;
|
pub const Q050: Query = Query::Q050;
|
||||||
pub const Q051: Query = Query::Q051;
|
pub const Q051: Query = Query::Q051;
|
||||||
|
pub const Q054: Query = Query::Q054;
|
||||||
|
pub const Q055: Query = Query::Q055;
|
||||||
|
pub const Q056: Query = Query::Q056;
|
||||||
|
pub const Q057: Query = Query::Q057;
|
||||||
|
pub const Q058: Query = Query::Q058;
|
||||||
|
pub const Q059: Query = Query::Q059;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "../tests/queries.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
#[test]
|
|
||||||
fn every_backend_has_explicit_queries() {
|
|
||||||
for query in [
|
|
||||||
Q001,
|
|
||||||
Q003,
|
|
||||||
Q004,
|
|
||||||
Q011,
|
|
||||||
Q021,
|
|
||||||
Q033,
|
|
||||||
USER_LIST_WORKSPACES,
|
|
||||||
USER_LIST_PADS,
|
|
||||||
] {
|
|
||||||
assert!(!get(DatabaseKind::Sqlite, query).is_empty());
|
|
||||||
assert!(!get(DatabaseKind::Postgres, query).is_empty());
|
|
||||||
assert!(!get(DatabaseKind::MySql, query).is_empty());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+73
-29
@@ -31,6 +31,7 @@ pub fn get(query: Query) -> &'static str {
|
|||||||
Query::AUTH_UPDATE_EDITOR_COLOR => {
|
Query::AUTH_UPDATE_EDITOR_COLOR => {
|
||||||
r#"UPDATE users SET editor_color = ?, updated_at = ? WHERE id = ?"#
|
r#"UPDATE users SET editor_color = ?, updated_at = ? WHERE id = ?"#
|
||||||
}
|
}
|
||||||
|
Query::AUTH_UPDATE_THEME => r#"UPDATE users SET theme = ?, updated_at = ? WHERE id = ?"#,
|
||||||
Query::AUTH_EDITOR_COLOR_BY_USER => r#"SELECT editor_color FROM users WHERE id = ?"#,
|
Query::AUTH_EDITOR_COLOR_BY_USER => r#"SELECT editor_color FROM users WHERE id = ?"#,
|
||||||
Query::RESOURCE_COLOR_BY_USER => {
|
Query::RESOURCE_COLOR_BY_USER => {
|
||||||
r#"SELECT color FROM user_resource_colors WHERE user_id = ? AND resource_kind = ? AND resource_slug = ?"#
|
r#"SELECT color FROM user_resource_colors WHERE user_id = ? AND resource_kind = ? AND resource_slug = ?"#
|
||||||
@@ -58,16 +59,16 @@ pub fn get(query: Query) -> &'static str {
|
|||||||
r#"DELETE FROM resource_editor_settings WHERE resource_kind = ? AND resource_slug = ?"#
|
r#"DELETE FROM resource_editor_settings WHERE resource_kind = ? AND resource_slug = ?"#
|
||||||
}
|
}
|
||||||
Query::EDITOR_PREFERENCES_SELECT_PAD => {
|
Query::EDITOR_PREFERENCES_SELECT_PAD => {
|
||||||
r#"SELECT CAST(CASE WHEN compact_view THEN 1 ELSE 0 END AS SIGNED), CAST(CASE WHEN editor_line_numbers THEN 1 ELSE 0 END AS SIGNED), CAST(CASE WHEN preview_line_numbers THEN 1 ELSE 0 END AS SIGNED), CAST(CASE WHEN line_links THEN 1 ELSE 0 END AS SIGNED), CAST(font_family AS CHAR CHARACTER SET utf8mb4), font_size FROM user_editor_preferences WHERE user_id = ? AND pad_id = ?"#
|
r#"SELECT CAST(CASE WHEN compact_view THEN 1 ELSE 0 END AS SIGNED), CAST(CASE WHEN editor_line_numbers THEN 1 ELSE 0 END AS SIGNED), CAST(CASE WHEN preview_line_numbers THEN 1 ELSE 0 END AS SIGNED), CAST(CASE WHEN line_links THEN 1 ELSE 0 END AS SIGNED), CAST(CASE WHEN toolbar_collapsed THEN 1 ELSE 0 END AS SIGNED), CAST(font_family AS CHAR CHARACTER SET utf8mb4), font_size FROM user_editor_preferences WHERE user_id = ? AND pad_id = ?"#
|
||||||
}
|
}
|
||||||
Query::EDITOR_PREFERENCES_SELECT_NOTE => {
|
Query::EDITOR_PREFERENCES_SELECT_NOTE => {
|
||||||
r#"SELECT CAST(CASE WHEN compact_view THEN 1 ELSE 0 END AS SIGNED), CAST(CASE WHEN editor_line_numbers THEN 1 ELSE 0 END AS SIGNED), CAST(CASE WHEN preview_line_numbers THEN 1 ELSE 0 END AS SIGNED), CAST(CASE WHEN line_links THEN 1 ELSE 0 END AS SIGNED), CAST(font_family AS CHAR CHARACTER SET utf8mb4), font_size FROM user_editor_preferences WHERE user_id = ? AND note_id = ?"#
|
r#"SELECT CAST(CASE WHEN compact_view THEN 1 ELSE 0 END AS SIGNED), CAST(CASE WHEN editor_line_numbers THEN 1 ELSE 0 END AS SIGNED), CAST(CASE WHEN preview_line_numbers THEN 1 ELSE 0 END AS SIGNED), CAST(CASE WHEN line_links THEN 1 ELSE 0 END AS SIGNED), CAST(CASE WHEN toolbar_collapsed THEN 1 ELSE 0 END AS SIGNED), CAST(font_family AS CHAR CHARACTER SET utf8mb4), font_size FROM user_editor_preferences WHERE user_id = ? AND note_id = ?"#
|
||||||
}
|
}
|
||||||
Query::EDITOR_PREFERENCES_UPSERT_PAD => {
|
Query::EDITOR_PREFERENCES_UPSERT_PAD => {
|
||||||
r#"INSERT INTO user_editor_preferences (user_id, pad_id, note_id, compact_view, editor_line_numbers, preview_line_numbers, line_links, font_family, font_size, updated_at) VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON DUPLICATE KEY UPDATE compact_view = VALUES(compact_view), editor_line_numbers = VALUES(editor_line_numbers), preview_line_numbers = VALUES(preview_line_numbers), line_links = VALUES(line_links), font_family = VALUES(font_family), font_size = VALUES(font_size), updated_at = CURRENT_TIMESTAMP"#
|
r#"INSERT INTO user_editor_preferences (user_id, pad_id, note_id, compact_view, editor_line_numbers, preview_line_numbers, line_links, toolbar_collapsed, font_family, font_size, updated_at) VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON DUPLICATE KEY UPDATE compact_view = VALUES(compact_view), editor_line_numbers = VALUES(editor_line_numbers), preview_line_numbers = VALUES(preview_line_numbers), line_links = VALUES(line_links), toolbar_collapsed = VALUES(toolbar_collapsed), font_family = VALUES(font_family), font_size = VALUES(font_size), updated_at = CURRENT_TIMESTAMP"#
|
||||||
}
|
}
|
||||||
Query::EDITOR_PREFERENCES_UPSERT_NOTE => {
|
Query::EDITOR_PREFERENCES_UPSERT_NOTE => {
|
||||||
r#"INSERT INTO user_editor_preferences (user_id, pad_id, note_id, compact_view, editor_line_numbers, preview_line_numbers, line_links, font_family, font_size, updated_at) VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON DUPLICATE KEY UPDATE compact_view = VALUES(compact_view), editor_line_numbers = VALUES(editor_line_numbers), preview_line_numbers = VALUES(preview_line_numbers), line_links = VALUES(line_links), font_family = VALUES(font_family), font_size = VALUES(font_size), updated_at = CURRENT_TIMESTAMP"#
|
r#"INSERT INTO user_editor_preferences (user_id, pad_id, note_id, compact_view, editor_line_numbers, preview_line_numbers, line_links, toolbar_collapsed, font_family, font_size, updated_at) VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON DUPLICATE KEY UPDATE compact_view = VALUES(compact_view), editor_line_numbers = VALUES(editor_line_numbers), preview_line_numbers = VALUES(preview_line_numbers), line_links = VALUES(line_links), toolbar_collapsed = VALUES(toolbar_collapsed), font_family = VALUES(font_family), font_size = VALUES(font_size), updated_at = CURRENT_TIMESTAMP"#
|
||||||
}
|
}
|
||||||
Query::EDITOR_PREFERENCES_DELETE_BY_USER => {
|
Query::EDITOR_PREFERENCES_DELETE_BY_USER => {
|
||||||
r#"DELETE FROM user_editor_preferences WHERE user_id = ?"#
|
r#"DELETE FROM user_editor_preferences WHERE user_id = ?"#
|
||||||
@@ -103,7 +104,7 @@ pub fn get(query: Query) -> &'static str {
|
|||||||
r#"SELECT auth_provider, directory_display_name FROM users WHERE id = ?"#
|
r#"SELECT auth_provider, directory_display_name FROM users WHERE id = ?"#
|
||||||
}
|
}
|
||||||
Query::AUTH_USER_BY_EXTERNAL_ID => {
|
Query::AUTH_USER_BY_EXTERNAL_ID => {
|
||||||
r#"SELECT id, nickname, email, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS SIGNED) AS is_active FROM users WHERE auth_provider = ? AND external_id = ?"#
|
r#"SELECT id, nickname, email, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS SIGNED) AS is_active, CAST(theme AS CHAR CHARACTER SET utf8mb4) AS theme FROM users WHERE auth_provider = ? AND external_id = ?"#
|
||||||
}
|
}
|
||||||
Query::AUTH_DELETE_USER => r#"DELETE FROM users WHERE id = ?"#,
|
Query::AUTH_DELETE_USER => r#"DELETE FROM users WHERE id = ?"#,
|
||||||
Query::AUTH_ANONYMIZE_USER => {
|
Query::AUTH_ANONYMIZE_USER => {
|
||||||
@@ -150,19 +151,19 @@ pub fn get(query: Query) -> &'static str {
|
|||||||
}
|
}
|
||||||
Query::AUTH_DELETE_SESSIONS_BY_USER => r#"DELETE FROM user_sessions WHERE user_id = ?"#,
|
Query::AUTH_DELETE_SESSIONS_BY_USER => r#"DELETE FROM user_sessions WHERE user_id = ?"#,
|
||||||
Query::AUTH_USER_BY_SESSION => {
|
Query::AUTH_USER_BY_SESSION => {
|
||||||
r#"SELECT u.id, u.nickname, u.email, CAST(u.password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, u.confirmed_at, CAST(CASE WHEN u.is_active THEN 1 ELSE 0 END AS SIGNED) AS is_active FROM user_sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ? AND s.expires_at > ? AND u.is_active = 1"#
|
r#"SELECT u.id, u.nickname, u.email, CAST(u.password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, u.confirmed_at, CAST(CASE WHEN u.is_active THEN 1 ELSE 0 END AS SIGNED) AS is_active, CAST(u.theme AS CHAR CHARACTER SET utf8mb4) AS theme FROM user_sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ? AND s.expires_at > ? AND u.is_active = 1"#
|
||||||
}
|
}
|
||||||
Query::AUTH_INSERT_SESSION => {
|
Query::AUTH_INSERT_SESSION => {
|
||||||
r#"INSERT INTO user_sessions (token, user_id, expires_at) VALUES (?, ?, ?)"#
|
r#"INSERT INTO user_sessions (token, user_id, expires_at) VALUES (?, ?, ?)"#
|
||||||
}
|
}
|
||||||
Query::AUTH_USER_BY_NICKNAME => {
|
Query::AUTH_USER_BY_NICKNAME => {
|
||||||
r#"SELECT id, nickname, email, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS SIGNED) AS is_active FROM users WHERE nickname_key = ?"#
|
r#"SELECT id, nickname, email, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS SIGNED) AS is_active, CAST(theme AS CHAR CHARACTER SET utf8mb4) AS theme FROM users WHERE nickname_key = ?"#
|
||||||
}
|
}
|
||||||
Query::AUTH_USER_BY_EMAIL => {
|
Query::AUTH_USER_BY_EMAIL => {
|
||||||
r#"SELECT id, nickname, email, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS SIGNED) AS is_active FROM users WHERE email_key = ?"#
|
r#"SELECT id, nickname, email, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS SIGNED) AS is_active, CAST(theme AS CHAR CHARACTER SET utf8mb4) AS theme FROM users WHERE email_key = ?"#
|
||||||
}
|
}
|
||||||
Query::AUTH_USER_BY_SHARE_IDENTIFIER => {
|
Query::AUTH_USER_BY_SHARE_IDENTIFIER => {
|
||||||
r#"SELECT id, nickname, email, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS SIGNED) AS is_active FROM users JOIN (SELECT ? AS identifier) lookup ON 1 = 1 WHERE is_active = 1 AND (email_key = lookup.identifier OR LOWER(directory_username) = lookup.identifier OR LOWER(external_id) = lookup.identifier) LIMIT 1"#
|
r#"SELECT id, nickname, email, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS SIGNED) AS is_active, CAST(theme AS CHAR CHARACTER SET utf8mb4) AS theme FROM users JOIN (SELECT ? AS identifier) lookup ON 1 = 1 WHERE is_active = 1 AND (email_key = lookup.identifier OR LOWER(directory_username) = lookup.identifier OR LOWER(external_id) = lookup.identifier) LIMIT 1"#
|
||||||
}
|
}
|
||||||
Query::USER_ATTACH_WORKSPACE => {
|
Query::USER_ATTACH_WORKSPACE => {
|
||||||
r#"INSERT INTO user_workspaces (user_id, workspace_id) SELECT ?, id FROM workspaces WHERE slug = ?"#
|
r#"INSERT INTO user_workspaces (user_id, workspace_id) SELECT ?, id FROM workspaces WHERE slug = ?"#
|
||||||
@@ -171,10 +172,10 @@ pub fn get(query: Query) -> &'static str {
|
|||||||
r#"INSERT INTO user_pads (user_id, pad_id) SELECT ?, id FROM pads WHERE slug = ?"#
|
r#"INSERT INTO user_pads (user_id, pad_id) SELECT ?, id FROM pads WHERE slug = ?"#
|
||||||
}
|
}
|
||||||
Query::USER_LIST_WORKSPACES => {
|
Query::USER_LIST_WORKSPACES => {
|
||||||
r#"SELECT w.slug, CAST(w.title AS CHAR CHARACTER SET utf8mb4) AS title, CASE WHEN w.password_hash IS NULL THEN 0 ELSE 1 END AS protected, w.updated_at, CASE WHEN w.is_private THEN 1 ELSE 0 END AS private, 1 AS owned, 'rw' AS permission, '' AS shared_by FROM user_workspaces uw JOIN workspaces w ON w.id = uw.workspace_id WHERE uw.user_id = ? UNION SELECT w.slug, CAST(w.title AS CHAR CHARACTER SET utf8mb4) AS title, CASE WHEN w.password_hash IS NULL THEN 0 ELSE 1 END, w.updated_at, CASE WHEN w.is_private THEN 1 ELSE 0 END, 0, rp.permission, COALESCE((SELECT u.nickname FROM user_workspaces owner_uw JOIN users u ON u.id = owner_uw.user_id WHERE owner_uw.workspace_id = w.id LIMIT 1), 'Unknown user') AS shared_by FROM resource_permissions rp JOIN workspaces w ON w.slug = rp.resource_slug WHERE rp.resource_kind = 'workspace' AND rp.user_id = ? ORDER BY updated_at DESC"#
|
r#"SELECT w.slug, CAST(w.title AS CHAR CHARACTER SET utf8mb4) AS title, CAST(CASE WHEN w.password_hash IS NULL THEN 0 ELSE 1 END AS SIGNED) AS protected, w.updated_at, CAST(CASE WHEN w.is_private THEN 1 ELSE 0 END AS SIGNED) AS private, CAST(1 AS SIGNED) AS owned, 'rw' AS permission, '' AS shared_by FROM user_workspaces uw JOIN workspaces w ON w.id = uw.workspace_id WHERE uw.user_id = ? UNION SELECT w.slug, CAST(w.title AS CHAR CHARACTER SET utf8mb4) AS title, CAST(CASE WHEN w.password_hash IS NULL THEN 0 ELSE 1 END AS SIGNED), w.updated_at, CAST(CASE WHEN w.is_private THEN 1 ELSE 0 END AS SIGNED), CAST(0 AS SIGNED), rp.permission, COALESCE((SELECT u.nickname FROM user_workspaces owner_uw JOIN users u ON u.id = owner_uw.user_id WHERE owner_uw.workspace_id = w.id LIMIT 1), 'Unknown user') AS shared_by FROM resource_permissions rp JOIN workspaces w ON w.slug = rp.resource_slug WHERE rp.resource_kind = 'workspace' AND rp.user_id = ? ORDER BY updated_at DESC"#
|
||||||
}
|
}
|
||||||
Query::USER_LIST_PADS => {
|
Query::USER_LIST_PADS => {
|
||||||
r#"SELECT p.slug, CAST(p.title AS CHAR CHARACTER SET utf8mb4) AS title, CASE WHEN p.password_hash IS NULL THEN 0 ELSE 1 END AS protected, p.updated_at, CASE WHEN p.is_private THEN 1 ELSE 0 END AS private, 1 AS owned, 'rw' AS permission, '' AS shared_by FROM user_pads up JOIN pads p ON p.id = up.pad_id WHERE up.user_id = ? UNION SELECT p.slug, CAST(p.title AS CHAR CHARACTER SET utf8mb4) AS title, CASE WHEN p.password_hash IS NULL THEN 0 ELSE 1 END, p.updated_at, CASE WHEN p.is_private THEN 1 ELSE 0 END, 0, rp.permission, COALESCE((SELECT u.nickname FROM user_pads owner_up JOIN users u ON u.id = owner_up.user_id WHERE owner_up.pad_id = p.id LIMIT 1), 'Unknown user') AS shared_by FROM resource_permissions rp JOIN pads p ON p.slug = rp.resource_slug WHERE rp.resource_kind = 'pad' AND rp.user_id = ? ORDER BY updated_at DESC"#
|
r#"SELECT p.slug, CAST(p.title AS CHAR CHARACTER SET utf8mb4) AS title, CAST(CASE WHEN p.password_hash IS NULL THEN 0 ELSE 1 END AS SIGNED) AS protected, p.updated_at, CAST(CASE WHEN p.is_private THEN 1 ELSE 0 END AS SIGNED) AS private, CAST(1 AS SIGNED) AS owned, 'rw' AS permission, '' AS shared_by FROM user_pads up JOIN pads p ON p.id = up.pad_id WHERE up.user_id = ? UNION SELECT p.slug, CAST(p.title AS CHAR CHARACTER SET utf8mb4) AS title, CAST(CASE WHEN p.password_hash IS NULL THEN 0 ELSE 1 END AS SIGNED), p.updated_at, CAST(CASE WHEN p.is_private THEN 1 ELSE 0 END AS SIGNED), CAST(0 AS SIGNED), rp.permission, COALESCE((SELECT u.nickname FROM user_pads owner_up JOIN users u ON u.id = owner_up.user_id WHERE owner_up.pad_id = p.id LIMIT 1), 'Unknown user') AS shared_by FROM resource_permissions rp JOIN pads p ON p.slug = rp.resource_slug WHERE rp.resource_kind = 'pad' AND rp.user_id = ? ORDER BY updated_at DESC"#
|
||||||
}
|
}
|
||||||
Query::USER_OWNS_WORKSPACE => {
|
Query::USER_OWNS_WORKSPACE => {
|
||||||
r#"SELECT COUNT(*) FROM user_workspaces uw JOIN workspaces w ON w.id = uw.workspace_id WHERE uw.user_id = ? AND w.slug = ?"#
|
r#"SELECT COUNT(*) FROM user_workspaces uw JOIN workspaces w ON w.id = uw.workspace_id WHERE uw.user_id = ? AND w.slug = ?"#
|
||||||
@@ -199,6 +200,9 @@ pub fn get(query: Query) -> &'static str {
|
|||||||
Query::RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE => {
|
Query::RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE => {
|
||||||
r#"DELETE FROM resource_access_tokens WHERE resource_kind = ? AND resource_slug = ?"#
|
r#"DELETE FROM resource_access_tokens WHERE resource_kind = ? AND resource_slug = ?"#
|
||||||
}
|
}
|
||||||
|
Query::RESOURCE_ACCESS_TOKENS_DELETE_BY_TOKEN_HASH => {
|
||||||
|
r#"DELETE FROM resource_access_tokens WHERE token_hash = ?"#
|
||||||
|
}
|
||||||
Query::RESOURCE_ACCESS_TOKENS_INSERT => {
|
Query::RESOURCE_ACCESS_TOKENS_INSERT => {
|
||||||
r#"INSERT INTO resource_access_tokens (token_hash, resource_kind, resource_slug, expires_at) VALUES (?, ?, ?, ?)"#
|
r#"INSERT INTO resource_access_tokens (token_hash, resource_kind, resource_slug, expires_at) VALUES (?, ?, ?, ?)"#
|
||||||
}
|
}
|
||||||
@@ -230,16 +234,16 @@ pub fn get(query: Query) -> &'static str {
|
|||||||
r#"SELECT u.email, u.nickname, rp.permission FROM resource_permissions rp JOIN users u ON u.id = rp.user_id WHERE rp.resource_kind = ? AND rp.resource_slug = ? ORDER BY u.email"#
|
r#"SELECT u.email, u.nickname, rp.permission FROM resource_permissions rp JOIN users u ON u.id = rp.user_id WHERE rp.resource_kind = ? AND rp.resource_slug = ? ORDER BY u.email"#
|
||||||
}
|
}
|
||||||
Query::RESOURCE_SHARING_LINKS => {
|
Query::RESOURCE_SHARING_LINKS => {
|
||||||
r#"SELECT token_hash, CAST(token AS CHAR CHARACTER SET utf8mb4) AS token, permission, CAST(expires_at AS CHAR CHARACTER SET utf8mb4) AS expires_at, CAST(created_at AS CHAR CHARACTER SET utf8mb4) AS created_at FROM resource_share_links WHERE resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL ORDER BY created_at DESC"#
|
r#"SELECT token_hash, CAST(label AS CHAR CHARACTER SET utf8mb4) AS label, permission, CAST(expires_at AS CHAR CHARACTER SET utf8mb4) AS expires_at, CAST(created_at AS CHAR CHARACTER SET utf8mb4) AS created_at FROM resource_share_links WHERE resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL ORDER BY created_at DESC"#
|
||||||
}
|
}
|
||||||
Query::RESOURCE_SHARING_PENDING => {
|
Query::RESOURCE_SHARING_PENDING => {
|
||||||
r#"SELECT u.email, u.nickname, i.permission, CAST(i.expires_at AS CHAR CHARACTER SET utf8mb4) AS expires_at FROM resource_share_invitations i JOIN users u ON u.id = i.user_id WHERE i.resource_kind = ? AND i.resource_slug = ? AND i.accepted_at IS NULL ORDER BY u.email"#
|
r#"SELECT u.email, u.nickname, i.permission, CAST(i.expires_at AS CHAR CHARACTER SET utf8mb4) AS expires_at FROM resource_share_invitations i JOIN users u ON u.id = i.user_id WHERE i.resource_kind = ? AND i.resource_slug = ? AND i.accepted_at IS NULL ORDER BY u.email"#
|
||||||
}
|
}
|
||||||
Query::SHARE_LINK_INSERT => {
|
Query::SHARE_LINK_INSERT => {
|
||||||
r#"INSERT INTO resource_share_links (token_hash, token, resource_kind, resource_slug, permission, expires_at, created_by) VALUES (?, ?, ?, ?, ?, ?, ?)"#
|
r#"INSERT INTO resource_share_links (token_hash, label, resource_kind, resource_slug, permission, expires_at, created_by) VALUES (?, ?, ?, ?, ?, ?, ?)"#
|
||||||
}
|
}
|
||||||
Query::SHARE_LINK_UPDATE => {
|
Query::SHARE_LINK_UPDATE => {
|
||||||
r#"UPDATE resource_share_links SET permission = ?, expires_at = ? WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL"#
|
r#"UPDATE resource_share_links SET label = ?, permission = ?, expires_at = ? WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL"#
|
||||||
}
|
}
|
||||||
Query::SHARE_LINK_REVOKE => {
|
Query::SHARE_LINK_REVOKE => {
|
||||||
r#"UPDATE resource_share_links SET revoked_at = ? WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ?"#
|
r#"UPDATE resource_share_links SET revoked_at = ? WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ?"#
|
||||||
@@ -248,20 +252,42 @@ pub fn get(query: Query) -> &'static str {
|
|||||||
r#"SELECT permission FROM resource_permissions WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?"#
|
r#"SELECT permission FROM resource_permissions WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?"#
|
||||||
}
|
}
|
||||||
Query::SHARE_LINK_PERMISSION => {
|
Query::SHARE_LINK_PERMISSION => {
|
||||||
r#"SELECT permission FROM resource_share_links WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)"#
|
r#"SELECT permission, CAST(expires_at AS CHAR CHARACTER SET utf8mb4) AS expires_at FROM resource_share_links WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL"#
|
||||||
|
}
|
||||||
|
Query::SHARE_LINK_SESSION_SOURCE => {
|
||||||
|
r#"SELECT token_hash, permission, CAST(expires_at AS CHAR CHARACTER SET utf8mb4) AS expires_at FROM resource_share_links WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL"#
|
||||||
|
}
|
||||||
|
Query::SHARE_SESSION_INSERT => {
|
||||||
|
r#"INSERT INTO resource_share_sessions (session_token_hash, share_token_hash, resource_kind, resource_slug, expires_at) VALUES (?, ?, ?, ?, ?)"#
|
||||||
|
}
|
||||||
|
Query::SHARE_SESSION_PERMISSION => {
|
||||||
|
r#"SELECT l.permission, CAST(s.expires_at AS CHAR CHARACTER SET utf8mb4) AS session_expires_at, CAST(l.expires_at AS CHAR CHARACTER SET utf8mb4) AS link_expires_at FROM resource_share_sessions s JOIN resource_share_links l ON l.token_hash = s.share_token_hash AND l.resource_kind = s.resource_kind AND l.resource_slug = s.resource_slug WHERE s.session_token_hash = ? AND s.resource_kind = ? AND s.resource_slug = ? AND l.revoked_at IS NULL"#
|
||||||
|
}
|
||||||
|
Query::SHARE_SESSIONS_DELETE_BY_LINK => {
|
||||||
|
r#"DELETE FROM resource_share_sessions WHERE share_token_hash = ? AND resource_kind = ? AND resource_slug = ?"#
|
||||||
|
}
|
||||||
|
Query::SHARE_SESSIONS_DELETE_EXPIRED => {
|
||||||
|
r#"DELETE FROM resource_share_sessions WHERE expires_at <= ?"#
|
||||||
|
}
|
||||||
|
|
||||||
|
Query::PAD_PUBLIC_PAGE_DISABLED => {
|
||||||
|
r#"SELECT CAST(CASE WHEN public_page_disabled THEN 1 ELSE 0 END AS SIGNED) FROM pads WHERE id = ?"#
|
||||||
|
}
|
||||||
|
Query::NOTE_PUBLIC_PAGE_DISABLED => {
|
||||||
|
r#"SELECT CAST(CASE WHEN public_page_disabled THEN 1 ELSE 0 END AS SIGNED) FROM notes WHERE id = ?"#
|
||||||
}
|
}
|
||||||
Query::Q001 => {
|
Query::Q001 => {
|
||||||
r#"SELECT id, slug, CAST(title AS CHAR CHARACTER SET utf8mb4) AS title, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, created_at, updated_at, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS SIGNED) AS is_private FROM workspaces WHERE slug = ?"#
|
r#"SELECT id, slug, CAST(title AS CHAR CHARACTER SET utf8mb4) AS title, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, created_at, updated_at, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS SIGNED) AS is_private, CAST(created_by_guest_id AS CHAR CHARACTER SET utf8mb4) AS created_by_guest_id FROM workspaces WHERE slug = ?"#
|
||||||
}
|
}
|
||||||
Query::Q002 => r#"INSERT INTO workspaces (slug, title, password_hash) VALUES (?, ?, ?)"#,
|
Query::Q002 => r#"INSERT INTO workspaces (slug, title, password_hash, created_by_guest_id) VALUES (?, ?, ?, ?)"#,
|
||||||
Query::Q003 => {
|
Query::Q003 => {
|
||||||
r#"SELECT id, workspace_id, slug, CAST(title AS CHAR CHARACTER SET utf8mb4) AS title, CAST(content AS CHAR CHARACTER SET utf8mb4) AS content, created_at, updated_at, CAST(owner_map AS CHAR CHARACTER SET utf8mb4) AS owner_map, CAST(CASE WHEN protected THEN 1 ELSE 0 END AS SIGNED) AS protected, CAST(created_by AS CHAR CHARACTER SET utf8mb4) AS created_by FROM notes WHERE workspace_id = ? ORDER BY updated_at DESC, id DESC"#
|
r#"SELECT id, workspace_id, slug, CAST(title AS CHAR CHARACTER SET utf8mb4) AS title, CAST(content AS CHAR CHARACTER SET utf8mb4) AS content, created_at, updated_at, CAST(owner_map AS CHAR CHARACTER SET utf8mb4) AS owner_map, CAST(CASE WHEN protected THEN 1 ELSE 0 END AS SIGNED) AS protected, CAST(created_by AS CHAR CHARACTER SET utf8mb4) AS created_by, CAST(created_by_guest_id AS CHAR CHARACTER SET utf8mb4) AS created_by_guest_id FROM notes WHERE workspace_id = ? ORDER BY updated_at DESC, id DESC"#
|
||||||
}
|
}
|
||||||
Query::Q004 => {
|
Query::Q004 => {
|
||||||
r#"SELECT id, workspace_id, slug, CAST(title AS CHAR CHARACTER SET utf8mb4) AS title, CAST(content AS CHAR CHARACTER SET utf8mb4) AS content, created_at, updated_at, CAST(owner_map AS CHAR CHARACTER SET utf8mb4) AS owner_map, CAST(CASE WHEN protected THEN 1 ELSE 0 END AS SIGNED) AS protected, CAST(created_by AS CHAR CHARACTER SET utf8mb4) AS created_by FROM notes WHERE workspace_id = ? AND slug = ?"#
|
r#"SELECT id, workspace_id, slug, CAST(title AS CHAR CHARACTER SET utf8mb4) AS title, CAST(content AS CHAR CHARACTER SET utf8mb4) AS content, created_at, updated_at, CAST(owner_map AS CHAR CHARACTER SET utf8mb4) AS owner_map, CAST(CASE WHEN protected THEN 1 ELSE 0 END AS SIGNED) AS protected, CAST(created_by AS CHAR CHARACTER SET utf8mb4) AS created_by, CAST(created_by_guest_id AS CHAR CHARACTER SET utf8mb4) AS created_by_guest_id FROM notes WHERE workspace_id = ? AND slug = ?"#
|
||||||
}
|
}
|
||||||
Query::Q005 => {
|
Query::Q005 => {
|
||||||
r#"INSERT INTO notes (workspace_id, slug, title, protected, created_by) VALUES (?, ?, ?, ?, ?)"#
|
r#"INSERT INTO notes (workspace_id, slug, title, protected, created_by, created_by_guest_id) VALUES (?, ?, ?, ?, ?, ?)"#
|
||||||
}
|
}
|
||||||
Query::Q006 => {
|
Query::Q006 => {
|
||||||
r#"UPDATE notes SET content = ?, owner_map = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"#
|
r#"UPDATE notes SET content = ?, owner_map = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"#
|
||||||
@@ -275,9 +301,11 @@ pub fn get(query: Query) -> &'static str {
|
|||||||
r#"SELECT id, CAST(content AS CHAR CHARACTER SET utf8mb4) AS content, created_at, CAST(author AS CHAR CHARACTER SET utf8mb4) AS author, CAST(owner_map AS CHAR CHARACTER SET utf8mb4) AS owner_map FROM note_revisions WHERE note_id = ? ORDER BY id DESC LIMIT 100"#
|
r#"SELECT id, CAST(content AS CHAR CHARACTER SET utf8mb4) AS content, created_at, CAST(author AS CHAR CHARACTER SET utf8mb4) AS author, CAST(owner_map AS CHAR CHARACTER SET utf8mb4) AS owner_map FROM note_revisions WHERE note_id = ? ORDER BY id DESC LIMIT 100"#
|
||||||
}
|
}
|
||||||
Query::Q011 => {
|
Query::Q011 => {
|
||||||
r#"SELECT id, slug, CAST(title AS CHAR CHARACTER SET utf8mb4) AS title, CAST(content AS CHAR CHARACTER SET utf8mb4) AS content, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, created_at, updated_at, CAST(owner_map AS CHAR CHARACTER SET utf8mb4) AS owner_map, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS SIGNED) AS is_private FROM pads WHERE slug = ?"#
|
r#"SELECT id, slug, CAST(title AS CHAR CHARACTER SET utf8mb4) AS title, CAST(content AS CHAR CHARACTER SET utf8mb4) AS content, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, created_at, updated_at, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS SIGNED) AS is_private, CAST(created_by_guest_id AS CHAR CHARACTER SET utf8mb4) AS created_by_guest_id FROM pads WHERE slug = ?"#
|
||||||
|
}
|
||||||
|
Query::Q012 => {
|
||||||
|
r#"INSERT INTO pads (slug, title, password_hash, created_by_guest_id) VALUES (?, ?, ?, ?)"#
|
||||||
}
|
}
|
||||||
Query::Q012 => r#"INSERT INTO pads (slug, title, password_hash) VALUES (?, ?, ?)"#,
|
|
||||||
Query::Q013 => {
|
Query::Q013 => {
|
||||||
r#"UPDATE pads SET content = ?, owner_map = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"#
|
r#"UPDATE pads SET content = ?, owner_map = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"#
|
||||||
}
|
}
|
||||||
@@ -293,7 +321,7 @@ pub fn get(query: Query) -> &'static str {
|
|||||||
Query::Q019 => r#"SELECT token FROM published_pages WHERE note_id = ?"#,
|
Query::Q019 => r#"SELECT token FROM published_pages WHERE note_id = ?"#,
|
||||||
Query::Q020 => r#"INSERT INTO published_pages (token, note_id) VALUES (?, ?)"#,
|
Query::Q020 => r#"INSERT INTO published_pages (token, note_id) VALUES (?, ?)"#,
|
||||||
Query::Q021 => {
|
Query::Q021 => {
|
||||||
r#"SELECT pp.token, pp.pad_id, pp.note_id, CAST(CASE WHEN pp.allow_task_updates THEN 1 ELSE 0 END AS SIGNED) AS allow_task_updates, CAST(COALESCE(p.title, n.title) AS CHAR CHARACTER SET utf8mb4) AS title, CAST(COALESCE(p.content, n.content) AS CHAR CHARACTER SET utf8mb4) AS content, COALESCE(p.updated_at, n.updated_at) AS updated_at FROM published_pages pp LEFT JOIN pads p ON p.id = pp.pad_id LEFT JOIN notes n ON n.id = pp.note_id WHERE pp.token = ?"#
|
r#"SELECT pp.token, pp.pad_id, pp.note_id, CAST(CASE WHEN pp.allow_task_updates THEN 1 ELSE 0 END AS SIGNED) AS allow_task_updates, CAST(COALESCE(p.slug, n.slug) AS CHAR CHARACTER SET utf8mb4) AS resource_slug, n.workspace_id AS workspace_id, CAST(w.slug AS CHAR CHARACTER SET utf8mb4) AS workspace_slug, CAST(COALESCE(p.owner_map, n.owner_map, '[]') AS CHAR CHARACTER SET utf8mb4) AS owner_map, CAST(COALESCE(p.title, n.title) AS CHAR CHARACTER SET utf8mb4) AS title, CAST(COALESCE(p.content, n.content) AS CHAR CHARACTER SET utf8mb4) AS content, COALESCE(p.updated_at, n.updated_at) AS updated_at FROM published_pages pp LEFT JOIN pads p ON p.id = pp.pad_id LEFT JOIN notes n ON n.id = pp.note_id LEFT JOIN workspaces w ON w.id = n.workspace_id WHERE pp.token = ?"#
|
||||||
}
|
}
|
||||||
Query::Q022 => r#"SELECT file_token FROM pads WHERE id = ?"#,
|
Query::Q022 => r#"SELECT file_token FROM pads WHERE id = ?"#,
|
||||||
Query::Q023 => r#"UPDATE pads SET file_token = ? WHERE id = ? AND file_token IS NULL"#,
|
Query::Q023 => r#"UPDATE pads SET file_token = ? WHERE id = ? AND file_token IS NULL"#,
|
||||||
@@ -335,12 +363,6 @@ pub fn get(query: Query) -> &'static str {
|
|||||||
Query::Q047 => r#"DELETE FROM pad_files WHERE id = ? AND pad_id = ?"#,
|
Query::Q047 => r#"DELETE FROM pad_files WHERE id = ? AND pad_id = ?"#,
|
||||||
Query::Q040 => r#"UPDATE published_pages SET allow_task_updates = ? WHERE pad_id = ?"#,
|
Query::Q040 => r#"UPDATE published_pages SET allow_task_updates = ? WHERE pad_id = ?"#,
|
||||||
Query::Q041 => r#"UPDATE published_pages SET allow_task_updates = ? WHERE note_id = ?"#,
|
Query::Q041 => r#"UPDATE published_pages SET allow_task_updates = ? WHERE note_id = ?"#,
|
||||||
Query::Q042 => {
|
|
||||||
r#"UPDATE pads SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"#
|
|
||||||
}
|
|
||||||
Query::Q043 => {
|
|
||||||
r#"UPDATE notes SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"#
|
|
||||||
}
|
|
||||||
Query::Q044 => {
|
Query::Q044 => {
|
||||||
r#"SELECT CAST(CASE WHEN allow_task_updates THEN 1 ELSE 0 END AS SIGNED) FROM published_pages WHERE pad_id = ?"#
|
r#"SELECT CAST(CASE WHEN allow_task_updates THEN 1 ELSE 0 END AS SIGNED) FROM published_pages WHERE pad_id = ?"#
|
||||||
}
|
}
|
||||||
@@ -355,6 +377,28 @@ pub fn get(query: Query) -> &'static str {
|
|||||||
r#"SELECT CAST(CASE WHEN unprotected THEN 1 ELSE 0 END AS SIGNED) FROM published_pages WHERE note_id = ?"#
|
r#"SELECT CAST(CASE WHEN unprotected THEN 1 ELSE 0 END AS SIGNED) FROM published_pages WHERE note_id = ?"#
|
||||||
}
|
}
|
||||||
Query::Q050 => r#"UPDATE published_pages SET unprotected = ? WHERE pad_id = ?"#,
|
Query::Q050 => r#"UPDATE published_pages SET unprotected = ? WHERE pad_id = ?"#,
|
||||||
|
Query::Q054 => {
|
||||||
|
r#"INSERT INTO note_revisions (note_id, content, author, owner_map, collaboration_client_id, collaboration_update_id) VALUES (?, ?, ?, ?, ?, ?)"#
|
||||||
|
}
|
||||||
|
Query::Q055 => {
|
||||||
|
r#"INSERT INTO revisions (pad_id, content, author, owner_map, collaboration_client_id, collaboration_update_id) VALUES (?, ?, ?, ?, ?, ?)"#
|
||||||
|
}
|
||||||
|
Query::Q056 => {
|
||||||
|
r#"SELECT MAX(collaboration_update_id) FROM note_revisions WHERE note_id = ? AND collaboration_client_id = ?"#
|
||||||
|
}
|
||||||
|
Query::Q057 => {
|
||||||
|
r#"SELECT MAX(collaboration_update_id) FROM revisions WHERE pad_id = ? AND collaboration_client_id = ?"#
|
||||||
|
}
|
||||||
|
Query::Q058 => {
|
||||||
|
r#"SELECT n.content, n.owner_map, COALESCE((SELECT MAX(r.id) FROM note_revisions r WHERE r.note_id = n.id), 0) AS revision_id FROM notes n WHERE n.id = ?"#
|
||||||
|
}
|
||||||
|
Query::Q059 => {
|
||||||
|
r#"SELECT p.content, p.owner_map, COALESCE((SELECT MAX(r.id) FROM revisions r WHERE r.pad_id = p.id), 0) AS revision_id FROM pads p WHERE p.id = ?"#
|
||||||
|
}
|
||||||
Query::Q051 => r#"UPDATE published_pages SET unprotected = ? WHERE note_id = ?"#,
|
Query::Q051 => r#"UPDATE published_pages SET unprotected = ? WHERE note_id = ?"#,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../tests/queries_mysql.rs"]
|
||||||
|
mod tests;
|
||||||
|
|||||||
+65
-27
@@ -31,6 +31,7 @@ pub fn get(query: Query) -> &'static str {
|
|||||||
Query::AUTH_UPDATE_EDITOR_COLOR => {
|
Query::AUTH_UPDATE_EDITOR_COLOR => {
|
||||||
r#"UPDATE users SET editor_color = $1, updated_at = $2 WHERE id = $3"#
|
r#"UPDATE users SET editor_color = $1, updated_at = $2 WHERE id = $3"#
|
||||||
}
|
}
|
||||||
|
Query::AUTH_UPDATE_THEME => r#"UPDATE users SET theme = $1, updated_at = $2 WHERE id = $3"#,
|
||||||
Query::AUTH_EDITOR_COLOR_BY_USER => r#"SELECT editor_color FROM users WHERE id = $1"#,
|
Query::AUTH_EDITOR_COLOR_BY_USER => r#"SELECT editor_color FROM users WHERE id = $1"#,
|
||||||
Query::RESOURCE_COLOR_BY_USER => {
|
Query::RESOURCE_COLOR_BY_USER => {
|
||||||
r#"SELECT color FROM user_resource_colors WHERE user_id = $1 AND resource_kind = $2 AND resource_slug = $3"#
|
r#"SELECT color FROM user_resource_colors WHERE user_id = $1 AND resource_kind = $2 AND resource_slug = $3"#
|
||||||
@@ -58,16 +59,16 @@ pub fn get(query: Query) -> &'static str {
|
|||||||
r#"DELETE FROM resource_editor_settings WHERE resource_kind = $1 AND resource_slug = $2"#
|
r#"DELETE FROM resource_editor_settings WHERE resource_kind = $1 AND resource_slug = $2"#
|
||||||
}
|
}
|
||||||
Query::EDITOR_PREFERENCES_SELECT_PAD => {
|
Query::EDITOR_PREFERENCES_SELECT_PAD => {
|
||||||
r#"SELECT (CASE WHEN compact_view THEN 1 ELSE 0 END)::BIGINT, (CASE WHEN editor_line_numbers THEN 1 ELSE 0 END)::BIGINT, (CASE WHEN preview_line_numbers THEN 1 ELSE 0 END)::BIGINT, (CASE WHEN line_links THEN 1 ELSE 0 END)::BIGINT, font_family, font_size FROM user_editor_preferences WHERE user_id = $1 AND pad_id = $2"#
|
r#"SELECT (CASE WHEN compact_view THEN 1 ELSE 0 END)::BIGINT, (CASE WHEN editor_line_numbers THEN 1 ELSE 0 END)::BIGINT, (CASE WHEN preview_line_numbers THEN 1 ELSE 0 END)::BIGINT, (CASE WHEN line_links THEN 1 ELSE 0 END)::BIGINT, (CASE WHEN toolbar_collapsed THEN 1 ELSE 0 END)::BIGINT, font_family, font_size FROM user_editor_preferences WHERE user_id = $1 AND pad_id = $2"#
|
||||||
}
|
}
|
||||||
Query::EDITOR_PREFERENCES_SELECT_NOTE => {
|
Query::EDITOR_PREFERENCES_SELECT_NOTE => {
|
||||||
r#"SELECT (CASE WHEN compact_view THEN 1 ELSE 0 END)::BIGINT, (CASE WHEN editor_line_numbers THEN 1 ELSE 0 END)::BIGINT, (CASE WHEN preview_line_numbers THEN 1 ELSE 0 END)::BIGINT, (CASE WHEN line_links THEN 1 ELSE 0 END)::BIGINT, font_family, font_size FROM user_editor_preferences WHERE user_id = $1 AND note_id = $2"#
|
r#"SELECT (CASE WHEN compact_view THEN 1 ELSE 0 END)::BIGINT, (CASE WHEN editor_line_numbers THEN 1 ELSE 0 END)::BIGINT, (CASE WHEN preview_line_numbers THEN 1 ELSE 0 END)::BIGINT, (CASE WHEN line_links THEN 1 ELSE 0 END)::BIGINT, (CASE WHEN toolbar_collapsed THEN 1 ELSE 0 END)::BIGINT, font_family, font_size FROM user_editor_preferences WHERE user_id = $1 AND note_id = $2"#
|
||||||
}
|
}
|
||||||
Query::EDITOR_PREFERENCES_UPSERT_PAD => {
|
Query::EDITOR_PREFERENCES_UPSERT_PAD => {
|
||||||
r#"INSERT INTO user_editor_preferences (user_id, pad_id, note_id, compact_view, editor_line_numbers, preview_line_numbers, line_links, font_family, font_size, updated_at) VALUES ($1, $2, NULL, $3, $4, $5, $6, $7, $8, CURRENT_TIMESTAMP::text) ON CONFLICT (user_id, pad_id) DO UPDATE SET compact_view = EXCLUDED.compact_view, editor_line_numbers = EXCLUDED.editor_line_numbers, preview_line_numbers = EXCLUDED.preview_line_numbers, line_links = EXCLUDED.line_links, font_family = EXCLUDED.font_family, font_size = EXCLUDED.font_size, updated_at = CURRENT_TIMESTAMP::text"#
|
r#"INSERT INTO user_editor_preferences (user_id, pad_id, note_id, compact_view, editor_line_numbers, preview_line_numbers, line_links, toolbar_collapsed, font_family, font_size, updated_at) VALUES ($1, $2, NULL, $3, $4, $5, $6, $7, $8, $9, CURRENT_TIMESTAMP::text) ON CONFLICT (user_id, pad_id) DO UPDATE SET compact_view = EXCLUDED.compact_view, editor_line_numbers = EXCLUDED.editor_line_numbers, preview_line_numbers = EXCLUDED.preview_line_numbers, line_links = EXCLUDED.line_links, toolbar_collapsed = EXCLUDED.toolbar_collapsed, font_family = EXCLUDED.font_family, font_size = EXCLUDED.font_size, updated_at = CURRENT_TIMESTAMP::text"#
|
||||||
}
|
}
|
||||||
Query::EDITOR_PREFERENCES_UPSERT_NOTE => {
|
Query::EDITOR_PREFERENCES_UPSERT_NOTE => {
|
||||||
r#"INSERT INTO user_editor_preferences (user_id, pad_id, note_id, compact_view, editor_line_numbers, preview_line_numbers, line_links, font_family, font_size, updated_at) VALUES ($1, NULL, $2, $3, $4, $5, $6, $7, $8, CURRENT_TIMESTAMP::text) ON CONFLICT (user_id, note_id) DO UPDATE SET compact_view = EXCLUDED.compact_view, editor_line_numbers = EXCLUDED.editor_line_numbers, preview_line_numbers = EXCLUDED.preview_line_numbers, line_links = EXCLUDED.line_links, font_family = EXCLUDED.font_family, font_size = EXCLUDED.font_size, updated_at = CURRENT_TIMESTAMP::text"#
|
r#"INSERT INTO user_editor_preferences (user_id, pad_id, note_id, compact_view, editor_line_numbers, preview_line_numbers, line_links, toolbar_collapsed, font_family, font_size, updated_at) VALUES ($1, NULL, $2, $3, $4, $5, $6, $7, $8, $9, CURRENT_TIMESTAMP::text) ON CONFLICT (user_id, note_id) DO UPDATE SET compact_view = EXCLUDED.compact_view, editor_line_numbers = EXCLUDED.editor_line_numbers, preview_line_numbers = EXCLUDED.preview_line_numbers, line_links = EXCLUDED.line_links, toolbar_collapsed = EXCLUDED.toolbar_collapsed, font_family = EXCLUDED.font_family, font_size = EXCLUDED.font_size, updated_at = CURRENT_TIMESTAMP::text"#
|
||||||
}
|
}
|
||||||
Query::EDITOR_PREFERENCES_DELETE_BY_USER => {
|
Query::EDITOR_PREFERENCES_DELETE_BY_USER => {
|
||||||
r#"DELETE FROM user_editor_preferences WHERE user_id = $1"#
|
r#"DELETE FROM user_editor_preferences WHERE user_id = $1"#
|
||||||
@@ -103,7 +104,7 @@ pub fn get(query: Query) -> &'static str {
|
|||||||
r#"SELECT auth_provider, directory_display_name FROM users WHERE id = $1"#
|
r#"SELECT auth_provider, directory_display_name FROM users WHERE id = $1"#
|
||||||
}
|
}
|
||||||
Query::AUTH_USER_BY_EXTERNAL_ID => {
|
Query::AUTH_USER_BY_EXTERNAL_ID => {
|
||||||
r#"SELECT id, nickname, email, password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS BIGINT) AS is_active FROM users WHERE auth_provider = $1 AND external_id = $2"#
|
r#"SELECT id, nickname, email, password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS BIGINT) AS is_active, theme FROM users WHERE auth_provider = $1 AND external_id = $2"#
|
||||||
}
|
}
|
||||||
Query::AUTH_DELETE_USER => r#"DELETE FROM users WHERE id = $1"#,
|
Query::AUTH_DELETE_USER => r#"DELETE FROM users WHERE id = $1"#,
|
||||||
Query::AUTH_ANONYMIZE_USER => {
|
Query::AUTH_ANONYMIZE_USER => {
|
||||||
@@ -152,19 +153,19 @@ pub fn get(query: Query) -> &'static str {
|
|||||||
}
|
}
|
||||||
Query::AUTH_DELETE_SESSIONS_BY_USER => r#"DELETE FROM user_sessions WHERE user_id = $1"#,
|
Query::AUTH_DELETE_SESSIONS_BY_USER => r#"DELETE FROM user_sessions WHERE user_id = $1"#,
|
||||||
Query::AUTH_USER_BY_SESSION => {
|
Query::AUTH_USER_BY_SESSION => {
|
||||||
r#"SELECT u.id, u.nickname, u.email, u.password_hash, u.confirmed_at, CAST(CASE WHEN u.is_active THEN 1 ELSE 0 END AS BIGINT) AS is_active FROM user_sessions s JOIN users u ON u.id = s.user_id WHERE s.token = $1 AND s.expires_at > $2 AND u.is_active = TRUE"#
|
r#"SELECT u.id, u.nickname, u.email, u.password_hash, u.confirmed_at, CAST(CASE WHEN u.is_active THEN 1 ELSE 0 END AS BIGINT) AS is_active, u.theme FROM user_sessions s JOIN users u ON u.id = s.user_id WHERE s.token = $1 AND s.expires_at > $2 AND u.is_active = TRUE"#
|
||||||
}
|
}
|
||||||
Query::AUTH_INSERT_SESSION => {
|
Query::AUTH_INSERT_SESSION => {
|
||||||
r#"INSERT INTO user_sessions (token, user_id, expires_at) VALUES ($1, $2, $3)"#
|
r#"INSERT INTO user_sessions (token, user_id, expires_at) VALUES ($1, $2, $3)"#
|
||||||
}
|
}
|
||||||
Query::AUTH_USER_BY_NICKNAME => {
|
Query::AUTH_USER_BY_NICKNAME => {
|
||||||
r#"SELECT id, nickname, email, password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS BIGINT) AS is_active FROM users WHERE nickname_key = $1"#
|
r#"SELECT id, nickname, email, password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS BIGINT) AS is_active, theme FROM users WHERE nickname_key = $1"#
|
||||||
}
|
}
|
||||||
Query::AUTH_USER_BY_EMAIL => {
|
Query::AUTH_USER_BY_EMAIL => {
|
||||||
r#"SELECT id, nickname, email, password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS BIGINT) AS is_active FROM users WHERE email_key = $1"#
|
r#"SELECT id, nickname, email, password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS BIGINT) AS is_active, theme FROM users WHERE email_key = $1"#
|
||||||
}
|
}
|
||||||
Query::AUTH_USER_BY_SHARE_IDENTIFIER => {
|
Query::AUTH_USER_BY_SHARE_IDENTIFIER => {
|
||||||
r#"SELECT id, nickname, email, password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS BIGINT) AS is_active FROM users WHERE is_active = TRUE AND (email_key = $1 OR LOWER(directory_username) = $1 OR LOWER(external_id) = $1) LIMIT 1"#
|
r#"SELECT id, nickname, email, password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS BIGINT) AS is_active, theme FROM users WHERE is_active = TRUE AND (email_key = $1 OR LOWER(directory_username) = $1 OR LOWER(external_id) = $1) LIMIT 1"#
|
||||||
}
|
}
|
||||||
Query::USER_ATTACH_WORKSPACE => {
|
Query::USER_ATTACH_WORKSPACE => {
|
||||||
r#"INSERT INTO user_workspaces (user_id, workspace_id) SELECT $1, id FROM workspaces WHERE slug = $2"#
|
r#"INSERT INTO user_workspaces (user_id, workspace_id) SELECT $1, id FROM workspaces WHERE slug = $2"#
|
||||||
@@ -201,6 +202,9 @@ pub fn get(query: Query) -> &'static str {
|
|||||||
Query::RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE => {
|
Query::RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE => {
|
||||||
r#"DELETE FROM resource_access_tokens WHERE resource_kind = $1 AND resource_slug = $2"#
|
r#"DELETE FROM resource_access_tokens WHERE resource_kind = $1 AND resource_slug = $2"#
|
||||||
}
|
}
|
||||||
|
Query::RESOURCE_ACCESS_TOKENS_DELETE_BY_TOKEN_HASH => {
|
||||||
|
r#"DELETE FROM resource_access_tokens WHERE token_hash = $1"#
|
||||||
|
}
|
||||||
Query::RESOURCE_ACCESS_TOKENS_INSERT => {
|
Query::RESOURCE_ACCESS_TOKENS_INSERT => {
|
||||||
r#"INSERT INTO resource_access_tokens (token_hash, resource_kind, resource_slug, expires_at) VALUES ($1, $2, $3, $4)"#
|
r#"INSERT INTO resource_access_tokens (token_hash, resource_kind, resource_slug, expires_at) VALUES ($1, $2, $3, $4)"#
|
||||||
}
|
}
|
||||||
@@ -232,16 +236,16 @@ pub fn get(query: Query) -> &'static str {
|
|||||||
r#"SELECT u.email, u.nickname, rp.permission FROM resource_permissions rp JOIN users u ON u.id = rp.user_id WHERE rp.resource_kind = $1 AND rp.resource_slug = $2 ORDER BY u.email"#
|
r#"SELECT u.email, u.nickname, rp.permission FROM resource_permissions rp JOIN users u ON u.id = rp.user_id WHERE rp.resource_kind = $1 AND rp.resource_slug = $2 ORDER BY u.email"#
|
||||||
}
|
}
|
||||||
Query::RESOURCE_SHARING_LINKS => {
|
Query::RESOURCE_SHARING_LINKS => {
|
||||||
r#"SELECT token_hash, token, permission, expires_at, created_at FROM resource_share_links WHERE resource_kind = $1 AND resource_slug = $2 AND revoked_at IS NULL ORDER BY created_at DESC"#
|
r#"SELECT token_hash, label, permission, expires_at, created_at FROM resource_share_links WHERE resource_kind = $1 AND resource_slug = $2 AND revoked_at IS NULL ORDER BY created_at DESC"#
|
||||||
}
|
}
|
||||||
Query::RESOURCE_SHARING_PENDING => {
|
Query::RESOURCE_SHARING_PENDING => {
|
||||||
r#"SELECT u.email, u.nickname, i.permission, i.expires_at FROM resource_share_invitations i JOIN users u ON u.id = i.user_id WHERE i.resource_kind = $1 AND i.resource_slug = $2 AND i.accepted_at IS NULL ORDER BY u.email"#
|
r#"SELECT u.email, u.nickname, i.permission, i.expires_at FROM resource_share_invitations i JOIN users u ON u.id = i.user_id WHERE i.resource_kind = $1 AND i.resource_slug = $2 AND i.accepted_at IS NULL ORDER BY u.email"#
|
||||||
}
|
}
|
||||||
Query::SHARE_LINK_INSERT => {
|
Query::SHARE_LINK_INSERT => {
|
||||||
r#"INSERT INTO resource_share_links (token_hash, token, resource_kind, resource_slug, permission, expires_at, created_by) VALUES ($1, $2, $3, $4, $5, $6, $7)"#
|
r#"INSERT INTO resource_share_links (token_hash, label, resource_kind, resource_slug, permission, expires_at, created_by) VALUES ($1, $2, $3, $4, $5, $6, $7)"#
|
||||||
}
|
}
|
||||||
Query::SHARE_LINK_UPDATE => {
|
Query::SHARE_LINK_UPDATE => {
|
||||||
r#"UPDATE resource_share_links SET permission = $1, expires_at = $2 WHERE token_hash = $3 AND resource_kind = $4 AND resource_slug = $5 AND revoked_at IS NULL"#
|
r#"UPDATE resource_share_links SET label = $1, permission = $2, expires_at = $3 WHERE token_hash = $4 AND resource_kind = $5 AND resource_slug = $6 AND revoked_at IS NULL"#
|
||||||
}
|
}
|
||||||
Query::SHARE_LINK_REVOKE => {
|
Query::SHARE_LINK_REVOKE => {
|
||||||
r#"UPDATE resource_share_links SET revoked_at = $1 WHERE token_hash = $2 AND resource_kind = $3 AND resource_slug = $4"#
|
r#"UPDATE resource_share_links SET revoked_at = $1 WHERE token_hash = $2 AND resource_kind = $3 AND resource_slug = $4"#
|
||||||
@@ -250,20 +254,40 @@ pub fn get(query: Query) -> &'static str {
|
|||||||
r#"SELECT permission FROM resource_permissions WHERE resource_kind = $1 AND resource_slug = $2 AND user_id = $3"#
|
r#"SELECT permission FROM resource_permissions WHERE resource_kind = $1 AND resource_slug = $2 AND user_id = $3"#
|
||||||
}
|
}
|
||||||
Query::SHARE_LINK_PERMISSION => {
|
Query::SHARE_LINK_PERMISSION => {
|
||||||
r#"SELECT permission FROM resource_share_links WHERE token_hash = $1 AND resource_kind = $2 AND resource_slug = $3 AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > $4)"#
|
r#"SELECT permission, expires_at FROM resource_share_links WHERE token_hash = $1 AND resource_kind = $2 AND resource_slug = $3 AND revoked_at IS NULL"#
|
||||||
|
}
|
||||||
|
Query::SHARE_LINK_SESSION_SOURCE => {
|
||||||
|
r#"SELECT token_hash, permission, expires_at FROM resource_share_links WHERE token_hash = $1 AND resource_kind = $2 AND resource_slug = $3 AND revoked_at IS NULL"#
|
||||||
|
}
|
||||||
|
Query::SHARE_SESSION_INSERT => {
|
||||||
|
r#"INSERT INTO resource_share_sessions (session_token_hash, share_token_hash, resource_kind, resource_slug, expires_at) VALUES ($1, $2, $3, $4, $5)"#
|
||||||
|
}
|
||||||
|
Query::SHARE_SESSION_PERMISSION => {
|
||||||
|
r#"SELECT l.permission, s.expires_at, l.expires_at FROM resource_share_sessions s JOIN resource_share_links l ON l.token_hash = s.share_token_hash AND l.resource_kind = s.resource_kind AND l.resource_slug = s.resource_slug WHERE s.session_token_hash = $1 AND s.resource_kind = $2 AND s.resource_slug = $3 AND l.revoked_at IS NULL"#
|
||||||
|
}
|
||||||
|
Query::SHARE_SESSIONS_DELETE_BY_LINK => {
|
||||||
|
r#"DELETE FROM resource_share_sessions WHERE share_token_hash = $1 AND resource_kind = $2 AND resource_slug = $3"#
|
||||||
|
}
|
||||||
|
Query::SHARE_SESSIONS_DELETE_EXPIRED => {
|
||||||
|
r#"DELETE FROM resource_share_sessions WHERE expires_at <= $1"#
|
||||||
|
}
|
||||||
|
|
||||||
|
Query::PAD_PUBLIC_PAGE_DISABLED => r#"SELECT public_page_disabled FROM pads WHERE id = $1"#,
|
||||||
|
Query::NOTE_PUBLIC_PAGE_DISABLED => {
|
||||||
|
r#"SELECT public_page_disabled FROM notes WHERE id = $1"#
|
||||||
}
|
}
|
||||||
Query::Q001 => {
|
Query::Q001 => {
|
||||||
r#"SELECT id, slug, title, password_hash, created_at, updated_at, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS BIGINT) AS is_private FROM workspaces WHERE slug = $1"#
|
r#"SELECT id, slug, title, password_hash, created_at, updated_at, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS BIGINT) AS is_private, created_by_guest_id FROM workspaces WHERE slug = $1"#
|
||||||
}
|
}
|
||||||
Query::Q002 => r#"INSERT INTO workspaces (slug, title, password_hash) VALUES ($1, $2, $3)"#,
|
Query::Q002 => r#"INSERT INTO workspaces (slug, title, password_hash, created_by_guest_id) VALUES ($1, $2, $3, $4)"#,
|
||||||
Query::Q003 => {
|
Query::Q003 => {
|
||||||
r#"SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map, CAST(CASE WHEN protected THEN 1 ELSE 0 END AS BIGINT) AS protected, created_by FROM notes WHERE workspace_id = $1 ORDER BY updated_at DESC, id DESC"#
|
r#"SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map, CAST(CASE WHEN protected THEN 1 ELSE 0 END AS BIGINT) AS protected, created_by, created_by_guest_id FROM notes WHERE workspace_id = $1 ORDER BY updated_at DESC, id DESC"#
|
||||||
}
|
}
|
||||||
Query::Q004 => {
|
Query::Q004 => {
|
||||||
r#"SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map, CAST(CASE WHEN protected THEN 1 ELSE 0 END AS BIGINT) AS protected, created_by FROM notes WHERE workspace_id = $1 AND slug = $2"#
|
r#"SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map, CAST(CASE WHEN protected THEN 1 ELSE 0 END AS BIGINT) AS protected, created_by, created_by_guest_id FROM notes WHERE workspace_id = $1 AND slug = $2"#
|
||||||
}
|
}
|
||||||
Query::Q005 => {
|
Query::Q005 => {
|
||||||
r#"INSERT INTO notes (workspace_id, slug, title, protected, created_by) VALUES ($1, $2, $3, $4, $5)"#
|
r#"INSERT INTO notes (workspace_id, slug, title, protected, created_by, created_by_guest_id) VALUES ($1, $2, $3, $4, $5, $6)"#
|
||||||
}
|
}
|
||||||
Query::Q006 => {
|
Query::Q006 => {
|
||||||
r#"UPDATE notes SET content = $1, owner_map = $2, updated_at = (CURRENT_TIMESTAMP::text) WHERE id = $3"#
|
r#"UPDATE notes SET content = $1, owner_map = $2, updated_at = (CURRENT_TIMESTAMP::text) WHERE id = $3"#
|
||||||
@@ -279,9 +303,11 @@ pub fn get(query: Query) -> &'static str {
|
|||||||
r#"SELECT id, content, created_at, author, owner_map FROM note_revisions WHERE note_id = $1 ORDER BY id DESC LIMIT 100"#
|
r#"SELECT id, content, created_at, author, owner_map FROM note_revisions WHERE note_id = $1 ORDER BY id DESC LIMIT 100"#
|
||||||
}
|
}
|
||||||
Query::Q011 => {
|
Query::Q011 => {
|
||||||
r#"SELECT id, slug, title, content, password_hash, created_at, updated_at, owner_map, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS BIGINT) AS is_private FROM pads WHERE slug = $1"#
|
r#"SELECT id, slug, title, content, password_hash, created_at, updated_at, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS BIGINT) AS is_private, created_by_guest_id FROM pads WHERE slug = $1"#
|
||||||
|
}
|
||||||
|
Query::Q012 => {
|
||||||
|
r#"INSERT INTO pads (slug, title, password_hash, created_by_guest_id) VALUES ($1, $2, $3, $4)"#
|
||||||
}
|
}
|
||||||
Query::Q012 => r#"INSERT INTO pads (slug, title, password_hash) VALUES ($1, $2, $3)"#,
|
|
||||||
Query::Q013 => {
|
Query::Q013 => {
|
||||||
r#"UPDATE pads SET content = $1, owner_map = $2, updated_at = (CURRENT_TIMESTAMP::text) WHERE id = $3"#
|
r#"UPDATE pads SET content = $1, owner_map = $2, updated_at = (CURRENT_TIMESTAMP::text) WHERE id = $3"#
|
||||||
}
|
}
|
||||||
@@ -297,7 +323,7 @@ pub fn get(query: Query) -> &'static str {
|
|||||||
Query::Q019 => r#"SELECT token FROM published_pages WHERE note_id = $1"#,
|
Query::Q019 => r#"SELECT token FROM published_pages WHERE note_id = $1"#,
|
||||||
Query::Q020 => r#"INSERT INTO published_pages (token, note_id) VALUES ($1, $2)"#,
|
Query::Q020 => r#"INSERT INTO published_pages (token, note_id) VALUES ($1, $2)"#,
|
||||||
Query::Q021 => {
|
Query::Q021 => {
|
||||||
r#"SELECT pp.token, pp.pad_id, pp.note_id, pp.allow_task_updates, COALESCE(p.title, n.title) AS title, COALESCE(p.content, n.content) AS content, COALESCE(p.updated_at, n.updated_at) AS updated_at FROM published_pages pp LEFT JOIN pads p ON p.id = pp.pad_id LEFT JOIN notes n ON n.id = pp.note_id WHERE pp.token = $1"#
|
r#"SELECT pp.token, pp.pad_id, pp.note_id, pp.allow_task_updates, COALESCE(p.slug, n.slug) AS resource_slug, n.workspace_id AS workspace_id, w.slug AS workspace_slug, COALESCE(p.owner_map, n.owner_map, '[]') AS owner_map, COALESCE(p.title, n.title) AS title, COALESCE(p.content, n.content) AS content, COALESCE(p.updated_at, n.updated_at) AS updated_at FROM published_pages pp LEFT JOIN pads p ON p.id = pp.pad_id LEFT JOIN notes n ON n.id = pp.note_id LEFT JOIN workspaces w ON w.id = n.workspace_id WHERE pp.token = $1"#
|
||||||
}
|
}
|
||||||
Query::Q022 => r#"SELECT file_token FROM pads WHERE id = $1"#,
|
Query::Q022 => r#"SELECT file_token FROM pads WHERE id = $1"#,
|
||||||
Query::Q023 => r#"UPDATE pads SET file_token = $1 WHERE id = $2 AND file_token IS NULL"#,
|
Query::Q023 => r#"UPDATE pads SET file_token = $1 WHERE id = $2 AND file_token IS NULL"#,
|
||||||
@@ -333,18 +359,30 @@ pub fn get(query: Query) -> &'static str {
|
|||||||
Query::Q047 => r#"DELETE FROM pad_files WHERE id = $1 AND pad_id = $2"#,
|
Query::Q047 => r#"DELETE FROM pad_files WHERE id = $1 AND pad_id = $2"#,
|
||||||
Query::Q040 => r#"UPDATE published_pages SET allow_task_updates = $1 WHERE pad_id = $2"#,
|
Query::Q040 => r#"UPDATE published_pages SET allow_task_updates = $1 WHERE pad_id = $2"#,
|
||||||
Query::Q041 => r#"UPDATE published_pages SET allow_task_updates = $1 WHERE note_id = $2"#,
|
Query::Q041 => r#"UPDATE published_pages SET allow_task_updates = $1 WHERE note_id = $2"#,
|
||||||
Query::Q042 => {
|
|
||||||
r#"UPDATE pads SET content = $1, updated_at = (CURRENT_TIMESTAMP::text) WHERE id = $2"#
|
|
||||||
}
|
|
||||||
Query::Q043 => {
|
|
||||||
r#"UPDATE notes SET content = $1, updated_at = (CURRENT_TIMESTAMP::text) WHERE id = $2"#
|
|
||||||
}
|
|
||||||
Query::Q044 => r#"SELECT allow_task_updates FROM published_pages WHERE pad_id = $1"#,
|
Query::Q044 => r#"SELECT allow_task_updates FROM published_pages WHERE pad_id = $1"#,
|
||||||
Query::Q045 => r#"SELECT allow_task_updates FROM published_pages WHERE note_id = $1"#,
|
Query::Q045 => r#"SELECT allow_task_updates FROM published_pages WHERE note_id = $1"#,
|
||||||
|
|
||||||
Query::Q048 => r#"SELECT unprotected FROM published_pages WHERE pad_id = $1"#,
|
Query::Q048 => r#"SELECT unprotected FROM published_pages WHERE pad_id = $1"#,
|
||||||
Query::Q049 => r#"SELECT unprotected FROM published_pages WHERE note_id = $1"#,
|
Query::Q049 => r#"SELECT unprotected FROM published_pages WHERE note_id = $1"#,
|
||||||
Query::Q050 => r#"UPDATE published_pages SET unprotected = $1 WHERE pad_id = $2"#,
|
Query::Q050 => r#"UPDATE published_pages SET unprotected = $1 WHERE pad_id = $2"#,
|
||||||
|
Query::Q054 => {
|
||||||
|
r#"INSERT INTO note_revisions (note_id, content, author, owner_map, collaboration_client_id, collaboration_update_id) VALUES ($1, $2, $3, $4, $5, $6)"#
|
||||||
|
}
|
||||||
|
Query::Q055 => {
|
||||||
|
r#"INSERT INTO revisions (pad_id, content, author, owner_map, collaboration_client_id, collaboration_update_id) VALUES ($1, $2, $3, $4, $5, $6)"#
|
||||||
|
}
|
||||||
|
Query::Q056 => {
|
||||||
|
r#"SELECT MAX(collaboration_update_id) FROM note_revisions WHERE note_id = $1 AND collaboration_client_id = $2"#
|
||||||
|
}
|
||||||
|
Query::Q057 => {
|
||||||
|
r#"SELECT MAX(collaboration_update_id) FROM revisions WHERE pad_id = $1 AND collaboration_client_id = $2"#
|
||||||
|
}
|
||||||
|
Query::Q058 => {
|
||||||
|
r#"SELECT n.content, n.owner_map, COALESCE((SELECT MAX(r.id) FROM note_revisions r WHERE r.note_id = n.id), 0) AS revision_id FROM notes n WHERE n.id = $1"#
|
||||||
|
}
|
||||||
|
Query::Q059 => {
|
||||||
|
r#"SELECT p.content, p.owner_map, COALESCE((SELECT MAX(r.id) FROM revisions r WHERE r.pad_id = p.id), 0) AS revision_id FROM pads p WHERE p.id = $1"#
|
||||||
|
}
|
||||||
Query::Q051 => r#"UPDATE published_pages SET unprotected = $1 WHERE note_id = $2"#,
|
Query::Q051 => r#"UPDATE published_pages SET unprotected = $1 WHERE note_id = $2"#,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+67
-27
@@ -31,6 +31,7 @@ pub fn get(query: Query) -> &'static str {
|
|||||||
Query::AUTH_UPDATE_EDITOR_COLOR => {
|
Query::AUTH_UPDATE_EDITOR_COLOR => {
|
||||||
r#"UPDATE users SET editor_color = ?, updated_at = ? WHERE id = ?"#
|
r#"UPDATE users SET editor_color = ?, updated_at = ? WHERE id = ?"#
|
||||||
}
|
}
|
||||||
|
Query::AUTH_UPDATE_THEME => r#"UPDATE users SET theme = ?, updated_at = ? WHERE id = ?"#,
|
||||||
Query::AUTH_EDITOR_COLOR_BY_USER => r#"SELECT editor_color FROM users WHERE id = ?"#,
|
Query::AUTH_EDITOR_COLOR_BY_USER => r#"SELECT editor_color FROM users WHERE id = ?"#,
|
||||||
Query::RESOURCE_COLOR_BY_USER => {
|
Query::RESOURCE_COLOR_BY_USER => {
|
||||||
r#"SELECT color FROM user_resource_colors WHERE user_id = ? AND resource_kind = ? AND resource_slug = ?"#
|
r#"SELECT color FROM user_resource_colors WHERE user_id = ? AND resource_kind = ? AND resource_slug = ?"#
|
||||||
@@ -58,16 +59,16 @@ pub fn get(query: Query) -> &'static str {
|
|||||||
r#"DELETE FROM resource_editor_settings WHERE resource_kind = ? AND resource_slug = ?"#
|
r#"DELETE FROM resource_editor_settings WHERE resource_kind = ? AND resource_slug = ?"#
|
||||||
}
|
}
|
||||||
Query::EDITOR_PREFERENCES_SELECT_PAD => {
|
Query::EDITOR_PREFERENCES_SELECT_PAD => {
|
||||||
r#"SELECT CASE WHEN compact_view THEN 1 ELSE 0 END, CASE WHEN editor_line_numbers THEN 1 ELSE 0 END, CASE WHEN preview_line_numbers THEN 1 ELSE 0 END, CASE WHEN line_links THEN 1 ELSE 0 END, font_family, font_size FROM user_editor_preferences WHERE user_id = ? AND pad_id = ?"#
|
r#"SELECT CASE WHEN compact_view THEN 1 ELSE 0 END, CASE WHEN editor_line_numbers THEN 1 ELSE 0 END, CASE WHEN preview_line_numbers THEN 1 ELSE 0 END, CASE WHEN line_links THEN 1 ELSE 0 END, CASE WHEN toolbar_collapsed THEN 1 ELSE 0 END, font_family, font_size FROM user_editor_preferences WHERE user_id = ? AND pad_id = ?"#
|
||||||
}
|
}
|
||||||
Query::EDITOR_PREFERENCES_SELECT_NOTE => {
|
Query::EDITOR_PREFERENCES_SELECT_NOTE => {
|
||||||
r#"SELECT CASE WHEN compact_view THEN 1 ELSE 0 END, CASE WHEN editor_line_numbers THEN 1 ELSE 0 END, CASE WHEN preview_line_numbers THEN 1 ELSE 0 END, CASE WHEN line_links THEN 1 ELSE 0 END, font_family, font_size FROM user_editor_preferences WHERE user_id = ? AND note_id = ?"#
|
r#"SELECT CASE WHEN compact_view THEN 1 ELSE 0 END, CASE WHEN editor_line_numbers THEN 1 ELSE 0 END, CASE WHEN preview_line_numbers THEN 1 ELSE 0 END, CASE WHEN line_links THEN 1 ELSE 0 END, CASE WHEN toolbar_collapsed THEN 1 ELSE 0 END, font_family, font_size FROM user_editor_preferences WHERE user_id = ? AND note_id = ?"#
|
||||||
}
|
}
|
||||||
Query::EDITOR_PREFERENCES_UPSERT_PAD => {
|
Query::EDITOR_PREFERENCES_UPSERT_PAD => {
|
||||||
r#"INSERT INTO user_editor_preferences (user_id, pad_id, note_id, compact_view, editor_line_numbers, preview_line_numbers, line_links, font_family, font_size, updated_at) VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT (user_id, pad_id) DO UPDATE SET compact_view = excluded.compact_view, editor_line_numbers = excluded.editor_line_numbers, preview_line_numbers = excluded.preview_line_numbers, line_links = excluded.line_links, font_family = excluded.font_family, font_size = excluded.font_size, updated_at = CURRENT_TIMESTAMP"#
|
r#"INSERT INTO user_editor_preferences (user_id, pad_id, note_id, compact_view, editor_line_numbers, preview_line_numbers, line_links, toolbar_collapsed, font_family, font_size, updated_at) VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT (user_id, pad_id) DO UPDATE SET compact_view = excluded.compact_view, editor_line_numbers = excluded.editor_line_numbers, preview_line_numbers = excluded.preview_line_numbers, line_links = excluded.line_links, toolbar_collapsed = excluded.toolbar_collapsed, font_family = excluded.font_family, font_size = excluded.font_size, updated_at = CURRENT_TIMESTAMP"#
|
||||||
}
|
}
|
||||||
Query::EDITOR_PREFERENCES_UPSERT_NOTE => {
|
Query::EDITOR_PREFERENCES_UPSERT_NOTE => {
|
||||||
r#"INSERT INTO user_editor_preferences (user_id, pad_id, note_id, compact_view, editor_line_numbers, preview_line_numbers, line_links, font_family, font_size, updated_at) VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT (user_id, note_id) DO UPDATE SET compact_view = excluded.compact_view, editor_line_numbers = excluded.editor_line_numbers, preview_line_numbers = excluded.preview_line_numbers, line_links = excluded.line_links, font_family = excluded.font_family, font_size = excluded.font_size, updated_at = CURRENT_TIMESTAMP"#
|
r#"INSERT INTO user_editor_preferences (user_id, pad_id, note_id, compact_view, editor_line_numbers, preview_line_numbers, line_links, toolbar_collapsed, font_family, font_size, updated_at) VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT (user_id, note_id) DO UPDATE SET compact_view = excluded.compact_view, editor_line_numbers = excluded.editor_line_numbers, preview_line_numbers = excluded.preview_line_numbers, line_links = excluded.line_links, toolbar_collapsed = excluded.toolbar_collapsed, font_family = excluded.font_family, font_size = excluded.font_size, updated_at = CURRENT_TIMESTAMP"#
|
||||||
}
|
}
|
||||||
Query::EDITOR_PREFERENCES_DELETE_BY_USER => {
|
Query::EDITOR_PREFERENCES_DELETE_BY_USER => {
|
||||||
r#"DELETE FROM user_editor_preferences WHERE user_id = ?"#
|
r#"DELETE FROM user_editor_preferences WHERE user_id = ?"#
|
||||||
@@ -103,7 +104,7 @@ pub fn get(query: Query) -> &'static str {
|
|||||||
r#"SELECT auth_provider, directory_display_name FROM users WHERE id = ?"#
|
r#"SELECT auth_provider, directory_display_name FROM users WHERE id = ?"#
|
||||||
}
|
}
|
||||||
Query::AUTH_USER_BY_EXTERNAL_ID => {
|
Query::AUTH_USER_BY_EXTERNAL_ID => {
|
||||||
r#"SELECT id, nickname, email, password_hash, confirmed_at, is_active FROM users WHERE auth_provider = ? AND external_id = ?"#
|
r#"SELECT id, nickname, email, password_hash, confirmed_at, is_active, theme FROM users WHERE auth_provider = ? AND external_id = ?"#
|
||||||
}
|
}
|
||||||
Query::AUTH_DELETE_USER => r#"DELETE FROM users WHERE id = ?"#,
|
Query::AUTH_DELETE_USER => r#"DELETE FROM users WHERE id = ?"#,
|
||||||
Query::AUTH_ANONYMIZE_USER => {
|
Query::AUTH_ANONYMIZE_USER => {
|
||||||
@@ -150,19 +151,19 @@ pub fn get(query: Query) -> &'static str {
|
|||||||
}
|
}
|
||||||
Query::AUTH_DELETE_SESSIONS_BY_USER => r#"DELETE FROM user_sessions WHERE user_id = ?"#,
|
Query::AUTH_DELETE_SESSIONS_BY_USER => r#"DELETE FROM user_sessions WHERE user_id = ?"#,
|
||||||
Query::AUTH_USER_BY_SESSION => {
|
Query::AUTH_USER_BY_SESSION => {
|
||||||
r#"SELECT u.id, u.nickname, u.email, u.password_hash, u.confirmed_at, u.is_active FROM user_sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ? AND s.expires_at > ? AND u.is_active = 1"#
|
r#"SELECT u.id, u.nickname, u.email, u.password_hash, u.confirmed_at, u.is_active, u.theme FROM user_sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ? AND s.expires_at > ? AND u.is_active = 1"#
|
||||||
}
|
}
|
||||||
Query::AUTH_INSERT_SESSION => {
|
Query::AUTH_INSERT_SESSION => {
|
||||||
r#"INSERT INTO user_sessions (token, user_id, expires_at) VALUES (?, ?, ?)"#
|
r#"INSERT INTO user_sessions (token, user_id, expires_at) VALUES (?, ?, ?)"#
|
||||||
}
|
}
|
||||||
Query::AUTH_USER_BY_NICKNAME => {
|
Query::AUTH_USER_BY_NICKNAME => {
|
||||||
r#"SELECT id, nickname, email, password_hash, confirmed_at, is_active FROM users WHERE nickname_key = ?"#
|
r#"SELECT id, nickname, email, password_hash, confirmed_at, is_active, theme FROM users WHERE nickname_key = ?"#
|
||||||
}
|
}
|
||||||
Query::AUTH_USER_BY_EMAIL => {
|
Query::AUTH_USER_BY_EMAIL => {
|
||||||
r#"SELECT id, nickname, email, password_hash, confirmed_at, is_active FROM users WHERE email_key = ?"#
|
r#"SELECT id, nickname, email, password_hash, confirmed_at, is_active, theme FROM users WHERE email_key = ?"#
|
||||||
}
|
}
|
||||||
Query::AUTH_USER_BY_SHARE_IDENTIFIER => {
|
Query::AUTH_USER_BY_SHARE_IDENTIFIER => {
|
||||||
r#"SELECT id, nickname, email, password_hash, confirmed_at, CASE WHEN is_active THEN 1 ELSE 0 END AS is_active FROM users WHERE is_active = 1 AND (email_key = ?1 OR LOWER(directory_username) = ?1 OR LOWER(external_id) = ?1) LIMIT 1"#
|
r#"SELECT id, nickname, email, password_hash, confirmed_at, CASE WHEN is_active THEN 1 ELSE 0 END AS is_active, theme FROM users WHERE is_active = 1 AND (email_key = ?1 OR LOWER(directory_username) = ?1 OR LOWER(external_id) = ?1) LIMIT 1"#
|
||||||
}
|
}
|
||||||
Query::USER_ATTACH_WORKSPACE => {
|
Query::USER_ATTACH_WORKSPACE => {
|
||||||
r#"INSERT INTO user_workspaces (user_id, workspace_id) SELECT ?, id FROM workspaces WHERE slug = ?"#
|
r#"INSERT INTO user_workspaces (user_id, workspace_id) SELECT ?, id FROM workspaces WHERE slug = ?"#
|
||||||
@@ -199,6 +200,9 @@ pub fn get(query: Query) -> &'static str {
|
|||||||
Query::RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE => {
|
Query::RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE => {
|
||||||
r#"DELETE FROM resource_access_tokens WHERE resource_kind = ? AND resource_slug = ?"#
|
r#"DELETE FROM resource_access_tokens WHERE resource_kind = ? AND resource_slug = ?"#
|
||||||
}
|
}
|
||||||
|
Query::RESOURCE_ACCESS_TOKENS_DELETE_BY_TOKEN_HASH => {
|
||||||
|
r#"DELETE FROM resource_access_tokens WHERE token_hash = ?"#
|
||||||
|
}
|
||||||
Query::RESOURCE_ACCESS_TOKENS_INSERT => {
|
Query::RESOURCE_ACCESS_TOKENS_INSERT => {
|
||||||
r#"INSERT INTO resource_access_tokens (token_hash, resource_kind, resource_slug, expires_at) VALUES (?, ?, ?, ?)"#
|
r#"INSERT INTO resource_access_tokens (token_hash, resource_kind, resource_slug, expires_at) VALUES (?, ?, ?, ?)"#
|
||||||
}
|
}
|
||||||
@@ -230,16 +234,16 @@ pub fn get(query: Query) -> &'static str {
|
|||||||
r#"SELECT u.email, u.nickname, rp.permission FROM resource_permissions rp JOIN users u ON u.id = rp.user_id WHERE rp.resource_kind = ? AND rp.resource_slug = ? ORDER BY u.email"#
|
r#"SELECT u.email, u.nickname, rp.permission FROM resource_permissions rp JOIN users u ON u.id = rp.user_id WHERE rp.resource_kind = ? AND rp.resource_slug = ? ORDER BY u.email"#
|
||||||
}
|
}
|
||||||
Query::RESOURCE_SHARING_LINKS => {
|
Query::RESOURCE_SHARING_LINKS => {
|
||||||
r#"SELECT token_hash, token, permission, expires_at, created_at FROM resource_share_links WHERE resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL ORDER BY created_at DESC"#
|
r#"SELECT token_hash, label, permission, expires_at, created_at FROM resource_share_links WHERE resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL ORDER BY created_at DESC"#
|
||||||
}
|
}
|
||||||
Query::RESOURCE_SHARING_PENDING => {
|
Query::RESOURCE_SHARING_PENDING => {
|
||||||
r#"SELECT u.email, u.nickname, i.permission, i.expires_at FROM resource_share_invitations i JOIN users u ON u.id = i.user_id WHERE i.resource_kind = ? AND i.resource_slug = ? AND i.accepted_at IS NULL ORDER BY u.email"#
|
r#"SELECT u.email, u.nickname, i.permission, i.expires_at FROM resource_share_invitations i JOIN users u ON u.id = i.user_id WHERE i.resource_kind = ? AND i.resource_slug = ? AND i.accepted_at IS NULL ORDER BY u.email"#
|
||||||
}
|
}
|
||||||
Query::SHARE_LINK_INSERT => {
|
Query::SHARE_LINK_INSERT => {
|
||||||
r#"INSERT INTO resource_share_links (token_hash, token, resource_kind, resource_slug, permission, expires_at, created_by) VALUES (?, ?, ?, ?, ?, ?, ?)"#
|
r#"INSERT INTO resource_share_links (token_hash, label, resource_kind, resource_slug, permission, expires_at, created_by) VALUES (?, ?, ?, ?, ?, ?, ?)"#
|
||||||
}
|
}
|
||||||
Query::SHARE_LINK_UPDATE => {
|
Query::SHARE_LINK_UPDATE => {
|
||||||
r#"UPDATE resource_share_links SET permission = ?, expires_at = ? WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL"#
|
r#"UPDATE resource_share_links SET label = ?, permission = ?, expires_at = ? WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL"#
|
||||||
}
|
}
|
||||||
Query::SHARE_LINK_REVOKE => {
|
Query::SHARE_LINK_REVOKE => {
|
||||||
r#"UPDATE resource_share_links SET revoked_at = ? WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ?"#
|
r#"UPDATE resource_share_links SET revoked_at = ? WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ?"#
|
||||||
@@ -248,20 +252,42 @@ pub fn get(query: Query) -> &'static str {
|
|||||||
r#"SELECT permission FROM resource_permissions WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?"#
|
r#"SELECT permission FROM resource_permissions WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?"#
|
||||||
}
|
}
|
||||||
Query::SHARE_LINK_PERMISSION => {
|
Query::SHARE_LINK_PERMISSION => {
|
||||||
r#"SELECT permission FROM resource_share_links WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)"#
|
r#"SELECT permission, expires_at FROM resource_share_links WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL"#
|
||||||
|
}
|
||||||
|
Query::SHARE_LINK_SESSION_SOURCE => {
|
||||||
|
r#"SELECT token_hash, permission, expires_at FROM resource_share_links WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL"#
|
||||||
|
}
|
||||||
|
Query::SHARE_SESSION_INSERT => {
|
||||||
|
r#"INSERT INTO resource_share_sessions (session_token_hash, share_token_hash, resource_kind, resource_slug, expires_at) VALUES (?, ?, ?, ?, ?)"#
|
||||||
|
}
|
||||||
|
Query::SHARE_SESSION_PERMISSION => {
|
||||||
|
r#"SELECT l.permission, s.expires_at, l.expires_at FROM resource_share_sessions s JOIN resource_share_links l ON l.token_hash = s.share_token_hash AND l.resource_kind = s.resource_kind AND l.resource_slug = s.resource_slug WHERE s.session_token_hash = ? AND s.resource_kind = ? AND s.resource_slug = ? AND l.revoked_at IS NULL"#
|
||||||
|
}
|
||||||
|
Query::SHARE_SESSIONS_DELETE_BY_LINK => {
|
||||||
|
r#"DELETE FROM resource_share_sessions WHERE share_token_hash = ? AND resource_kind = ? AND resource_slug = ?"#
|
||||||
|
}
|
||||||
|
Query::SHARE_SESSIONS_DELETE_EXPIRED => {
|
||||||
|
r#"DELETE FROM resource_share_sessions WHERE expires_at <= ?"#
|
||||||
|
}
|
||||||
|
|
||||||
|
Query::PAD_PUBLIC_PAGE_DISABLED => {
|
||||||
|
r#"SELECT CASE WHEN public_page_disabled THEN 1 ELSE 0 END FROM pads WHERE id = ?"#
|
||||||
|
}
|
||||||
|
Query::NOTE_PUBLIC_PAGE_DISABLED => {
|
||||||
|
r#"SELECT CASE WHEN public_page_disabled THEN 1 ELSE 0 END FROM notes WHERE id = ?"#
|
||||||
}
|
}
|
||||||
Query::Q001 => {
|
Query::Q001 => {
|
||||||
r#"SELECT id, slug, title, password_hash, created_at, updated_at, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS INTEGER) AS is_private FROM workspaces WHERE slug = ?"#
|
r#"SELECT id, slug, title, password_hash, created_at, updated_at, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS INTEGER) AS is_private, created_by_guest_id FROM workspaces WHERE slug = ?"#
|
||||||
}
|
}
|
||||||
Query::Q002 => r#"INSERT INTO workspaces (slug, title, password_hash) VALUES (?, ?, ?)"#,
|
Query::Q002 => r#"INSERT INTO workspaces (slug, title, password_hash, created_by_guest_id) VALUES (?, ?, ?, ?)"#,
|
||||||
Query::Q003 => {
|
Query::Q003 => {
|
||||||
r#"SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map, protected, created_by FROM notes WHERE workspace_id = ? ORDER BY updated_at DESC, id DESC"#
|
r#"SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map, protected, created_by, created_by_guest_id FROM notes WHERE workspace_id = ? ORDER BY updated_at DESC, id DESC"#
|
||||||
}
|
}
|
||||||
Query::Q004 => {
|
Query::Q004 => {
|
||||||
r#"SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map, protected, created_by FROM notes WHERE workspace_id = ? AND slug = ?"#
|
r#"SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map, protected, created_by, created_by_guest_id FROM notes WHERE workspace_id = ? AND slug = ?"#
|
||||||
}
|
}
|
||||||
Query::Q005 => {
|
Query::Q005 => {
|
||||||
r#"INSERT INTO notes (workspace_id, slug, title, protected, created_by) VALUES (?, ?, ?, ?, ?)"#
|
r#"INSERT INTO notes (workspace_id, slug, title, protected, created_by, created_by_guest_id) VALUES (?, ?, ?, ?, ?, ?)"#
|
||||||
}
|
}
|
||||||
Query::Q006 => {
|
Query::Q006 => {
|
||||||
r#"UPDATE notes SET content = ?, owner_map = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"#
|
r#"UPDATE notes SET content = ?, owner_map = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"#
|
||||||
@@ -275,9 +301,11 @@ pub fn get(query: Query) -> &'static str {
|
|||||||
r#"SELECT id, content, created_at, author, owner_map FROM note_revisions WHERE note_id = ? ORDER BY id DESC LIMIT 100"#
|
r#"SELECT id, content, created_at, author, owner_map FROM note_revisions WHERE note_id = ? ORDER BY id DESC LIMIT 100"#
|
||||||
}
|
}
|
||||||
Query::Q011 => {
|
Query::Q011 => {
|
||||||
r#"SELECT id, slug, title, content, password_hash, created_at, updated_at, owner_map, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS INTEGER) AS is_private FROM pads WHERE slug = ?"#
|
r#"SELECT id, slug, title, content, password_hash, created_at, updated_at, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS INTEGER) AS is_private, created_by_guest_id FROM pads WHERE slug = ?"#
|
||||||
|
}
|
||||||
|
Query::Q012 => {
|
||||||
|
r#"INSERT INTO pads (slug, title, password_hash, created_by_guest_id) VALUES (?, ?, ?, ?)"#
|
||||||
}
|
}
|
||||||
Query::Q012 => r#"INSERT INTO pads (slug, title, password_hash) VALUES (?, ?, ?)"#,
|
|
||||||
Query::Q013 => {
|
Query::Q013 => {
|
||||||
r#"UPDATE pads SET content = ?, owner_map = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"#
|
r#"UPDATE pads SET content = ?, owner_map = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"#
|
||||||
}
|
}
|
||||||
@@ -293,7 +321,7 @@ pub fn get(query: Query) -> &'static str {
|
|||||||
Query::Q019 => r#"SELECT token FROM published_pages WHERE note_id = ?"#,
|
Query::Q019 => r#"SELECT token FROM published_pages WHERE note_id = ?"#,
|
||||||
Query::Q020 => r#"INSERT INTO published_pages (token, note_id) VALUES (?, ?)"#,
|
Query::Q020 => r#"INSERT INTO published_pages (token, note_id) VALUES (?, ?)"#,
|
||||||
Query::Q021 => {
|
Query::Q021 => {
|
||||||
r#"SELECT pp.token, pp.pad_id, pp.note_id, CASE WHEN pp.allow_task_updates THEN 1 ELSE 0 END AS allow_task_updates, COALESCE(p.title, n.title) AS title, COALESCE(p.content, n.content) AS content, COALESCE(p.updated_at, n.updated_at) AS updated_at FROM published_pages pp LEFT JOIN pads p ON p.id = pp.pad_id LEFT JOIN notes n ON n.id = pp.note_id WHERE pp.token = ?"#
|
r#"SELECT pp.token, pp.pad_id, pp.note_id, CASE WHEN pp.allow_task_updates THEN 1 ELSE 0 END AS allow_task_updates, COALESCE(p.slug, n.slug) AS resource_slug, n.workspace_id AS workspace_id, w.slug AS workspace_slug, COALESCE(p.owner_map, n.owner_map, '[]') AS owner_map, COALESCE(p.title, n.title) AS title, COALESCE(p.content, n.content) AS content, COALESCE(p.updated_at, n.updated_at) AS updated_at FROM published_pages pp LEFT JOIN pads p ON p.id = pp.pad_id LEFT JOIN notes n ON n.id = pp.note_id LEFT JOIN workspaces w ON w.id = n.workspace_id WHERE pp.token = ?"#
|
||||||
}
|
}
|
||||||
Query::Q022 => r#"SELECT file_token FROM pads WHERE id = ?"#,
|
Query::Q022 => r#"SELECT file_token FROM pads WHERE id = ?"#,
|
||||||
Query::Q023 => r#"UPDATE pads SET file_token = ? WHERE id = ? AND file_token IS NULL"#,
|
Query::Q023 => r#"UPDATE pads SET file_token = ? WHERE id = ? AND file_token IS NULL"#,
|
||||||
@@ -329,12 +357,6 @@ pub fn get(query: Query) -> &'static str {
|
|||||||
Query::Q047 => r#"DELETE FROM pad_files WHERE id = ? AND pad_id = ?"#,
|
Query::Q047 => r#"DELETE FROM pad_files WHERE id = ? AND pad_id = ?"#,
|
||||||
Query::Q040 => r#"UPDATE published_pages SET allow_task_updates = ? WHERE pad_id = ?"#,
|
Query::Q040 => r#"UPDATE published_pages SET allow_task_updates = ? WHERE pad_id = ?"#,
|
||||||
Query::Q041 => r#"UPDATE published_pages SET allow_task_updates = ? WHERE note_id = ?"#,
|
Query::Q041 => r#"UPDATE published_pages SET allow_task_updates = ? WHERE note_id = ?"#,
|
||||||
Query::Q042 => {
|
|
||||||
r#"UPDATE pads SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"#
|
|
||||||
}
|
|
||||||
Query::Q043 => {
|
|
||||||
r#"UPDATE notes SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"#
|
|
||||||
}
|
|
||||||
Query::Q044 => {
|
Query::Q044 => {
|
||||||
r#"SELECT CASE WHEN allow_task_updates THEN 1 ELSE 0 END FROM published_pages WHERE pad_id = ?"#
|
r#"SELECT CASE WHEN allow_task_updates THEN 1 ELSE 0 END FROM published_pages WHERE pad_id = ?"#
|
||||||
}
|
}
|
||||||
@@ -349,6 +371,24 @@ pub fn get(query: Query) -> &'static str {
|
|||||||
r#"SELECT CASE WHEN unprotected THEN 1 ELSE 0 END FROM published_pages WHERE note_id = ?"#
|
r#"SELECT CASE WHEN unprotected THEN 1 ELSE 0 END FROM published_pages WHERE note_id = ?"#
|
||||||
}
|
}
|
||||||
Query::Q050 => r#"UPDATE published_pages SET unprotected = ? WHERE pad_id = ?"#,
|
Query::Q050 => r#"UPDATE published_pages SET unprotected = ? WHERE pad_id = ?"#,
|
||||||
|
Query::Q054 => {
|
||||||
|
r#"INSERT INTO note_revisions (note_id, content, author, owner_map, collaboration_client_id, collaboration_update_id) VALUES (?, ?, ?, ?, ?, ?)"#
|
||||||
|
}
|
||||||
|
Query::Q055 => {
|
||||||
|
r#"INSERT INTO revisions (pad_id, content, author, owner_map, collaboration_client_id, collaboration_update_id) VALUES (?, ?, ?, ?, ?, ?)"#
|
||||||
|
}
|
||||||
|
Query::Q056 => {
|
||||||
|
r#"SELECT MAX(collaboration_update_id) FROM note_revisions WHERE note_id = ? AND collaboration_client_id = ?"#
|
||||||
|
}
|
||||||
|
Query::Q057 => {
|
||||||
|
r#"SELECT MAX(collaboration_update_id) FROM revisions WHERE pad_id = ? AND collaboration_client_id = ?"#
|
||||||
|
}
|
||||||
|
Query::Q058 => {
|
||||||
|
r#"SELECT n.content, n.owner_map, COALESCE((SELECT MAX(r.id) FROM note_revisions r WHERE r.note_id = n.id), 0) AS revision_id FROM notes n WHERE n.id = ?"#
|
||||||
|
}
|
||||||
|
Query::Q059 => {
|
||||||
|
r#"SELECT p.content, p.owner_map, COALESCE((SELECT MAX(r.id) FROM revisions r WHERE r.pad_id = p.id), 0) AS revision_id FROM pads p WHERE p.id = ?"#
|
||||||
|
}
|
||||||
Query::Q051 => r#"UPDATE published_pages SET unprotected = ? WHERE note_id = ?"#,
|
Query::Q051 => r#"UPDATE published_pages SET unprotected = ? WHERE note_id = ?"#,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+295
@@ -0,0 +1,295 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
||||||
|
* Source-Available Code / Dual-Licensed.
|
||||||
|
*
|
||||||
|
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||||
|
* Commercial or production use requires a valid paid license.
|
||||||
|
* See LICENSE file in repository root for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
http::{HeaderMap, HeaderValue, Uri, header},
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
};
|
||||||
|
use rand_core::{OsRng, RngCore};
|
||||||
|
use serde::Serialize;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
pub const SESSION_COOKIE: &str = "__Host-rustpad_session";
|
||||||
|
pub const CSRF_COOKIE: &str = "__Host-rustpad_csrf";
|
||||||
|
pub const CSRF_HEADER: &str = "x-rustpad-csrf";
|
||||||
|
|
||||||
|
const CSRF_TOKEN_BYTES: usize = 32;
|
||||||
|
const CSRF_TTL_SECONDS: i64 = 24 * 60 * 60;
|
||||||
|
const RESOURCE_ACCESS_COOKIE_PREFIX: &str = "__Host-rustpad_access_";
|
||||||
|
const RESOURCE_ACCESS_COOKIE_SUFFIX_LENGTH: usize = 24;
|
||||||
|
const RESOURCE_ACCESS_TOKEN_LENGTH: usize = 64;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct CsrfResponse {
|
||||||
|
token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn session_token(headers: &HeaderMap) -> Option<&str> {
|
||||||
|
session_cookie_token(headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn session_cookie_token(headers: &HeaderMap) -> Option<&str> {
|
||||||
|
cookie_value(headers, SESSION_COOKIE)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bearer_token(headers: &HeaderMap) -> Option<&str> {
|
||||||
|
headers
|
||||||
|
.get(header::AUTHORIZATION)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.and_then(|value| value.strip_prefix("Bearer "))
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resource_token<'a>(headers: &'a HeaderMap, kind: &str, slug: &str) -> Option<&'a str> {
|
||||||
|
let name = resource_cookie_name(kind, slug);
|
||||||
|
cookie_value(headers, &name)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn resource_access_cookies(headers: &HeaderMap) -> Vec<(String, Option<String>)> {
|
||||||
|
headers
|
||||||
|
.get_all(header::COOKIE)
|
||||||
|
.iter()
|
||||||
|
.filter_map(|value| value.to_str().ok())
|
||||||
|
.flat_map(|cookies| cookies.split(';'))
|
||||||
|
.filter_map(|part| {
|
||||||
|
let (name, value) = part.trim().split_once('=')?;
|
||||||
|
if !valid_resource_access_cookie_name(name) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let value = value.trim();
|
||||||
|
let token = (value.len() == RESOURCE_ACCESS_TOKEN_LENGTH
|
||||||
|
&& value.bytes().all(|byte| byte.is_ascii_hexdigit()))
|
||||||
|
.then(|| value.to_owned());
|
||||||
|
Some((name.to_owned(), token))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn share_session_token<'a>(headers: &'a HeaderMap, kind: &str, slug: &str) -> Option<&'a str> {
|
||||||
|
let name = share_session_cookie_name(kind, slug);
|
||||||
|
cookie_value(headers, &name)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn session_cookie(token: &str, ttl_days: i64) -> HeaderValue {
|
||||||
|
secure_cookie(SESSION_COOKIE, token, ttl_days.saturating_mul(86_400))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_session_cookie() -> HeaderValue {
|
||||||
|
clear_cookie(SESSION_COOKIE)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn clear_resource_access_cookie(name: &str) -> Option<HeaderValue> {
|
||||||
|
valid_resource_access_cookie_name(name).then(|| clear_cookie(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn csrf_token_endpoint(headers: HeaderMap) -> Response {
|
||||||
|
let token = csrf_cookie_token(&headers)
|
||||||
|
.filter(|value| valid_csrf_token(value))
|
||||||
|
.map(str::to_owned)
|
||||||
|
.unwrap_or_else(random_csrf_token);
|
||||||
|
let mut response = Json(CsrfResponse {
|
||||||
|
token: token.clone(),
|
||||||
|
})
|
||||||
|
.into_response();
|
||||||
|
response
|
||||||
|
.headers_mut()
|
||||||
|
.insert(header::SET_COOKIE, csrf_cookie(&token));
|
||||||
|
response.headers_mut().insert(
|
||||||
|
header::CACHE_CONTROL,
|
||||||
|
HeaderValue::from_static("no-cache, no-store, max-age=0"),
|
||||||
|
);
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn csrf_request_is_valid(headers: &HeaderMap) -> bool {
|
||||||
|
let Some(cookie) = csrf_cookie_token(headers).filter(|value| valid_csrf_token(value)) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Some(provided) = headers
|
||||||
|
.get(CSRF_HEADER)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| valid_csrf_token(value))
|
||||||
|
else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
constant_time_eq(cookie.as_bytes(), provided.as_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn csrf_cookie_token(headers: &HeaderMap) -> Option<&str> {
|
||||||
|
cookie_value(headers, CSRF_COOKIE)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resource_cookie(kind: &str, slug: &str, token: &str, ttl_days: i64) -> HeaderValue {
|
||||||
|
secure_cookie(
|
||||||
|
&resource_cookie_name(kind, slug),
|
||||||
|
token,
|
||||||
|
ttl_days.saturating_mul(86_400),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn share_session_cookie(
|
||||||
|
kind: &str,
|
||||||
|
slug: &str,
|
||||||
|
token: &str,
|
||||||
|
max_age_seconds: i64,
|
||||||
|
) -> HeaderValue {
|
||||||
|
secure_cookie(
|
||||||
|
&share_session_cookie_name(kind, slug),
|
||||||
|
token,
|
||||||
|
max_age_seconds,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn client_key(headers: &HeaderMap) -> String {
|
||||||
|
let forwarded_ip = header_ip(headers, "cf-connecting-ip")
|
||||||
|
.or_else(|| header_ip(headers, "x-real-ip"))
|
||||||
|
.or_else(|| header_ip(headers, "x-forwarded-for"));
|
||||||
|
if let Some(value) = forwarded_ip {
|
||||||
|
return format!("ip:{value}");
|
||||||
|
}
|
||||||
|
|
||||||
|
let user_agent = first_header_value(headers, header::USER_AGENT.as_str()).unwrap_or("");
|
||||||
|
let language = first_header_value(headers, header::ACCEPT_LANGUAGE.as_str()).unwrap_or("");
|
||||||
|
if user_agent.is_empty() && language.is_empty() {
|
||||||
|
return "unknown".into();
|
||||||
|
}
|
||||||
|
let digest = Sha256::digest(format!("{user_agent}|{language}").as_bytes());
|
||||||
|
format!("browser:{}", hex::encode(&digest[..12]))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn websocket_origin_allowed(headers: &HeaderMap) -> bool {
|
||||||
|
let Some(origin) = headers
|
||||||
|
.get(header::ORIGIN)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty() && *value != "null")
|
||||||
|
else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Ok(uri) = origin.parse::<Uri>() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Some(origin_authority) = uri.authority().map(|value| value.as_str()) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if uri.path() != "/" || uri.query().is_some() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let Some(expected_authority) = first_header_value(headers, header::HOST.as_str()) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if !origin_authority.eq_ignore_ascii_case(expected_authority) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if let Some(expected_scheme) = first_header_value(headers, "x-forwarded-proto") {
|
||||||
|
let Some(origin_scheme) = uri.scheme_str() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if !origin_scheme.eq_ignore_ascii_case(expected_scheme) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
matches!(uri.scheme_str(), Some("http" | "https"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resource_cookie_name(kind: &str, slug: &str) -> String {
|
||||||
|
let digest = Sha256::digest(format!("{kind}:{slug}").as_bytes());
|
||||||
|
format!("{RESOURCE_ACCESS_COOKIE_PREFIX}{}", hex::encode(&digest[..12]))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_resource_access_cookie_name(name: &str) -> bool {
|
||||||
|
name.strip_prefix(RESOURCE_ACCESS_COOKIE_PREFIX)
|
||||||
|
.is_some_and(|suffix| {
|
||||||
|
suffix.len() == RESOURCE_ACCESS_COOKIE_SUFFIX_LENGTH
|
||||||
|
&& suffix.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn share_session_cookie_name(kind: &str, slug: &str) -> String {
|
||||||
|
let digest = Sha256::digest(format!("{kind}:{slug}").as_bytes());
|
||||||
|
format!("__Host-rustpad_share_{}", hex::encode(&digest[..12]))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cookie_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
|
||||||
|
headers
|
||||||
|
.get(header::COOKIE)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.and_then(|cookies| {
|
||||||
|
cookies.split(';').find_map(|part| {
|
||||||
|
let (cookie_name, cookie_value) = part.trim().split_once('=')?;
|
||||||
|
(cookie_name == name && !cookie_value.is_empty()).then_some(cookie_value)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn secure_cookie(name: &str, value: &str, max_age: i64) -> HeaderValue {
|
||||||
|
HeaderValue::from_str(&format!(
|
||||||
|
"{name}={value}; Path=/; Max-Age={}; HttpOnly; Secure; SameSite=Lax",
|
||||||
|
max_age.max(1)
|
||||||
|
))
|
||||||
|
.expect("valid secure cookie")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn csrf_cookie(token: &str) -> HeaderValue {
|
||||||
|
HeaderValue::from_str(&format!(
|
||||||
|
"{CSRF_COOKIE}={token}; Path=/; Max-Age={}; Secure; SameSite=Strict",
|
||||||
|
CSRF_TTL_SECONDS
|
||||||
|
))
|
||||||
|
.expect("valid csrf cookie")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn random_csrf_token() -> String {
|
||||||
|
let mut bytes = [0_u8; CSRF_TOKEN_BYTES];
|
||||||
|
let mut rng = OsRng;
|
||||||
|
rng.fill_bytes(&mut bytes);
|
||||||
|
hex::encode(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_csrf_token(value: &str) -> bool {
|
||||||
|
value.len() == CSRF_TOKEN_BYTES * 2 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
|
||||||
|
if left.len() != right.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
left.iter()
|
||||||
|
.zip(right)
|
||||||
|
.fold(0_u8, |difference, (left, right)| {
|
||||||
|
difference | (*left ^ *right)
|
||||||
|
})
|
||||||
|
== 0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear_cookie(name: &str) -> HeaderValue {
|
||||||
|
HeaderValue::from_str(&format!(
|
||||||
|
"{name}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax"
|
||||||
|
))
|
||||||
|
.expect("valid clear cookie")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn header_ip(headers: &HeaderMap, name: &str) -> Option<std::net::IpAddr> {
|
||||||
|
first_header_value(headers, name)?.parse().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn first_header_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
|
||||||
|
headers
|
||||||
|
.get(name)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.and_then(|value| value.split(',').next())
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "tests/security.rs"]
|
||||||
|
mod tests;
|
||||||
+160
-5
@@ -7,18 +7,27 @@
|
|||||||
* See LICENSE file in repository root for details.
|
* See LICENSE file in repository root for details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use crate::database::Database;
|
use crate::{collab::CollaborativeDocument, database::Database};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::{
|
use std::{
|
||||||
collections::HashMap,
|
collections::HashMap,
|
||||||
sync::{
|
sync::{
|
||||||
Arc,
|
Arc, Weak,
|
||||||
atomic::{AtomicU64, Ordering},
|
atomic::{AtomicU64, Ordering},
|
||||||
},
|
},
|
||||||
|
time::{Duration, Instant},
|
||||||
};
|
};
|
||||||
use tokio::sync::{RwLock, broadcast};
|
use tokio::sync::{Mutex, RwLock, broadcast};
|
||||||
|
|
||||||
const CHANNEL_CAPACITY: usize = 256;
|
const CHANNEL_CAPACITY: usize = 256;
|
||||||
|
const MAX_RATE_LIMIT_ENTRIES: usize = 50_000;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct RateLimitEntry {
|
||||||
|
started_at: Instant,
|
||||||
|
attempts: u32,
|
||||||
|
window: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum SmtpSecurity {
|
pub enum SmtpSecurity {
|
||||||
@@ -40,11 +49,14 @@ pub struct SmtpConfig {
|
|||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct NoteUpdate {
|
pub struct NoteUpdate {
|
||||||
pub content: String,
|
pub base_revision_id: i64,
|
||||||
pub revision_id: i64,
|
pub revision_id: i64,
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
pub author: Option<String>,
|
pub author: Option<String>,
|
||||||
pub owner_map: String,
|
pub client_id: String,
|
||||||
|
pub update_id: u64,
|
||||||
|
pub operation: crate::collab::TextOperation,
|
||||||
|
pub owner_replacements: Vec<crate::collab::OwnerReplacement>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
@@ -68,6 +80,14 @@ fn compact_presence_name(name: &str) -> String {
|
|||||||
format!("{initial}.{rest}")
|
format!("{initial}.{rest}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn workspace_password_event_channel(
|
||||||
|
channel_key: &str,
|
||||||
|
workspace_key: &str,
|
||||||
|
note_prefix: &str,
|
||||||
|
) -> bool {
|
||||||
|
channel_key == workspace_key || channel_key.starts_with(note_prefix)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
struct PresenceConnection {
|
struct PresenceConnection {
|
||||||
identity: String,
|
identity: String,
|
||||||
@@ -79,6 +99,7 @@ pub enum RoomEvent {
|
|||||||
Document(NoteUpdate),
|
Document(NoteUpdate),
|
||||||
Presence(Vec<PresenceUser>),
|
Presence(Vec<PresenceUser>),
|
||||||
Chat { sender: String, text: String },
|
Chat { sender: String, text: String },
|
||||||
|
PasswordRequired { except_client_id: Option<String> },
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -87,7 +108,10 @@ pub struct AppState {
|
|||||||
pub asset_version: String,
|
pub asset_version: String,
|
||||||
pub storage: crate::storage::Storage,
|
pub storage: crate::storage::Storage,
|
||||||
pub upload_max_size_bytes: usize,
|
pub upload_max_size_bytes: usize,
|
||||||
|
pub guest_upload_enabled: bool,
|
||||||
|
pub guest_upload_max_size_bytes: usize,
|
||||||
pub file_cache_max_age_seconds: u64,
|
pub file_cache_max_age_seconds: u64,
|
||||||
|
pub files_public_url: Option<String>,
|
||||||
pub smtp: Option<SmtpConfig>,
|
pub smtp: Option<SmtpConfig>,
|
||||||
pub registration_enabled: bool,
|
pub registration_enabled: bool,
|
||||||
pub account_confirmation_required: bool,
|
pub account_confirmation_required: bool,
|
||||||
@@ -98,8 +122,10 @@ pub struct AppState {
|
|||||||
pub unconfirmed_account_ttl_days: i64,
|
pub unconfirmed_account_ttl_days: i64,
|
||||||
pub ldap: Option<crate::auth::ldap::LdapConfig>,
|
pub ldap: Option<crate::auth::ldap::LdapConfig>,
|
||||||
channels: RwLock<HashMap<String, broadcast::Sender<RoomEvent>>>,
|
channels: RwLock<HashMap<String, broadcast::Sender<RoomEvent>>>,
|
||||||
|
collaborative_documents: RwLock<HashMap<String, Weak<Mutex<CollaborativeDocument>>>>,
|
||||||
presence: RwLock<HashMap<String, HashMap<u64, PresenceConnection>>>,
|
presence: RwLock<HashMap<String, HashMap<u64, PresenceConnection>>>,
|
||||||
next_connection_id: AtomicU64,
|
next_connection_id: AtomicU64,
|
||||||
|
rate_limits: Mutex<HashMap<String, RateLimitEntry>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
@@ -108,7 +134,10 @@ impl AppState {
|
|||||||
asset_version: String,
|
asset_version: String,
|
||||||
storage: crate::storage::Storage,
|
storage: crate::storage::Storage,
|
||||||
upload_max_size_bytes: usize,
|
upload_max_size_bytes: usize,
|
||||||
|
guest_upload_enabled: bool,
|
||||||
|
guest_upload_max_size_bytes: usize,
|
||||||
file_cache_max_age_seconds: u64,
|
file_cache_max_age_seconds: u64,
|
||||||
|
files_public_url: Option<String>,
|
||||||
smtp: Option<SmtpConfig>,
|
smtp: Option<SmtpConfig>,
|
||||||
registration_enabled: bool,
|
registration_enabled: bool,
|
||||||
account_confirmation_required: bool,
|
account_confirmation_required: bool,
|
||||||
@@ -124,7 +153,10 @@ impl AppState {
|
|||||||
asset_version,
|
asset_version,
|
||||||
storage,
|
storage,
|
||||||
upload_max_size_bytes,
|
upload_max_size_bytes,
|
||||||
|
guest_upload_enabled,
|
||||||
|
guest_upload_max_size_bytes,
|
||||||
file_cache_max_age_seconds,
|
file_cache_max_age_seconds,
|
||||||
|
files_public_url,
|
||||||
smtp,
|
smtp,
|
||||||
registration_enabled,
|
registration_enabled,
|
||||||
account_confirmation_required,
|
account_confirmation_required,
|
||||||
@@ -135,10 +167,88 @@ impl AppState {
|
|||||||
unconfirmed_account_ttl_days,
|
unconfirmed_account_ttl_days,
|
||||||
ldap,
|
ldap,
|
||||||
channels: RwLock::new(HashMap::new()),
|
channels: RwLock::new(HashMap::new()),
|
||||||
|
collaborative_documents: RwLock::new(HashMap::new()),
|
||||||
presence: RwLock::new(HashMap::new()),
|
presence: RwLock::new(HashMap::new()),
|
||||||
next_connection_id: AtomicU64::new(1),
|
next_connection_id: AtomicU64::new(1),
|
||||||
|
rate_limits: Mutex::new(HashMap::new()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
pub async fn check_rate_limit(
|
||||||
|
&self,
|
||||||
|
key: String,
|
||||||
|
max_attempts: u32,
|
||||||
|
window: Duration,
|
||||||
|
) -> Result<(), u64> {
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut limits = self.rate_limits.lock().await;
|
||||||
|
if !limits.contains_key(&key) && limits.len() >= MAX_RATE_LIMIT_ENTRIES {
|
||||||
|
limits.retain(|_, entry| now.duration_since(entry.started_at) < entry.window);
|
||||||
|
if limits.len() >= MAX_RATE_LIMIT_ENTRIES {
|
||||||
|
if let Some(oldest_key) = limits
|
||||||
|
.iter()
|
||||||
|
.min_by_key(|(_, entry)| entry.started_at)
|
||||||
|
.map(|(key, _)| key.clone())
|
||||||
|
{
|
||||||
|
limits.remove(&oldest_key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let entry = limits.entry(key).or_insert(RateLimitEntry {
|
||||||
|
started_at: now,
|
||||||
|
attempts: 0,
|
||||||
|
window,
|
||||||
|
});
|
||||||
|
if now.duration_since(entry.started_at) >= entry.window {
|
||||||
|
entry.started_at = now;
|
||||||
|
entry.attempts = 0;
|
||||||
|
}
|
||||||
|
entry.window = window;
|
||||||
|
if entry.attempts >= max_attempts {
|
||||||
|
let remaining = entry
|
||||||
|
.window
|
||||||
|
.saturating_sub(now.duration_since(entry.started_at));
|
||||||
|
return Err(remaining.as_secs().max(1));
|
||||||
|
}
|
||||||
|
entry.attempts += 1;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn clear_rate_limit(&self, key: &str) {
|
||||||
|
self.rate_limits.lock().await.remove(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn collaborative_document(
|
||||||
|
&self,
|
||||||
|
key: &str,
|
||||||
|
content: String,
|
||||||
|
owner_map: String,
|
||||||
|
revision_id: i64,
|
||||||
|
) -> Arc<Mutex<CollaborativeDocument>> {
|
||||||
|
if let Some(document) = self
|
||||||
|
.collaborative_documents
|
||||||
|
.read()
|
||||||
|
.await
|
||||||
|
.get(key)
|
||||||
|
.and_then(|document| document.upgrade())
|
||||||
|
{
|
||||||
|
return document;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut documents = self.collaborative_documents.write().await;
|
||||||
|
if let Some(document) = documents.get(key).and_then(|document| document.upgrade()) {
|
||||||
|
return document;
|
||||||
|
}
|
||||||
|
documents.retain(|_, document| document.strong_count() > 0);
|
||||||
|
|
||||||
|
let document = Arc::new(Mutex::new(CollaborativeDocument::new(
|
||||||
|
content,
|
||||||
|
owner_map,
|
||||||
|
revision_id,
|
||||||
|
)));
|
||||||
|
documents.insert(key.to_owned(), Arc::downgrade(&document));
|
||||||
|
document
|
||||||
|
}
|
||||||
|
|
||||||
async fn channel_for_key(&self, key: String) -> broadcast::Sender<RoomEvent> {
|
async fn channel_for_key(&self, key: String) -> broadcast::Sender<RoomEvent> {
|
||||||
if let Some(sender) = self.channels.read().await.get(&key) {
|
if let Some(sender) = self.channels.read().await.get(&key) {
|
||||||
return sender.clone();
|
return sender.clone();
|
||||||
@@ -152,9 +262,16 @@ impl AppState {
|
|||||||
pub fn note_room_key(workspace_slug: &str, note_slug: &str) -> String {
|
pub fn note_room_key(workspace_slug: &str, note_slug: &str) -> String {
|
||||||
format!("workspace:{workspace_slug}/{note_slug}")
|
format!("workspace:{workspace_slug}/{note_slug}")
|
||||||
}
|
}
|
||||||
|
pub fn workspace_room_key(workspace_slug: &str) -> String {
|
||||||
|
format!("workspace:{workspace_slug}")
|
||||||
|
}
|
||||||
pub fn pad_room_key(slug: &str) -> String {
|
pub fn pad_room_key(slug: &str) -> String {
|
||||||
format!("pad:{slug}")
|
format!("pad:{slug}")
|
||||||
}
|
}
|
||||||
|
pub async fn workspace_channel(&self, workspace_slug: &str) -> broadcast::Sender<RoomEvent> {
|
||||||
|
self.channel_for_key(Self::workspace_room_key(workspace_slug))
|
||||||
|
.await
|
||||||
|
}
|
||||||
pub async fn note_channel(
|
pub async fn note_channel(
|
||||||
&self,
|
&self,
|
||||||
workspace_slug: &str,
|
workspace_slug: &str,
|
||||||
@@ -166,6 +283,40 @@ impl AppState {
|
|||||||
pub async fn pad_channel(&self, slug: &str) -> broadcast::Sender<RoomEvent> {
|
pub async fn pad_channel(&self, slug: &str) -> broadcast::Sender<RoomEvent> {
|
||||||
self.channel_for_key(Self::pad_room_key(slug)).await
|
self.channel_for_key(Self::pad_room_key(slug)).await
|
||||||
}
|
}
|
||||||
|
pub async fn notify_pad_password_required(
|
||||||
|
&self,
|
||||||
|
slug: &str,
|
||||||
|
except_client_id: Option<String>,
|
||||||
|
) {
|
||||||
|
let key = Self::pad_room_key(slug);
|
||||||
|
let sender = self.channels.read().await.get(&key).cloned();
|
||||||
|
if let Some(sender) = sender {
|
||||||
|
let _ = sender.send(RoomEvent::PasswordRequired { except_client_id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub async fn notify_workspace_password_required(
|
||||||
|
&self,
|
||||||
|
workspace_slug: &str,
|
||||||
|
except_client_id: Option<String>,
|
||||||
|
) {
|
||||||
|
let workspace_key = Self::workspace_room_key(workspace_slug);
|
||||||
|
let prefix = format!("workspace:{workspace_slug}/");
|
||||||
|
let senders = self
|
||||||
|
.channels
|
||||||
|
.read()
|
||||||
|
.await
|
||||||
|
.iter()
|
||||||
|
.filter(|(key, _)| {
|
||||||
|
workspace_password_event_channel(key.as_str(), &workspace_key, &prefix)
|
||||||
|
})
|
||||||
|
.map(|(_, sender)| sender.clone())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
for sender in senders {
|
||||||
|
let _ = sender.send(RoomEvent::PasswordRequired {
|
||||||
|
except_client_id: except_client_id.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
pub async fn join_room(
|
pub async fn join_room(
|
||||||
&self,
|
&self,
|
||||||
key: &str,
|
key: &str,
|
||||||
@@ -226,6 +377,10 @@ impl AppState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "tests/state.rs"]
|
||||||
|
mod tests;
|
||||||
|
|
||||||
fn sorted_users(room: &HashMap<u64, PresenceConnection>) -> Vec<PresenceUser> {
|
fn sorted_users(room: &HashMap<u64, PresenceConnection>) -> Vec<PresenceUser> {
|
||||||
let mut by_identity: HashMap<&str, PresenceUser> = HashMap::new();
|
let mut by_identity: HashMap<&str, PresenceUser> = HashMap::new();
|
||||||
for connection in room.values() {
|
for connection in room.values() {
|
||||||
|
|||||||
+4
-1
@@ -231,7 +231,10 @@ pub async fn delete_url_file(
|
|||||||
owner_id: i64,
|
owner_id: i64,
|
||||||
url: &str,
|
url: &str,
|
||||||
) -> Result<(), StorageError> {
|
) -> Result<(), StorageError> {
|
||||||
let parts: Vec<&str> = url.trim_start_matches('/').split('/').collect();
|
let Some(path) = crate::file_urls::canonical_file_path(url) else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let parts: Vec<&str> = path.trim_start_matches('/').split('/').collect();
|
||||||
if parts.len() != 3 || parts[0] != "f" {
|
if parts.len() != 3 || parts[0] != "f" {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
||||||
|
* Source-Available Code / Dual-Licensed.
|
||||||
|
*
|
||||||
|
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||||
|
* Commercial or production use requires a valid paid license.
|
||||||
|
* See LICENSE file in repository root for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn headers_with_guest_id(guest_id: &str) -> HeaderMap {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
header::COOKIE,
|
||||||
|
HeaderValue::from_str(&format!("rustpad_guest_id={guest_id}"))
|
||||||
|
.expect("valid cookie header"),
|
||||||
|
);
|
||||||
|
headers
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn guest_owner_requires_the_original_browser_identifier() {
|
||||||
|
let owner_id = "0123456789abcdef0123456789abcdef";
|
||||||
|
let owner_headers = headers_with_guest_id(owner_id);
|
||||||
|
let visitor_headers = headers_with_guest_id("fedcba9876543210fedcba9876543210");
|
||||||
|
|
||||||
|
assert!(guest_owner_is_requester(&owner_headers, Some(owner_id)));
|
||||||
|
assert!(!guest_owner_is_requester(&visitor_headers, Some(owner_id)));
|
||||||
|
assert!(!guest_owner_is_requester(&HeaderMap::new(), Some(owner_id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_guest_identifier_does_not_grant_ownership() {
|
||||||
|
let headers = headers_with_guest_id("too-short");
|
||||||
|
assert!(!guest_owner_is_requester(&headers, Some("too-short")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_an_owner_can_set_the_first_password() {
|
||||||
|
assert!(can_set_resource_password(false, true, false));
|
||||||
|
assert!(can_set_resource_password(false, false, true));
|
||||||
|
assert!(!can_set_resource_password(false, false, false));
|
||||||
|
assert!(!can_set_resource_password(true, true, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_allow_owner_or_verified_password_holder() {
|
||||||
|
assert!(can_manage_resource_settings(true, false, false));
|
||||||
|
assert!(can_manage_resource_settings(false, true, false));
|
||||||
|
assert!(can_manage_resource_settings(false, false, true));
|
||||||
|
assert!(!can_manage_resource_settings(false, false, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_note_deletion_follows_rw_protection_rules() {
|
||||||
|
assert!(can_delete_workspace_note(AccessLevel::Write, false, false));
|
||||||
|
assert!(can_delete_workspace_note(AccessLevel::Write, true, true));
|
||||||
|
assert!(!can_delete_workspace_note(AccessLevel::Write, true, false));
|
||||||
|
assert!(!can_delete_workspace_note(AccessLevel::Read, false, true));
|
||||||
|
assert!(!can_delete_workspace_note(AccessLevel::None, false, true));
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
||||||
|
* Source-Available Code / Dual-Licensed.
|
||||||
|
*
|
||||||
|
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||||
|
* Commercial or production use requires a valid paid license.
|
||||||
|
* See LICENSE file in repository root for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
content_references_file, content_references_stored_file, is_safe_inline_image_mime,
|
||||||
|
is_safe_inline_video_mime, parse_byte_range,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_raster_images_are_inline() {
|
||||||
|
assert!(is_safe_inline_image_mime("image/png"));
|
||||||
|
assert!(is_safe_inline_image_mime("image/jpeg"));
|
||||||
|
assert!(!is_safe_inline_image_mime("image/svg+xml"));
|
||||||
|
assert!(!is_safe_inline_image_mime("text/html"));
|
||||||
|
assert!(!is_safe_inline_image_mime("application/xml"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extended_image_alias_is_still_attached() {
|
||||||
|
assert!(content_references_file(
|
||||||
|
"[image=photo.jpg,Photo,a=left,size=640x400]",
|
||||||
|
"photo.jpg",
|
||||||
|
"/f/token/photo.jpg",
|
||||||
|
));
|
||||||
|
assert!(content_references_file(
|
||||||
|
"[file=report.pdf,Quarterly report]",
|
||||||
|
"report.pdf",
|
||||||
|
"/f/token/report.pdf",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attachment_references_survive_origin_changes() {
|
||||||
|
let stored = "/f/token/image.png";
|
||||||
|
assert!(content_references_stored_file(
|
||||||
|
"",
|
||||||
|
"image.png",
|
||||||
|
stored,
|
||||||
|
None,
|
||||||
|
));
|
||||||
|
assert!(content_references_stored_file(
|
||||||
|
"",
|
||||||
|
"image.png",
|
||||||
|
"https://old-files.example.com/f/token/image.png",
|
||||||
|
Some("https://new-files.example.com"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn common_video_formats_are_inline() {
|
||||||
|
assert!(is_safe_inline_video_mime("video/mp4"));
|
||||||
|
assert!(is_safe_inline_video_mime("video/webm"));
|
||||||
|
assert!(!is_safe_inline_video_mime("text/html"));
|
||||||
|
assert!(!is_safe_inline_video_mime("application/javascript"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn video_alias_is_still_attached() {
|
||||||
|
assert!(content_references_file(
|
||||||
|
"[video=clip.mp4,Product demo]",
|
||||||
|
"clip.mp4",
|
||||||
|
"/f/token/clip.mp4",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn byte_ranges_support_video_seeking() {
|
||||||
|
assert_eq!(parse_byte_range("bytes=0-99", 1_000), Ok(Some((0, 100))));
|
||||||
|
assert_eq!(parse_byte_range("bytes=500-", 1_000), Ok(Some((500, 1_000))));
|
||||||
|
assert_eq!(parse_byte_range("bytes=-100", 1_000), Ok(Some((900, 1_000))));
|
||||||
|
assert_eq!(parse_byte_range("bytes=900-2000", 1_000), Ok(Some((900, 1_000))));
|
||||||
|
assert_eq!(parse_byte_range("bytes=1000-", 1_000), Err(()));
|
||||||
|
assert_eq!(parse_byte_range("bytes=0-1,4-5", 1_000), Err(()));
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
||||||
|
* Source-Available Code / Dual-Licensed.
|
||||||
|
*
|
||||||
|
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||||
|
* Commercial or production use requires a valid paid license.
|
||||||
|
* See LICENSE file in repository root for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use super::{ResponseHeaderPolicy, apply_response_headers, response_header_policy};
|
||||||
|
use axum::http::{HeaderMap, HeaderValue, header};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn classifies_assets_and_icons_as_static_assets() {
|
||||||
|
for path in [
|
||||||
|
"/assets/app.js",
|
||||||
|
"/assets",
|
||||||
|
"/favicon.ico",
|
||||||
|
"/icons/favicon.svg",
|
||||||
|
"/icons/missing.svg",
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
response_header_policy(path),
|
||||||
|
ResponseHeaderPolicy::StaticAsset
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn classifies_file_routes_as_files() {
|
||||||
|
for path in ["/f", "/f/token/image.png"] {
|
||||||
|
assert_eq!(response_header_policy(path), ResponseHeaderPolicy::File);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn classifies_other_routes_as_application() {
|
||||||
|
for path in [
|
||||||
|
"/",
|
||||||
|
"/api/auth/me",
|
||||||
|
"/static/missing.css",
|
||||||
|
"/files/legacy/image.png",
|
||||||
|
"/unknown",
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
response_header_policy(path),
|
||||||
|
ResponseHeaderPolicy::Application
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn static_asset_policy_only_adds_nosniff() {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
header::CACHE_CONTROL,
|
||||||
|
HeaderValue::from_static("public, max-age=3600"),
|
||||||
|
);
|
||||||
|
|
||||||
|
apply_response_headers(ResponseHeaderPolicy::StaticAsset, &mut headers);
|
||||||
|
|
||||||
|
assert_eq!(headers.len(), 2);
|
||||||
|
assert_eq!(headers[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
|
||||||
|
assert!(!headers.contains_key("x-frame-options"));
|
||||||
|
assert!(!headers.contains_key("cross-origin-opener-policy"));
|
||||||
|
assert!(!headers.contains_key("cross-origin-resource-policy"));
|
||||||
|
assert!(!headers.contains_key("referrer-policy"));
|
||||||
|
assert!(!headers.contains_key("permissions-policy"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn file_policy_keeps_file_headers_without_document_policies() {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
"content-security-policy",
|
||||||
|
HeaderValue::from_static("default-src 'none'; sandbox"),
|
||||||
|
);
|
||||||
|
headers.insert(
|
||||||
|
header::CONTENT_DISPOSITION,
|
||||||
|
HeaderValue::from_static("attachment; filename=\"manual.pdf\""),
|
||||||
|
);
|
||||||
|
|
||||||
|
apply_response_headers(ResponseHeaderPolicy::File, &mut headers);
|
||||||
|
|
||||||
|
assert_eq!(headers[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
|
||||||
|
assert_eq!(
|
||||||
|
headers["content-security-policy"],
|
||||||
|
"default-src 'none'; sandbox"
|
||||||
|
);
|
||||||
|
assert!(headers.contains_key(header::CONTENT_DISPOSITION));
|
||||||
|
assert!(!headers.contains_key("x-frame-options"));
|
||||||
|
assert!(!headers.contains_key("cross-origin-opener-policy"));
|
||||||
|
assert!(!headers.contains_key("cross-origin-resource-policy"));
|
||||||
|
assert!(!headers.contains_key("referrer-policy"));
|
||||||
|
assert!(!headers.contains_key("permissions-policy"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn application_policy_preserves_handler_headers() {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
"content-security-policy",
|
||||||
|
HeaderValue::from_static("default-src 'none'; sandbox"),
|
||||||
|
);
|
||||||
|
|
||||||
|
apply_response_headers(ResponseHeaderPolicy::Application, &mut headers);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
headers["content-security-policy"],
|
||||||
|
"default-src 'none'; sandbox"
|
||||||
|
);
|
||||||
|
assert_eq!(headers["x-frame-options"], "DENY");
|
||||||
|
assert_eq!(headers["cross-origin-opener-policy"], "same-origin");
|
||||||
|
assert_eq!(headers["cross-origin-resource-policy"], "same-origin");
|
||||||
|
assert_eq!(headers[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
|
||||||
|
assert_eq!(
|
||||||
|
headers["referrer-policy"],
|
||||||
|
"strict-origin-when-cross-origin"
|
||||||
|
);
|
||||||
|
assert!(headers.contains_key("permissions-policy"));
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
||||||
|
* Source-Available Code / Dual-Licensed.
|
||||||
|
*
|
||||||
|
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||||
|
* Commercial or production use requires a valid paid license.
|
||||||
|
* See LICENSE file in repository root for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn share_exchange_redirects_to_canonical_resource_path() {
|
||||||
|
let uri: Uri = "/w/private?view=all&share=secret&page=2".parse().unwrap();
|
||||||
|
assert_eq!(canonical_resource_url(&uri), "/w/private?view=all&page=2");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encoded_or_repeated_parameters_are_parsed_without_rejection() {
|
||||||
|
let uri: Uri = "/w/private?%73hare=one&share=two".parse().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
share_token_from_query(uri.query()),
|
||||||
|
(true, Some("one".into()))
|
||||||
|
);
|
||||||
|
assert_eq!(canonical_resource_url(&uri), "/w/private");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_share_parameter_is_still_removed_from_the_url() {
|
||||||
|
let uri: Uri = "/w/private?share=%ZZ&keep=no".parse().unwrap();
|
||||||
|
assert_eq!(share_token_from_query(uri.query()), (true, None));
|
||||||
|
assert_eq!(canonical_resource_url(&uri), "/w/private?keep=no");
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
||||||
|
* Source-Available Code / Dual-Licensed.
|
||||||
|
*
|
||||||
|
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||||
|
* Commercial or production use requires a valid paid license.
|
||||||
|
* See LICENSE file in repository root for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::{
|
||||||
|
database::Database,
|
||||||
|
state::AppState,
|
||||||
|
storage::Storage,
|
||||||
|
};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
async fn logout_test_state() -> SharedState {
|
||||||
|
let db = Database::connect("sqlite::memory:", 1)
|
||||||
|
.await
|
||||||
|
.expect("test database");
|
||||||
|
crate::run_migrations(&db).await.expect("test migrations");
|
||||||
|
Arc::new(AppState::new(
|
||||||
|
db,
|
||||||
|
"test".into(),
|
||||||
|
Storage::Local {
|
||||||
|
root: std::env::temp_dir().join("rustpad-logout-tests"),
|
||||||
|
},
|
||||||
|
1_000_000,
|
||||||
|
true,
|
||||||
|
1_000_000,
|
||||||
|
0,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
"error".into(),
|
||||||
|
7,
|
||||||
|
7,
|
||||||
|
7,
|
||||||
|
None,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn logout_only_revokes_current_browser_session_and_password_access() {
|
||||||
|
let state = logout_test_state().await;
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO users (nickname, nickname_key, email, email_key, password_hash) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind("Logout Test")
|
||||||
|
.bind("logout test")
|
||||||
|
.bind("logout@example.test")
|
||||||
|
.bind("logout@example.test")
|
||||||
|
.bind("password-hash")
|
||||||
|
.execute(state.db.pool())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let expires_at = (Utc::now() + Duration::days(7)).to_rfc3339();
|
||||||
|
for session in ["current-session", "other-device-session"] {
|
||||||
|
sqlx::query("INSERT INTO user_sessions (token, user_id, expires_at) VALUES (?, 1, ?)")
|
||||||
|
.bind(session)
|
||||||
|
.bind(&expires_at)
|
||||||
|
.execute(state.db.pool())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let current_access_token = "a".repeat(64);
|
||||||
|
let other_access_token = "b".repeat(64);
|
||||||
|
for (token, slug) in [
|
||||||
|
(current_access_token.as_str(), "current-pad"),
|
||||||
|
(other_access_token.as_str(), "other-pad"),
|
||||||
|
] {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO resource_access_tokens (token_hash, resource_kind, resource_slug, expires_at) VALUES (?, 'pad', ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(hash_token(token))
|
||||||
|
.bind(slug)
|
||||||
|
.bind(&expires_at)
|
||||||
|
.execute(state.db.pool())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let access_cookie = crate::security::resource_cookie(
|
||||||
|
"pad",
|
||||||
|
"current-pad",
|
||||||
|
¤t_access_token,
|
||||||
|
7,
|
||||||
|
);
|
||||||
|
let access_cookie_name = access_cookie
|
||||||
|
.to_str()
|
||||||
|
.unwrap()
|
||||||
|
.split_once('=')
|
||||||
|
.unwrap()
|
||||||
|
.0
|
||||||
|
.to_string();
|
||||||
|
let share_cookie_name = "__Host-rustpad_share_0123456789abcdef01234567";
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
header::COOKIE,
|
||||||
|
axum::http::HeaderValue::from_str(&format!(
|
||||||
|
"{}=current-session; {}={}; {}=share-token",
|
||||||
|
crate::security::SESSION_COOKIE,
|
||||||
|
access_cookie_name,
|
||||||
|
current_access_token,
|
||||||
|
share_cookie_name,
|
||||||
|
))
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = match logout(State(state.clone()), headers).await {
|
||||||
|
Ok(response) => response,
|
||||||
|
Err(error) => panic!("logout failed: {}", error.message),
|
||||||
|
};
|
||||||
|
|
||||||
|
let current_session_count: i64 =
|
||||||
|
sqlx::query_scalar("SELECT COUNT(*) FROM user_sessions WHERE token = ?")
|
||||||
|
.bind("current-session")
|
||||||
|
.fetch_one(state.db.pool())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let other_session_count: i64 =
|
||||||
|
sqlx::query_scalar("SELECT COUNT(*) FROM user_sessions WHERE token = ?")
|
||||||
|
.bind("other-device-session")
|
||||||
|
.fetch_one(state.db.pool())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let current_access_count: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COUNT(*) FROM resource_access_tokens WHERE token_hash = ?",
|
||||||
|
)
|
||||||
|
.bind(hash_token(¤t_access_token))
|
||||||
|
.fetch_one(state.db.pool())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let other_access_count: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COUNT(*) FROM resource_access_tokens WHERE token_hash = ?",
|
||||||
|
)
|
||||||
|
.bind(hash_token(&other_access_token))
|
||||||
|
.fetch_one(state.db.pool())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let user_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE id = 1")
|
||||||
|
.fetch_one(state.db.pool())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(current_session_count, 0);
|
||||||
|
assert_eq!(other_session_count, 1);
|
||||||
|
assert_eq!(current_access_count, 0);
|
||||||
|
assert_eq!(other_access_count, 1);
|
||||||
|
assert_eq!(user_count, 1);
|
||||||
|
|
||||||
|
let set_cookies = response
|
||||||
|
.headers()
|
||||||
|
.get_all(header::SET_COOKIE)
|
||||||
|
.iter()
|
||||||
|
.map(|value| value.to_str().unwrap())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert!(set_cookies.iter().any(|value| {
|
||||||
|
value.starts_with(&format!("{}=;", crate::security::SESSION_COOKIE))
|
||||||
|
}));
|
||||||
|
assert!(set_cookies.iter().any(|value| {
|
||||||
|
value.starts_with(&format!("{access_cookie_name}=;"))
|
||||||
|
}));
|
||||||
|
assert!(!set_cookies.iter().any(|value| value.starts_with(share_cookie_name)));
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
||||||
|
* Source-Available Code / Dual-Licensed.
|
||||||
|
*
|
||||||
|
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||||
|
* Commercial or production use requires a valid paid license.
|
||||||
|
* See LICENSE file in repository root for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn operation(components: Vec<OperationComponent>) -> TextOperation {
|
||||||
|
TextOperation { components }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn concurrent_insertions_have_stable_order() {
|
||||||
|
let left = operation(vec![
|
||||||
|
OperationComponent::Retain { count: 1 },
|
||||||
|
OperationComponent::Insert {
|
||||||
|
text: "X".into(),
|
||||||
|
owners: Vec::new(),
|
||||||
|
},
|
||||||
|
OperationComponent::Retain { count: 1 },
|
||||||
|
]);
|
||||||
|
let right = operation(vec![
|
||||||
|
OperationComponent::Retain { count: 1 },
|
||||||
|
OperationComponent::Insert {
|
||||||
|
text: "Y".into(),
|
||||||
|
owners: Vec::new(),
|
||||||
|
},
|
||||||
|
OperationComponent::Retain { count: 1 },
|
||||||
|
]);
|
||||||
|
let left_prime = transform_operation(&left, &right, true).unwrap();
|
||||||
|
let right_prime = transform_operation(&right, &left, false).unwrap();
|
||||||
|
let after_right = apply_operation_to_document("aYb", "[]", &left_prime, &[])
|
||||||
|
.unwrap()
|
||||||
|
.0;
|
||||||
|
let after_left = apply_operation_to_document("aXb", "[]", &right_prime, &[])
|
||||||
|
.unwrap()
|
||||||
|
.0;
|
||||||
|
assert_eq!(after_right, "aXYb");
|
||||||
|
assert_eq!(after_left, "aXYb");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn utf16_offsets_support_emoji() {
|
||||||
|
let operation = operation(vec![
|
||||||
|
OperationComponent::Retain { count: 3 },
|
||||||
|
OperationComponent::Insert {
|
||||||
|
text: "x".into(),
|
||||||
|
owners: Vec::new(),
|
||||||
|
},
|
||||||
|
OperationComponent::Retain { count: 1 },
|
||||||
|
]);
|
||||||
|
let result = apply_operation_to_document("A😀B", "[]", &operation, &[])
|
||||||
|
.unwrap()
|
||||||
|
.0;
|
||||||
|
assert_eq!(result, "A😀xB");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn operation_from_edit_preserves_utf16_boundaries() {
|
||||||
|
let operation = operation_from_edit("A😀B", "A😀xB", "[]");
|
||||||
|
let result = apply_operation_to_document("A😀B", "[]", &operation, &[])
|
||||||
|
.unwrap()
|
||||||
|
.0;
|
||||||
|
assert_eq!(result, "A😀xB");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn acknowledgements_survive_history_compaction() {
|
||||||
|
let mut document = CollaborativeDocument::new(String::new(), "[]".into(), 0);
|
||||||
|
for update_id in 1..=MAX_OPERATION_HISTORY as u64 + 8 {
|
||||||
|
let base_revision_id = document.revision_id;
|
||||||
|
let revision_id = base_revision_id + 1;
|
||||||
|
document.revision_id = revision_id;
|
||||||
|
document.record(AppliedOperation {
|
||||||
|
base_revision_id,
|
||||||
|
revision_id,
|
||||||
|
client_id: "client-123".into(),
|
||||||
|
update_id,
|
||||||
|
operation: TextOperation::default(),
|
||||||
|
owner_replacements: Vec::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
assert!(document.has_applied_update("client-123", 1));
|
||||||
|
assert_eq!(
|
||||||
|
document.acknowledged_updates("client-123"),
|
||||||
|
vec![MAX_OPERATION_HISTORY as u64 + 8]
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
||||||
|
* Source-Available Code / Dual-Licensed.
|
||||||
|
*
|
||||||
|
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||||
|
* Commercial or production use requires a valid paid license.
|
||||||
|
* See LICENSE file in repository root for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use super::{megabytes_to_bytes, multipart_body_limit_bytes};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn converts_upload_megabytes_to_bytes() {
|
||||||
|
assert_eq!(megabytes_to_bytes("LIMIT", 5).unwrap(), 5 * 1024 * 1024);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_overflowing_upload_limit() {
|
||||||
|
assert!(megabytes_to_bytes("LIMIT", u64::MAX).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multipart_limit_uses_user_limit_when_guest_uploads_are_disabled() {
|
||||||
|
assert_eq!(
|
||||||
|
multipart_body_limit_bytes(20 * 1024 * 1024, false, 50 * 1024 * 1024),
|
||||||
|
21 * 1024 * 1024
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multipart_limit_uses_larger_enabled_guest_limit() {
|
||||||
|
assert_eq!(
|
||||||
|
multipart_body_limit_bytes(20 * 1024 * 1024, true, 50 * 1024 * 1024),
|
||||||
|
51 * 1024 * 1024
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
||||||
|
* Source-Available Code / Dual-Licensed.
|
||||||
|
*
|
||||||
|
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||||
|
* Commercial or production use requires a valid paid license.
|
||||||
|
* See LICENSE file in repository root for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detects_security_from_standard_ports() {
|
||||||
|
assert_eq!(smtp_security_for_port(25), SmtpSecurity::None);
|
||||||
|
assert_eq!(smtp_security_for_port(465), SmtpSecurity::Tls);
|
||||||
|
assert_eq!(smtp_security_for_port(587), SmtpSecurity::StartTls);
|
||||||
|
assert_eq!(smtp_security_for_port(2525), SmtpSecurity::None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepts_supported_security_modes() {
|
||||||
|
assert_eq!(parse_smtp_security("none").unwrap(), SmtpSecurity::None);
|
||||||
|
assert_eq!(
|
||||||
|
parse_smtp_security("starttls").unwrap(),
|
||||||
|
SmtpSecurity::StartTls
|
||||||
|
);
|
||||||
|
assert_eq!(parse_smtp_security("tls").unwrap(), SmtpSecurity::Tls);
|
||||||
|
assert!(parse_smtp_security("auto").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalizes_smtp_from() {
|
||||||
|
assert_eq!(
|
||||||
|
normalize_smtp_from(" \"RustPad <rustpad@notes.example>\" ".to_owned()).unwrap(),
|
||||||
|
"RustPad <rustpad@notes.example>"
|
||||||
|
);
|
||||||
|
assert!(normalize_smtp_from("RustPad".to_owned()).is_err());
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
||||||
|
* Source-Available Code / Dual-Licensed.
|
||||||
|
*
|
||||||
|
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||||
|
* Commercial or production use requires a valid paid license.
|
||||||
|
* See LICENSE file in repository root for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn workspace(password_hash: Option<String>) -> Workspace {
|
||||||
|
Workspace {
|
||||||
|
id: 1,
|
||||||
|
slug: "private-workspace".into(),
|
||||||
|
title: "Private workspace".into(),
|
||||||
|
password_hash,
|
||||||
|
created_at: String::new(),
|
||||||
|
updated_at: String::new(),
|
||||||
|
is_private: 1,
|
||||||
|
created_by_guest_id: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pad(password_hash: Option<String>) -> Pad {
|
||||||
|
Pad {
|
||||||
|
id: 1,
|
||||||
|
slug: "private-pad".into(),
|
||||||
|
title: "Private pad".into(),
|
||||||
|
content: String::new(),
|
||||||
|
password_hash,
|
||||||
|
created_at: String::new(),
|
||||||
|
updated_at: String::new(),
|
||||||
|
is_private: 1,
|
||||||
|
created_by_guest_id: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_password_does_not_grant_password_access() {
|
||||||
|
assert!(!verify_workspace_password(&workspace(None), None));
|
||||||
|
assert!(!verify_workspace_password(
|
||||||
|
&workspace(None),
|
||||||
|
Some("anything")
|
||||||
|
));
|
||||||
|
assert!(!verify_pad_password(&pad(None), None));
|
||||||
|
assert!(!verify_pad_password(&pad(None), Some("anything")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn configured_password_is_verified() {
|
||||||
|
let workspace = workspace(Some(hash_password("workspace-secret")));
|
||||||
|
assert!(verify_workspace_password(
|
||||||
|
&workspace,
|
||||||
|
Some("workspace-secret")
|
||||||
|
));
|
||||||
|
assert!(!verify_workspace_password(&workspace, Some("wrong")));
|
||||||
|
|
||||||
|
let pad = pad(Some(hash_password("pad-secret")));
|
||||||
|
assert!(verify_pad_password(&pad, Some("pad-secret")));
|
||||||
|
assert!(!verify_pad_password(&pad, Some("wrong")));
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
||||||
|
* Source-Available Code / Dual-Licensed.
|
||||||
|
*
|
||||||
|
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||||
|
* Commercial or production use requires a valid paid license.
|
||||||
|
* See LICENSE file in repository root for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalizes_bare_domain_and_http_origins() {
|
||||||
|
assert_eq!(
|
||||||
|
normalize_public_base(Some("files.note.example.com".into())).unwrap(),
|
||||||
|
Some("https://files.note.example.com".into())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
normalize_public_base(Some("http://localhost:3001/".into())).unwrap(),
|
||||||
|
Some("http://localhost:3001".into())
|
||||||
|
);
|
||||||
|
assert_eq!(normalize_public_base(Some(" ".into())).unwrap(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_non_origin_public_urls() {
|
||||||
|
assert!(normalize_public_base(Some("ftp://files.example.com".into())).is_err());
|
||||||
|
assert!(normalize_public_base(Some("https://files.example.com/path".into())).is_err());
|
||||||
|
assert!(normalize_public_base(Some("https://user@files.example.com".into())).is_err());
|
||||||
|
assert!(normalize_public_base(Some("files.example.com\\path".into())).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extracts_canonical_path_from_relative_and_absolute_urls() {
|
||||||
|
assert_eq!(
|
||||||
|
canonical_file_path("/f/token/image.png"),
|
||||||
|
Some("/f/token/image.png".into())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
canonical_file_path("https://files.example.com/f/token/image.png"),
|
||||||
|
Some("/f/token/image.png".into())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
canonical_file_path("https://files.example.com/f/token/image.png?download=1"),
|
||||||
|
Some("/f/token/image.png".into())
|
||||||
|
);
|
||||||
|
assert_eq!(canonical_file_path("/files/token/image.png"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn switches_between_custom_origin_and_application_path() {
|
||||||
|
let stored = "/f/token/manual.pdf";
|
||||||
|
assert_eq!(public_file_url(None, stored), stored);
|
||||||
|
assert_eq!(
|
||||||
|
public_file_url(Some("https://files.example.com"), stored),
|
||||||
|
"https://files.example.com/f/token/manual.pdf"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
public_file_url(None, "https://old.example.com/f/token/manual.pdf"),
|
||||||
|
stored
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
public_file_url(Some("https://files.example.com"), "/invalid/path"),
|
||||||
|
"/invalid/path"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
||||||
|
* Source-Available Code / Dual-Licensed.
|
||||||
|
*
|
||||||
|
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||||
|
* Commercial or production use requires a valid paid license.
|
||||||
|
* See LICENSE file in repository root for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use super::startup_credential;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn startup_credential_contains_product_identity() {
|
||||||
|
let credential = startup_credential();
|
||||||
|
assert!(credential.contains(&format!("RustPad {}", env!("CARGO_PKG_VERSION"))));
|
||||||
|
assert!(credential.contains("Mateusz Gruszczyński @linuxiarz.pl"));
|
||||||
|
assert!(
|
||||||
|
credential.contains("https://git.linuxiarz.pl/gru/rustpad/src/branch/master/LICENSE.md")
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
||||||
|
* Source-Available Code / Dual-Licensed.
|
||||||
|
*
|
||||||
|
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||||
|
* Commercial or production use requires a valid paid license.
|
||||||
|
* See LICENSE file in repository root for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn every_backend_has_explicit_queries() {
|
||||||
|
for query in [
|
||||||
|
Q001,
|
||||||
|
Q003,
|
||||||
|
Q004,
|
||||||
|
Q011,
|
||||||
|
Q021,
|
||||||
|
Q033,
|
||||||
|
USER_LIST_WORKSPACES,
|
||||||
|
USER_LIST_PADS,
|
||||||
|
RESOURCE_ACCESS_TOKENS_DELETE_BY_TOKEN_HASH,
|
||||||
|
SHARE_LINK_SESSION_SOURCE,
|
||||||
|
SHARE_SESSION_INSERT,
|
||||||
|
SHARE_SESSION_PERMISSION,
|
||||||
|
SHARE_SESSIONS_DELETE_BY_LINK,
|
||||||
|
SHARE_SESSIONS_DELETE_EXPIRED,
|
||||||
|
PAD_PUBLIC_PAGE_DISABLED,
|
||||||
|
NOTE_PUBLIC_PAGE_DISABLED,
|
||||||
|
] {
|
||||||
|
assert!(!get(DatabaseKind::Sqlite, query).is_empty());
|
||||||
|
assert!(!get(DatabaseKind::Postgres, query).is_empty());
|
||||||
|
assert!(!get(DatabaseKind::MySql, query).is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
||||||
|
* Source-Available Code / Dual-Licensed.
|
||||||
|
*
|
||||||
|
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||||
|
* Commercial or production use requires a valid paid license.
|
||||||
|
* See LICENSE file in repository root for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn boolean_projections_are_normalized_for_sqlx_any() {
|
||||||
|
// MySQL BOOLEAN is TINYINT(1), which sqlx::Any 0.8 cannot map directly.
|
||||||
|
for (query, expected_casts) in [
|
||||||
|
(Query::RESOURCE_EDITOR_SETTINGS_SELECT, 1),
|
||||||
|
(Query::EDITOR_PREFERENCES_SELECT_PAD, 5),
|
||||||
|
(Query::EDITOR_PREFERENCES_SELECT_NOTE, 5),
|
||||||
|
(Query::AUTH_USER_BY_EXTERNAL_ID, 1),
|
||||||
|
(Query::AUTH_USER_BY_SESSION, 1),
|
||||||
|
(Query::AUTH_USER_BY_NICKNAME, 1),
|
||||||
|
(Query::AUTH_USER_BY_EMAIL, 1),
|
||||||
|
(Query::AUTH_USER_BY_SHARE_IDENTIFIER, 1),
|
||||||
|
(Query::USER_LIST_WORKSPACES, 6),
|
||||||
|
(Query::USER_LIST_PADS, 6),
|
||||||
|
(Query::PAD_PUBLIC_PAGE_DISABLED, 1),
|
||||||
|
(Query::NOTE_PUBLIC_PAGE_DISABLED, 1),
|
||||||
|
(Query::Q001, 1),
|
||||||
|
(Query::Q003, 1),
|
||||||
|
(Query::Q004, 1),
|
||||||
|
(Query::Q011, 1),
|
||||||
|
(Query::Q021, 1),
|
||||||
|
(Query::Q033, 1),
|
||||||
|
(Query::Q036, 1),
|
||||||
|
(Query::Q038, 1),
|
||||||
|
(Query::Q046, 1),
|
||||||
|
(Query::Q044, 1),
|
||||||
|
(Query::Q045, 1),
|
||||||
|
(Query::Q048, 1),
|
||||||
|
(Query::Q049, 1),
|
||||||
|
] {
|
||||||
|
let sql = get(query);
|
||||||
|
assert_eq!(
|
||||||
|
sql.matches("AS SIGNED").count(),
|
||||||
|
expected_casts,
|
||||||
|
"MySQL boolean projection is not normalized in {query:?}: {sql}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
||||||
|
* Source-Available Code / Dual-Licensed.
|
||||||
|
*
|
||||||
|
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||||
|
* Commercial or production use requires a valid paid license.
|
||||||
|
* See LICENSE file in repository root for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn websocket_headers(origin: &'static str, host: &'static str) -> HeaderMap {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(header::ORIGIN, HeaderValue::from_static(origin));
|
||||||
|
headers.insert(header::HOST, HeaderValue::from_static(host));
|
||||||
|
headers
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn account_sessions_are_cookie_only() {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
header::AUTHORIZATION,
|
||||||
|
HeaderValue::from_static("Bearer legacy-account-token"),
|
||||||
|
);
|
||||||
|
assert_eq!(session_token(&headers), None);
|
||||||
|
assert_eq!(bearer_token(&headers), Some("legacy-account-token"));
|
||||||
|
|
||||||
|
headers.insert(
|
||||||
|
header::COOKIE,
|
||||||
|
HeaderValue::from_static("__Host-rustpad_session=cookie-token"),
|
||||||
|
);
|
||||||
|
assert_eq!(session_token(&headers), Some("cookie-token"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prefers_proxy_controlled_real_ip() {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
axum::http::HeaderName::from_static("x-forwarded-for"),
|
||||||
|
HeaderValue::from_static("203.0.113.10"),
|
||||||
|
);
|
||||||
|
headers.insert(
|
||||||
|
axum::http::HeaderName::from_static("x-real-ip"),
|
||||||
|
HeaderValue::from_static("198.51.100.20"),
|
||||||
|
);
|
||||||
|
assert_eq!(client_key(&headers), "ip:198.51.100.20");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepts_same_origin_websocket() {
|
||||||
|
let headers = websocket_headers("https://pad.example.com", "pad.example.com");
|
||||||
|
assert!(websocket_origin_allowed(&headers));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_cross_origin_websocket() {
|
||||||
|
let headers = websocket_headers("https://evil.example", "pad.example.com");
|
||||||
|
assert!(!websocket_origin_allowed(&headers));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn does_not_trust_forwarded_host_for_websocket_origin() {
|
||||||
|
let mut headers = websocket_headers("https://evil.example", "pad.example.com");
|
||||||
|
headers.insert(
|
||||||
|
axum::http::HeaderName::from_static("x-forwarded-host"),
|
||||||
|
HeaderValue::from_static("evil.example"),
|
||||||
|
);
|
||||||
|
assert!(!websocket_origin_allowed(&headers));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_origin_with_path() {
|
||||||
|
let headers = websocket_headers("https://pad.example.com/other", "pad.example.com");
|
||||||
|
assert!(!websocket_origin_allowed(&headers));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_missing_websocket_origin() {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(header::HOST, HeaderValue::from_static("pad.example.com"));
|
||||||
|
assert!(!websocket_origin_allowed(&headers));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn secure_cookies_are_not_script_readable() {
|
||||||
|
let value = session_cookie("abc123", 7).to_str().unwrap();
|
||||||
|
assert!(value.contains("HttpOnly"));
|
||||||
|
assert!(value.contains("Secure"));
|
||||||
|
assert!(value.contains("SameSite=Lax"));
|
||||||
|
assert!(value.starts_with("__Host-rustpad_session=abc123;"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn share_sessions_use_separate_scoped_opaque_cookies() {
|
||||||
|
let value = share_session_cookie("workspace", "private-space", "opaque", 600)
|
||||||
|
.to_str()
|
||||||
|
.unwrap();
|
||||||
|
assert!(value.starts_with("__Host-rustpad_share_"));
|
||||||
|
assert!(value.contains("=opaque;"));
|
||||||
|
assert!(value.contains("Max-Age=600"));
|
||||||
|
assert!(value.contains("HttpOnly"));
|
||||||
|
assert!(value.contains("Secure"));
|
||||||
|
assert!(value.contains("SameSite=Lax"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn logout_cookie_discovery_only_selects_password_access_cookies() {
|
||||||
|
let valid_name = "__Host-rustpad_access_0123456789abcdef01234567";
|
||||||
|
let valid_token = "a".repeat(64);
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
header::COOKIE,
|
||||||
|
HeaderValue::from_str(&format!(
|
||||||
|
"{SESSION_COOKIE}=session-token; {valid_name}={valid_token}; __Host-rustpad_share_0123456789abcdef01234567=share-token; __Host-rustpad_access_too-short={valid_token}"
|
||||||
|
))
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
resource_access_cookies(&headers),
|
||||||
|
vec![(valid_name.to_string(), Some(valid_token))]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_password_access_token_is_cleared_but_not_revoked() {
|
||||||
|
let name = "__Host-rustpad_access_0123456789abcdef01234567";
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
header::COOKIE,
|
||||||
|
HeaderValue::from_str(&format!("{name}=not-a-valid-token")).unwrap(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
resource_access_cookies(&headers),
|
||||||
|
vec![(name.to_string(), None)]
|
||||||
|
);
|
||||||
|
let cleared = clear_resource_access_cookie(name).unwrap();
|
||||||
|
assert!(cleared.to_str().unwrap().starts_with(&format!("{name}=;")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn csrf_requires_matching_cookie_and_header() {
|
||||||
|
let token = "a".repeat(CSRF_TOKEN_BYTES * 2);
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
header::COOKIE,
|
||||||
|
HeaderValue::from_str(&format!("{CSRF_COOKIE}={token}")).unwrap(),
|
||||||
|
);
|
||||||
|
headers.insert(
|
||||||
|
axum::http::HeaderName::from_static(CSRF_HEADER),
|
||||||
|
HeaderValue::from_str(&token).unwrap(),
|
||||||
|
);
|
||||||
|
assert!(csrf_request_is_valid(&headers));
|
||||||
|
|
||||||
|
headers.insert(
|
||||||
|
axum::http::HeaderName::from_static(CSRF_HEADER),
|
||||||
|
HeaderValue::from_static(
|
||||||
|
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert!(!csrf_request_is_valid(&headers));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn csrf_cookie_is_strict_and_script_readable() {
|
||||||
|
let token = "a".repeat(CSRF_TOKEN_BYTES * 2);
|
||||||
|
let value = csrf_cookie(&token).to_str().unwrap();
|
||||||
|
assert!(value.contains("Secure"));
|
||||||
|
assert!(value.contains("SameSite=Strict"));
|
||||||
|
assert!(!value.contains("HttpOnly"));
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
||||||
|
* Source-Available Code / Dual-Licensed.
|
||||||
|
*
|
||||||
|
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||||
|
* Commercial or production use requires a valid paid license.
|
||||||
|
* See LICENSE file in repository root for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_password_event_reaches_the_workspace_page_and_its_notes() {
|
||||||
|
let workspace_key = AppState::workspace_room_key("team");
|
||||||
|
let note_prefix = "workspace:team/";
|
||||||
|
|
||||||
|
assert!(workspace_password_event_channel(
|
||||||
|
"workspace:team",
|
||||||
|
&workspace_key,
|
||||||
|
note_prefix,
|
||||||
|
));
|
||||||
|
assert!(workspace_password_event_channel(
|
||||||
|
"workspace:team/roadmap",
|
||||||
|
&workspace_key,
|
||||||
|
note_prefix,
|
||||||
|
));
|
||||||
|
assert!(!workspace_password_event_channel(
|
||||||
|
"workspace:team-two/roadmap",
|
||||||
|
&workspace_key,
|
||||||
|
note_prefix,
|
||||||
|
));
|
||||||
|
assert!(!workspace_password_event_channel(
|
||||||
|
"workspace:teams/roadmap",
|
||||||
|
&workspace_key,
|
||||||
|
note_prefix,
|
||||||
|
));
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
||||||
|
* Source-Available Code / Dual-Licensed.
|
||||||
|
*
|
||||||
|
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||||
|
* Commercial or production use requires a valid paid license.
|
||||||
|
* See LICENSE file in repository root for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn password_event_only_excludes_the_matching_connection() {
|
||||||
|
assert!(password_event_excludes_connection(
|
||||||
|
Some("client_owner"),
|
||||||
|
"client_owner",
|
||||||
|
));
|
||||||
|
assert!(!password_event_excludes_connection(
|
||||||
|
Some("client_owner"),
|
||||||
|
"client_visitor",
|
||||||
|
));
|
||||||
|
assert!(!password_event_excludes_connection(None, "client_owner"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn password_event_client_id_is_validated() {
|
||||||
|
assert_eq!(
|
||||||
|
clean_collaboration_client_id(Some("client_owner_123".into())),
|
||||||
|
Some("client_owner_123".into()),
|
||||||
|
);
|
||||||
|
assert_eq!(clean_collaboration_client_id(Some("short".into())), None);
|
||||||
|
assert_eq!(
|
||||||
|
clean_collaboration_client_id(Some("invalid client id!".into())),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_watch_authentication_accepts_a_minimal_client_message() {
|
||||||
|
let message = serde_json::from_str::<WorkspaceWatchClientMessage>(
|
||||||
|
r#"{"type":"authenticate","access_token":null,"client_id":"workspace_watch_123"}"#,
|
||||||
|
)
|
||||||
|
.expect("workspace watch authentication should parse");
|
||||||
|
|
||||||
|
match message {
|
||||||
|
WorkspaceWatchClientMessage::Authenticate {
|
||||||
|
access_token,
|
||||||
|
client_id,
|
||||||
|
} => {
|
||||||
|
assert!(access_token.is_none());
|
||||||
|
assert_eq!(client_id.as_deref(), Some("workspace_watch_123"));
|
||||||
|
}
|
||||||
|
WorkspaceWatchClientMessage::Ping { .. } => panic!("unexpected ping message"),
|
||||||
|
}
|
||||||
|
}
|
||||||
+953
-69
File diff suppressed because it is too large
Load Diff
+445
-62
@@ -7,14 +7,27 @@ enum PadServerMessage {
|
|||||||
title: String,
|
title: String,
|
||||||
content: String,
|
content: String,
|
||||||
owner_map: String,
|
owner_map: String,
|
||||||
|
revision_id: i64,
|
||||||
access_level: String,
|
access_level: String,
|
||||||
|
catchup_operations: Vec<AppliedOperation>,
|
||||||
|
acknowledged_update_ids: Vec<u64>,
|
||||||
|
resync_required: bool,
|
||||||
},
|
},
|
||||||
Document {
|
Document {
|
||||||
content: String,
|
base_revision_id: i64,
|
||||||
revision_id: i64,
|
revision_id: i64,
|
||||||
updated_at: String,
|
updated_at: String,
|
||||||
author: Option<String>,
|
author: Option<String>,
|
||||||
|
client_id: String,
|
||||||
|
update_id: u64,
|
||||||
|
operation: TextOperation,
|
||||||
|
owner_replacements: Vec<OwnerReplacement>,
|
||||||
|
},
|
||||||
|
Resync {
|
||||||
|
content: String,
|
||||||
|
revision_id: i64,
|
||||||
owner_map: String,
|
owner_map: String,
|
||||||
|
acknowledged_update_ids: Vec<u64>,
|
||||||
},
|
},
|
||||||
Presence {
|
Presence {
|
||||||
users: Vec<PresenceUser>,
|
users: Vec<PresenceUser>,
|
||||||
@@ -26,18 +39,54 @@ enum PadServerMessage {
|
|||||||
Pong {
|
Pong {
|
||||||
nonce: u64,
|
nonce: u64,
|
||||||
},
|
},
|
||||||
|
Diagnostics {
|
||||||
|
diagnostics: ConnectionDiagnostics,
|
||||||
|
},
|
||||||
|
PasswordRequired,
|
||||||
|
PasswordChanged,
|
||||||
Error {
|
Error {
|
||||||
message: String,
|
message: String,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
pub async fn upgrade_pad(
|
pub async fn upgrade_pad(
|
||||||
ws: WebSocketUpgrade,
|
ws: WebSocketUpgrade,
|
||||||
|
headers: HeaderMap,
|
||||||
Path(slug): Path<String>,
|
Path(slug): Path<String>,
|
||||||
State(state): State<SharedState>,
|
State(state): State<SharedState>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
ws.on_upgrade(move |socket| handle_pad_socket(socket, state, slug))
|
if !crate::security::websocket_origin_allowed(&headers) {
|
||||||
|
warn!(%slug, "pad websocket rejected: invalid origin");
|
||||||
|
return (StatusCode::FORBIDDEN, "Invalid WebSocket origin").into_response();
|
||||||
|
}
|
||||||
|
let account_token = crate::security::session_token(&headers).map(str::to_owned);
|
||||||
|
let share_session_token =
|
||||||
|
crate::security::share_session_token(&headers, "pad", &slug).map(str::to_owned);
|
||||||
|
let resource_token = crate::security::resource_token(&headers, "pad", &slug).map(str::to_owned);
|
||||||
|
let client_key = crate::security::client_key(&headers);
|
||||||
|
let client_context = RequestClientContext::from_headers(&headers, &client_key);
|
||||||
|
ws.on_upgrade(move |socket| {
|
||||||
|
handle_pad_socket(
|
||||||
|
socket,
|
||||||
|
state,
|
||||||
|
slug,
|
||||||
|
account_token,
|
||||||
|
share_session_token,
|
||||||
|
resource_token,
|
||||||
|
client_key,
|
||||||
|
client_context,
|
||||||
|
)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
async fn handle_pad_socket(mut socket: WebSocket, state: SharedState, slug: String) {
|
async fn handle_pad_socket(
|
||||||
|
mut socket: WebSocket,
|
||||||
|
state: SharedState,
|
||||||
|
slug: String,
|
||||||
|
cookie_session_token: Option<String>,
|
||||||
|
cookie_share_session_token: Option<String>,
|
||||||
|
cookie_password_token: Option<String>,
|
||||||
|
client_key: String,
|
||||||
|
client_context: RequestClientContext,
|
||||||
|
) {
|
||||||
info!(%slug, "pad websocket connected");
|
info!(%slug, "pad websocket connected");
|
||||||
let Some(pad) = db::find_pad(&state.db, &slug).await.ok().flatten() else {
|
let Some(pad) = db::find_pad(&state.db, &slug).await.ok().flatten() else {
|
||||||
warn!(%slug, "pad websocket rejected: pad not found");
|
warn!(%slug, "pad websocket rejected: pad not found");
|
||||||
@@ -50,37 +99,61 @@ async fn handle_pad_socket(mut socket: WebSocket, state: SharedState, slug: Stri
|
|||||||
.await;
|
.await;
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let (password, access_token, nickname, session_token, guest_id, color) =
|
let (
|
||||||
match socket.recv().await {
|
password,
|
||||||
Some(Ok(Message::Text(text))) => match serde_json::from_str::<ClientMessage>(&text) {
|
access_token,
|
||||||
Ok(ClientMessage::Authenticate {
|
nickname,
|
||||||
password,
|
guest_id,
|
||||||
access_token,
|
color,
|
||||||
nickname,
|
client_diagnostics,
|
||||||
session_token,
|
collaboration_client_id,
|
||||||
guest_id,
|
known_revision_id,
|
||||||
color,
|
) = match socket.recv().await {
|
||||||
}) => (
|
Some(Ok(Message::Text(text))) => match serde_json::from_str::<ClientMessage>(&text) {
|
||||||
password,
|
Ok(ClientMessage::Authenticate {
|
||||||
access_token,
|
password,
|
||||||
clean_nickname(nickname),
|
access_token,
|
||||||
session_token,
|
nickname,
|
||||||
clean_guest_id(guest_id),
|
guest_id,
|
||||||
clean_color(color),
|
color,
|
||||||
),
|
diagnostics,
|
||||||
_ => {
|
client_id,
|
||||||
let _ = send_pad(
|
known_revision_id,
|
||||||
&mut socket,
|
}) => (
|
||||||
&PadServerMessage::Error {
|
password,
|
||||||
message: "Wymagane uwierzytelnienie".into(),
|
access_token,
|
||||||
},
|
clean_nickname(nickname),
|
||||||
)
|
clean_guest_id(guest_id),
|
||||||
.await;
|
clean_color(color),
|
||||||
return;
|
diagnostics,
|
||||||
}
|
clean_collaboration_client_id(client_id)
|
||||||
},
|
.unwrap_or_else(|| format!("legacy_{}", db::random_suffix(24))),
|
||||||
_ => return,
|
known_revision_id.filter(|revision_id| *revision_id >= 0),
|
||||||
};
|
),
|
||||||
|
_ => {
|
||||||
|
let _ = send_pad(
|
||||||
|
&mut socket,
|
||||||
|
&PadServerMessage::Error {
|
||||||
|
message: "Wymagane uwierzytelnienie".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
let session_token = cookie_session_token;
|
||||||
|
let explicit_access_token = access_token
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty() && *value != "cookie")
|
||||||
|
.map(str::to_owned);
|
||||||
|
let external_tokens = [
|
||||||
|
explicit_access_token.as_deref(),
|
||||||
|
cookie_share_session_token.as_deref(),
|
||||||
|
cookie_password_token.as_deref(),
|
||||||
|
];
|
||||||
let nickname = match auth::authorize_nickname(&state, nickname, session_token.clone()).await {
|
let nickname = match auth::authorize_nickname(&state, nickname, session_token.clone()).await {
|
||||||
Ok(value) => value,
|
Ok(value) => value,
|
||||||
Err(message) => {
|
Err(message) => {
|
||||||
@@ -100,16 +173,58 @@ async fn handle_pad_socket(mut socket: WebSocket, state: SharedState, slug: Stri
|
|||||||
.map(|name| format!("guest:{id}:{}", name.to_lowercase()))
|
.map(|name| format!("guest:{id}:{}", name.to_lowercase()))
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
let supplied_token = session_token.as_deref().or(access_token.as_deref());
|
let permission = resource_permission_from_tokens(
|
||||||
let permission = auth::resource_permission(&state, "pad", &slug, supplied_token)
|
&state,
|
||||||
.await
|
"pad",
|
||||||
.ok()
|
&slug,
|
||||||
.flatten();
|
&external_tokens,
|
||||||
let anonymous_token_ok = permission.is_none()
|
session_token.as_deref(),
|
||||||
&& crate::api::verify_resource_access_token(&state, "pad", &slug, supplied_token)
|
)
|
||||||
|
.await;
|
||||||
|
let anonymous_token_ok =
|
||||||
|
password_access_from_tokens(&state, "pad", &slug, &external_tokens).await;
|
||||||
|
let password_limit_key = format!("resource-password:{client_key}:pad:{slug}");
|
||||||
|
let password_attempted = password
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.is_some_and(|value| !value.is_empty());
|
||||||
|
if pad.password_hash.is_some()
|
||||||
|
&& permission.as_deref() != Some("rw")
|
||||||
|
&& !anonymous_token_ok
|
||||||
|
&& password_attempted
|
||||||
|
{
|
||||||
|
let window = std::time::Duration::from_secs(15 * 60);
|
||||||
|
if let Err(seconds) = state
|
||||||
|
.check_rate_limit(format!("resource-password-client:{client_key}"), 50, window)
|
||||||
.await
|
.await
|
||||||
.unwrap_or(false);
|
{
|
||||||
|
let _ = send_pad(
|
||||||
|
&mut socket,
|
||||||
|
&PadServerMessage::Error {
|
||||||
|
message: format!("Too many password attempts. Try again in {seconds} seconds."),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Err(seconds) = state
|
||||||
|
.check_rate_limit(password_limit_key.clone(), 10, window)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
let _ = send_pad(
|
||||||
|
&mut socket,
|
||||||
|
&PadServerMessage::Error {
|
||||||
|
message: format!("Too many password attempts. Try again in {seconds} seconds."),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
let password_ok = db::verify_pad_password(&pad, password.as_deref());
|
let password_ok = db::verify_pad_password(&pad, password.as_deref());
|
||||||
|
if password_ok {
|
||||||
|
state.clear_rate_limit(&password_limit_key).await;
|
||||||
|
}
|
||||||
if pad.is_private != 0 && permission.is_none() && !anonymous_token_ok {
|
if pad.is_private != 0 && permission.is_none() && !anonymous_token_ok {
|
||||||
let _ = send_pad(
|
let _ = send_pad(
|
||||||
&mut socket,
|
&mut socket,
|
||||||
@@ -131,17 +246,98 @@ async fn handle_pad_socket(mut socket: WebSocket, state: SharedState, slug: Stri
|
|||||||
.await;
|
.await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let write_allowed = permission.as_deref() == Some("rw")
|
let (_, write_allowed) = current_resource_access(
|
||||||
|| anonymous_token_ok
|
&state,
|
||||||
|| password_ok
|
"pad",
|
||||||
|| (pad.is_private == 0 && pad.password_hash.is_none() && permission.is_none());
|
&slug,
|
||||||
|
&external_tokens,
|
||||||
|
session_token.as_deref(),
|
||||||
|
password_ok,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
info!(pad_id = pad.id, nickname = ?nickname, "pad websocket authenticated");
|
info!(pad_id = pad.id, nickname = ?nickname, "pad websocket authenticated");
|
||||||
|
let room_key = AppState::pad_room_key(&slug);
|
||||||
|
let collaboration_snapshot = match db::pad_collaboration_snapshot(&state.db, pad.id).await {
|
||||||
|
Ok(snapshot) => snapshot,
|
||||||
|
Err(error) => {
|
||||||
|
warn!(%error, pad_id = pad.id, "failed to load collaborative document");
|
||||||
|
let _ = send_pad(
|
||||||
|
&mut socket,
|
||||||
|
&PadServerMessage::Error {
|
||||||
|
message: "Failed to load the document".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let collaborative_document = state
|
||||||
|
.collaborative_document(
|
||||||
|
&room_key,
|
||||||
|
collaboration_snapshot.content,
|
||||||
|
collaboration_snapshot.owner_map,
|
||||||
|
collaboration_snapshot.revision_id,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
// Subscribe before taking the authentication snapshot. Updates committed after
|
||||||
|
// the snapshot are then queued for this connection instead of falling into a gap.
|
||||||
|
let channel = state.pad_channel(&slug).await;
|
||||||
|
let mut updates = channel.subscribe();
|
||||||
|
let persisted_acknowledged_update_id =
|
||||||
|
match db::latest_pad_collaboration_update_id(&state.db, pad.id, &collaboration_client_id)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(update_id) => update_id,
|
||||||
|
Err(error) => {
|
||||||
|
warn!(%error, pad_id = pad.id, "failed to load collaborative acknowledgement");
|
||||||
|
let _ = send_pad(
|
||||||
|
&mut socket,
|
||||||
|
&PadServerMessage::Error {
|
||||||
|
message: "Failed to load the document".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let (
|
||||||
|
authenticated_content,
|
||||||
|
authenticated_owner_map,
|
||||||
|
authenticated_revision_id,
|
||||||
|
catchup_operations,
|
||||||
|
acknowledged_update_ids,
|
||||||
|
resync_required,
|
||||||
|
) = {
|
||||||
|
let mut document = collaborative_document.lock().await;
|
||||||
|
if let Some(update_id) = persisted_acknowledged_update_id {
|
||||||
|
document.acknowledge(&collaboration_client_id, update_id);
|
||||||
|
}
|
||||||
|
let (catchup_operations, resync_required) = match known_revision_id {
|
||||||
|
Some(revision_id) => match document.operations_after(revision_id) {
|
||||||
|
Some(operations) => (operations, false),
|
||||||
|
None => (Vec::new(), revision_id != document.revision_id),
|
||||||
|
},
|
||||||
|
None => (Vec::new(), false),
|
||||||
|
};
|
||||||
|
(
|
||||||
|
document.content.clone(),
|
||||||
|
document.owner_map.clone(),
|
||||||
|
document.revision_id,
|
||||||
|
catchup_operations,
|
||||||
|
document.acknowledged_updates(&collaboration_client_id),
|
||||||
|
resync_required,
|
||||||
|
)
|
||||||
|
};
|
||||||
if send_pad(
|
if send_pad(
|
||||||
&mut socket,
|
&mut socket,
|
||||||
&PadServerMessage::Authenticated {
|
&PadServerMessage::Authenticated {
|
||||||
title: pad.title.clone(),
|
title: pad.title.clone(),
|
||||||
content: pad.content.clone(),
|
content: authenticated_content,
|
||||||
owner_map: pad.owner_map.clone(),
|
owner_map: authenticated_owner_map,
|
||||||
|
revision_id: authenticated_revision_id,
|
||||||
|
catchup_operations,
|
||||||
|
acknowledged_update_ids,
|
||||||
|
resync_required,
|
||||||
access_level: if write_allowed {
|
access_level: if write_allowed {
|
||||||
"full".into()
|
"full".into()
|
||||||
} else {
|
} else {
|
||||||
@@ -154,25 +350,156 @@ async fn handle_pad_socket(mut socket: WebSocket, state: SharedState, slug: Stri
|
|||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let room_key = AppState::pad_room_key(&slug);
|
|
||||||
let channel = state.pad_channel(&slug).await;
|
|
||||||
let mut updates = channel.subscribe();
|
|
||||||
let display_name = nickname.clone().unwrap_or_else(|| "Guest".into());
|
let display_name = nickname.clone().unwrap_or_else(|| "Guest".into());
|
||||||
let (connection_id, users) = state
|
let (connection_id, users) = state
|
||||||
.join_room(&room_key, display_name.clone(), color, presence_identity)
|
.join_room(&room_key, display_name.clone(), color, presence_identity)
|
||||||
.await;
|
.await;
|
||||||
let _ = channel.send(RoomEvent::Presence(users));
|
let _ = channel.send(RoomEvent::Presence(users));
|
||||||
let mut last_chat = Instant::now() - Duration::from_secs(1);
|
let mut last_chat = Instant::now() - Duration::from_secs(1);
|
||||||
|
let mut access_refresh = tokio::time::interval(Duration::from_secs(10));
|
||||||
|
access_refresh.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||||
|
access_refresh.tick().await;
|
||||||
let (mut sender, mut receiver) = socket.split();
|
let (mut sender, mut receiver) = socket.split();
|
||||||
|
if send_pad_split(
|
||||||
|
&mut sender,
|
||||||
|
&PadServerMessage::Diagnostics {
|
||||||
|
diagnostics: connection_diagnostics(&client_context, client_diagnostics),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
let users = state.leave_room(&room_key, connection_id).await;
|
||||||
|
let _ = channel.send(RoomEvent::Presence(users));
|
||||||
|
return;
|
||||||
|
}
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
incoming=receiver.next()=>match incoming{
|
incoming=receiver.next()=>match incoming{
|
||||||
Some(Ok(Message::Text(text)))=>match serde_json::from_str::<ClientMessage>(&text){
|
Some(Ok(Message::Text(text)))=>match serde_json::from_str::<ClientMessage>(&text){
|
||||||
Ok(ClientMessage::Update{content,owner_map})=>{if !write_allowed{let _=send_pad_split(&mut sender,&PadServerMessage::Error{message:"Read-only access".into()}).await;continue;}
|
Ok(ClientMessage::Update{base_revision_id,update_id,operation,owner_replacements})=>{
|
||||||
if content.len()>2_000_000 { let _=send_pad_split(&mut sender,&PadServerMessage::Error{message:"The document is too large".into()}).await; continue; }
|
let (read_allowed, current_write_allowed) = current_resource_access(
|
||||||
let owner_map=owner_map.unwrap_or_else(||"[]".into());
|
&state,
|
||||||
if let Ok((revision_id,updated_at))=db::save_pad_revision(&state.db,pad.id,&content,nickname.as_deref(),&owner_map).await{
|
"pad",
|
||||||
let _=channel.send(RoomEvent::Document(NoteUpdate{content,revision_id,updated_at,author:nickname.clone(),owner_map}));
|
&slug,
|
||||||
|
&external_tokens,
|
||||||
|
session_token.as_deref(),
|
||||||
|
password_ok,
|
||||||
|
).await;
|
||||||
|
if !read_allowed { let _=send_pad_split(&mut sender,&PadServerMessage::Error{message:"Access expired or revoked".into()}).await;break; }
|
||||||
|
if !current_write_allowed{let _=send_pad_split(&mut sender,&PadServerMessage::Error{message:"Read-only access".into()}).await;continue;}
|
||||||
|
if update_id == 0
|
||||||
|
|| update_id > i64::MAX as u64
|
||||||
|
|| !valid_owner_replacements(&owner_replacements)
|
||||||
|
{
|
||||||
|
let _=send_pad_split(&mut sender,&PadServerMessage::Error{message:"Invalid collaborative update".into()}).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut document = collaborative_document.lock().await;
|
||||||
|
if document.has_applied_update(&collaboration_client_id, update_id) {
|
||||||
|
let snapshot = (
|
||||||
|
document.content.clone(),
|
||||||
|
document.revision_id,
|
||||||
|
document.owner_map.clone(),
|
||||||
|
document.acknowledged_updates(&collaboration_client_id),
|
||||||
|
);
|
||||||
|
drop(document);
|
||||||
|
let _ = send_pad_split(&mut sender, &PadServerMessage::Resync {
|
||||||
|
content: snapshot.0,
|
||||||
|
revision_id: snapshot.1,
|
||||||
|
owner_map: snapshot.2,
|
||||||
|
acknowledged_update_ids: snapshot.3,
|
||||||
|
}).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let transformed = match document.transform_from(
|
||||||
|
base_revision_id,
|
||||||
|
&operation,
|
||||||
|
&collaboration_client_id,
|
||||||
|
update_id,
|
||||||
|
) {
|
||||||
|
Ok(operation) => operation,
|
||||||
|
Err(collab::OperationError::RevisionUnavailable) => {
|
||||||
|
let snapshot = (
|
||||||
|
document.content.clone(),
|
||||||
|
document.revision_id,
|
||||||
|
document.owner_map.clone(),
|
||||||
|
document.acknowledged_updates(&collaboration_client_id),
|
||||||
|
);
|
||||||
|
drop(document);
|
||||||
|
let _=send_pad_split(&mut sender,&PadServerMessage::Resync{
|
||||||
|
content:snapshot.0,
|
||||||
|
revision_id:snapshot.1,
|
||||||
|
owner_map:snapshot.2,
|
||||||
|
acknowledged_update_ids:snapshot.3,
|
||||||
|
}).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
drop(document);
|
||||||
|
warn!(%error, pad_id = pad.id, "invalid collaborative operation");
|
||||||
|
let _=send_pad_split(&mut sender,&PadServerMessage::Error{message:"Invalid collaborative update".into()}).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let applied_base_revision_id = document.revision_id;
|
||||||
|
let (content,owner_map)=match collab::apply_operation_to_document(
|
||||||
|
&document.content,
|
||||||
|
&document.owner_map,
|
||||||
|
&transformed,
|
||||||
|
&owner_replacements,
|
||||||
|
) {
|
||||||
|
Ok(document) => document,
|
||||||
|
Err(error) => {
|
||||||
|
drop(document);
|
||||||
|
warn!(%error, pad_id = pad.id, "failed to apply collaborative operation");
|
||||||
|
let _=send_pad_split(&mut sender,&PadServerMessage::Error{message:"Invalid collaborative update".into()}).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if content.len()>2_000_000 {
|
||||||
|
drop(document);
|
||||||
|
let _=send_pad_split(&mut sender,&PadServerMessage::Error{message:"The document is too large".into()}).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match db::save_collaborative_pad_revision(
|
||||||
|
&state.db,
|
||||||
|
pad.id,
|
||||||
|
&content,
|
||||||
|
nickname.as_deref(),
|
||||||
|
&owner_map,
|
||||||
|
&collaboration_client_id,
|
||||||
|
update_id as i64,
|
||||||
|
).await{
|
||||||
|
Ok((revision_id,updated_at))=>{
|
||||||
|
document.content=content.clone();
|
||||||
|
document.owner_map=owner_map.clone();
|
||||||
|
document.revision_id=revision_id;
|
||||||
|
document.record(AppliedOperation{
|
||||||
|
base_revision_id:applied_base_revision_id,
|
||||||
|
revision_id,
|
||||||
|
client_id:collaboration_client_id.clone(),
|
||||||
|
update_id,
|
||||||
|
operation:transformed.clone(),
|
||||||
|
owner_replacements:owner_replacements.clone(),
|
||||||
|
});
|
||||||
|
let _=channel.send(RoomEvent::Document(NoteUpdate{
|
||||||
|
base_revision_id:applied_base_revision_id,
|
||||||
|
revision_id,
|
||||||
|
updated_at,
|
||||||
|
author:nickname.clone(),
|
||||||
|
client_id:collaboration_client_id.clone(),
|
||||||
|
update_id,
|
||||||
|
operation:transformed,
|
||||||
|
owner_replacements,
|
||||||
|
}));
|
||||||
|
drop(document);
|
||||||
|
}
|
||||||
|
Err(error)=>{
|
||||||
|
drop(document);
|
||||||
|
warn!(%error, pad_id = pad.id, "failed to save revision");
|
||||||
|
let _=send_pad_split(&mut sender,&PadServerMessage::Error{message:"Failed to save the document".into()}).await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(ClientMessage::Ping{nonce})=>{ let _=send_pad_split(&mut sender,&PadServerMessage::Pong{nonce}).await; },
|
Ok(ClientMessage::Ping{nonce})=>{ let _=send_pad_split(&mut sender,&PadServerMessage::Pong{nonce}).await; },
|
||||||
@@ -188,12 +515,68 @@ async fn handle_pad_socket(mut socket: WebSocket, state: SharedState, slug: Stri
|
|||||||
Some(Ok(_))=>{},
|
Some(Ok(_))=>{},
|
||||||
Some(Err(error))=>{debug!(%error,"pad websocket receive error");break;}
|
Some(Err(error))=>{debug!(%error,"pad websocket receive error");break;}
|
||||||
},
|
},
|
||||||
update=updates.recv()=>match update{
|
_=access_refresh.tick()=>{
|
||||||
Ok(RoomEvent::Document(u))=>if send_pad_split(&mut sender,&PadServerMessage::Document{content:u.content,revision_id:u.revision_id,updated_at:u.updated_at,author:u.author,owner_map:u.owner_map}).await.is_err(){break;},
|
let (read_allowed, _) = current_resource_access(
|
||||||
Ok(RoomEvent::Presence(users))=>if send_pad_split(&mut sender,&PadServerMessage::Presence{users}).await.is_err(){break;},
|
&state,
|
||||||
Ok(RoomEvent::Chat{sender:chat_sender,text})=>if send_pad_split(&mut sender,&PadServerMessage::Chat{sender:chat_sender,text}).await.is_err(){break;},
|
"pad",
|
||||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_))=>if let Ok(Some(current))=db::find_pad(&state.db,&slug).await { if send_pad_split(&mut sender,&PadServerMessage::Document{content:current.content,revision_id:0,updated_at:current.updated_at,author:None,owner_map:current.owner_map}).await.is_err(){break;} },
|
&slug,
|
||||||
Err(tokio::sync::broadcast::error::RecvError::Closed)=>break,
|
&external_tokens,
|
||||||
|
session_token.as_deref(),
|
||||||
|
password_ok,
|
||||||
|
).await;
|
||||||
|
if !read_allowed {
|
||||||
|
let _=send_pad_split(&mut sender,&PadServerMessage::Error{message:"Access expired or revoked".into()}).await;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
update=updates.recv()=>{
|
||||||
|
if let Ok(RoomEvent::PasswordRequired { except_client_id }) = &update {
|
||||||
|
if password_event_excludes_connection(
|
||||||
|
except_client_id.as_deref(),
|
||||||
|
&collaboration_client_id,
|
||||||
|
) {
|
||||||
|
let _=send_pad_split(&mut sender,&PadServerMessage::PasswordChanged).await;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let (read_allowed, _) = current_resource_access(
|
||||||
|
&state,
|
||||||
|
"pad",
|
||||||
|
&slug,
|
||||||
|
&external_tokens,
|
||||||
|
session_token.as_deref(),
|
||||||
|
false,
|
||||||
|
).await;
|
||||||
|
if !read_allowed {
|
||||||
|
let _=send_pad_split(&mut sender,&PadServerMessage::PasswordRequired).await;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (read_allowed, _) = current_resource_access(
|
||||||
|
&state,
|
||||||
|
"pad",
|
||||||
|
&slug,
|
||||||
|
&external_tokens,
|
||||||
|
session_token.as_deref(),
|
||||||
|
password_ok,
|
||||||
|
).await;
|
||||||
|
if !read_allowed {
|
||||||
|
let _=send_pad_split(&mut sender,&PadServerMessage::Error{message:"Access expired or revoked".into()}).await;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
match update {
|
||||||
|
Ok(RoomEvent::Document(u))=>if send_pad_split(&mut sender,&PadServerMessage::Document{base_revision_id:u.base_revision_id,revision_id:u.revision_id,updated_at:u.updated_at,author:u.author,client_id:u.client_id,update_id:u.update_id,operation:u.operation,owner_replacements:u.owner_replacements}).await.is_err(){break;},
|
||||||
|
Ok(RoomEvent::Presence(users))=>if send_pad_split(&mut sender,&PadServerMessage::Presence{users}).await.is_err(){break;},
|
||||||
|
Ok(RoomEvent::Chat{sender:chat_sender,text})=>if send_pad_split(&mut sender,&PadServerMessage::Chat{sender:chat_sender,text}).await.is_err(){break;},
|
||||||
|
Ok(RoomEvent::PasswordRequired { .. })=>{},
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(_))=>{
|
||||||
|
let document=collaborative_document.lock().await;
|
||||||
|
let snapshot=(document.content.clone(),document.revision_id,document.owner_map.clone(),document.acknowledged_updates(&collaboration_client_id));
|
||||||
|
drop(document);
|
||||||
|
if send_pad_split(&mut sender,&PadServerMessage::Resync{content:snapshot.0,revision_id:snapshot.1,owner_map:snapshot.2,acknowledged_update_ids:snapshot.3}).await.is_err(){break;}
|
||||||
|
},
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Closed)=>break,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2331
-441
File diff suppressed because it is too large
Load Diff
+168
-40
@@ -4,8 +4,9 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<meta name="color-scheme" content="dark">
|
<meta name="color-scheme" content="dark light">
|
||||||
<title>__DOCUMENT_TITLE__ · __PARENT_TITLE__</title>
|
<title>__DOCUMENT_TITLE__ · __PARENT_TITLE__</title>
|
||||||
|
__APP_THEME_BOOTSTRAP__
|
||||||
__APP_STYLESHEET__
|
__APP_STYLESHEET__
|
||||||
__APP_IMPORT_MAP__
|
__APP_IMPORT_MAP__
|
||||||
__APP_ENTRYPOINT__
|
__APP_ENTRYPOINT__
|
||||||
@@ -13,8 +14,12 @@
|
|||||||
|
|
||||||
<body class="pad-page" data-resource-kind="__RESOURCE_KIND__" data-registration-enabled="__REGISTRATION_ENABLED__">
|
<body class="pad-page" data-resource-kind="__RESOURCE_KIND__" data-registration-enabled="__REGISTRATION_ENABLED__">
|
||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
<div class="app-header__main"><a id="resource-parent-link" class="brand __PARENT_CLASS__"
|
<div class="app-header__main">
|
||||||
href="__PARENT_URL__">__PARENT_TITLE__</a><span class="header-divider"></span>
|
<div class="resource-brand" aria-label="__RESOURCE_ARIA_LABEL__">
|
||||||
|
<a class="resource-brand__logo home-brand" href="/" aria-label="RustPad home">RustPad</a>
|
||||||
|
<nav class="resource-brand__trail" aria-label="Current location">__RESOURCE_BREADCRUMB__</nav>
|
||||||
|
</div>
|
||||||
|
<span class="header-divider"></span>
|
||||||
<div id="document-link-copy" class="document-heading document-heading--copy" role="button" tabindex="0"
|
<div id="document-link-copy" class="document-heading document-heading--copy" role="button" tabindex="0"
|
||||||
title="Copy this link" aria-label="Copy this link">
|
title="Copy this link" aria-label="Copy this link">
|
||||||
<h1 id="document-title">__DOCUMENT_TITLE__</h1>
|
<h1 id="document-title">__DOCUMENT_TITLE__</h1>
|
||||||
@@ -33,17 +38,36 @@
|
|||||||
<span aria-hidden="true"></span><span aria-hidden="true"></span><span aria-hidden="true"></span>
|
<span aria-hidden="true"></span><span aria-hidden="true"></span><span aria-hidden="true"></span>
|
||||||
</button>
|
</button>
|
||||||
<div id="header-actions" class="header-actions"><button id="copy-link" class="secondary-button">Copy
|
<div id="header-actions" class="header-actions"><button id="copy-link" class="secondary-button">Copy
|
||||||
link</button><button id="publish-page" class="secondary-button">Page</button>
|
link</button>
|
||||||
<details class="page-settings">
|
<details class="page-settings">
|
||||||
<summary class="secondary-button">Page settings</summary>
|
<summary class="secondary-button" aria-label="Page options"><span class="page-settings__label"><span
|
||||||
<div class="page-settings-menu"><label class="public-task-toggle"
|
class="page-settings__status" aria-hidden="true"></span>Page</span><span
|
||||||
|
class="page-settings__chevron" aria-hidden="true">▾</span></summary>
|
||||||
|
<div class="page-settings-menu">
|
||||||
|
<p id="page-password-requirement" class="page-password-requirement" hidden>Access to page
|
||||||
|
options requires a password-protected note.</p>
|
||||||
|
<form id="set-page-password-form" class="page-password-inline" hidden>
|
||||||
|
<div class="page-password-inline__heading">
|
||||||
|
<label id="set-page-password-label" for="set-page-password">Set password</label>
|
||||||
|
<small id="set-page-password-help">Minimum 8 characters.</small>
|
||||||
|
</div>
|
||||||
|
<div class="page-password-inline__controls"><input id="set-page-password" type="password"
|
||||||
|
minlength="8" maxlength="128" autocomplete="new-password" placeholder="Min. 8 chars"
|
||||||
|
aria-describedby="set-page-password-help" required><button type="submit"
|
||||||
|
class="page-password-inline__save">Set</button></div>
|
||||||
|
<small id="set-page-password-error" class="error" role="alert" aria-live="polite"></small>
|
||||||
|
</form>
|
||||||
|
<button id="publish-page" class="page-settings-action" type="button"><span>Open
|
||||||
|
page</span><small>Copy its link and open it in a new tab</small></button>
|
||||||
|
<div class="page-settings-divider" role="separator"></div><label class="public-task-toggle"
|
||||||
title="Enable or disable the published page"><input id="public-page-enabled"
|
title="Enable or disable the published page"><input id="public-page-enabled"
|
||||||
type="checkbox"> Enable Page</label><label class="public-task-toggle"
|
type="checkbox"> Enable Page</label><label class="public-task-toggle"
|
||||||
title="Allow visitors to update task checkboxes on the published page"><input
|
title="Allow visitors to update task checkboxes on the published page"><input
|
||||||
id="public-task-updates" type="checkbox"> Editable tasks</label><label
|
id="public-task-updates" type="checkbox"> Editable tasks</label><label
|
||||||
class="public-task-toggle"
|
class="public-task-toggle"
|
||||||
title="Allow the published page to open without the resource password or private access"><input
|
title="Allow the published page to open without the resource password or private access"><input
|
||||||
id="unprotect-public-page" type="checkbox"> Unprotect Page</label></div>
|
id="unprotect-public-page" type="checkbox"> Unprotect Page</label>
|
||||||
|
</div>
|
||||||
</details><button id="files-button" class="secondary-button">Files</button><button id="delete-note"
|
</details><button id="files-button" class="secondary-button">Files</button><button id="delete-note"
|
||||||
class="secondary-button danger-button" hidden>Delete</button><button id="history-button"
|
class="secondary-button danger-button" hidden>Delete</button><button id="history-button"
|
||||||
class="secondary-button">History</button>
|
class="secondary-button">History</button>
|
||||||
@@ -92,6 +116,8 @@
|
|||||||
data-format="horizontal-rule">Horizontal rule</button></div>
|
data-format="horizontal-rule">Horizontal rule</button></div>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
<button id="mobile-upload-button" class="mobile-upload-button" type="button"
|
||||||
|
title="Upload a file"><span aria-hidden="true"></span> Upload</button>
|
||||||
<div class="editor-controls"><label>Font<select id="font-family">
|
<div class="editor-controls"><label>Font<select id="font-family">
|
||||||
<option value="mono">Mono</option>
|
<option value="mono">Mono</option>
|
||||||
<option value="system">System</option>
|
<option value="system">System</option>
|
||||||
@@ -105,18 +131,35 @@
|
|||||||
<option value="20">20</option>
|
<option value="20">20</option>
|
||||||
<option value="22">22</option>
|
<option value="22">22</option>
|
||||||
</select></label></div><button id="upload-button"
|
</select></label></div><button id="upload-button"
|
||||||
class="toolbar-action">Image/file</button><input id="file-input" type="file" hidden><label
|
class="toolbar-action" title="Upload a file">Upload</button><input id="file-input" type="file" hidden><label
|
||||||
class="line-toggle"><input id="line-numbers-toggle" type="checkbox" checked> Editor
|
class="line-toggle"><input id="line-numbers-toggle" type="checkbox" checked> Editor
|
||||||
lines</label><label class="line-toggle"><input id="preview-line-numbers-toggle" type="checkbox">
|
lines</label><label class="line-toggle"><input id="preview-line-numbers-toggle" type="checkbox">
|
||||||
Preview lines</label><label class="line-toggle"><input id="compact-toggle" type="checkbox" checked>
|
Preview lines</label><label class="line-toggle"><input id="compact-toggle" type="checkbox" checked>
|
||||||
Compact</label><label class="line-toggle"><input id="line-links-toggle" type="checkbox">
|
Compact</label><label class="line-toggle"><input id="line-links-toggle" type="checkbox">
|
||||||
Line links</label>
|
Line links</label>
|
||||||
<div class="toolbar-fill"></div><button id="mode-toggle" class="markdown-toggle active"
|
<div class="toolbar-fill"></div><button id="mode-toggle" class="toolbar-action active"
|
||||||
aria-pressed="true">Markdown</button>
|
aria-pressed="true" aria-label="Markdown" title="Markdown"><span
|
||||||
<div class="view-switch"><button data-view="edit">Edit</button><button data-view="split"
|
class="control-label-full">Markdown</span><span class="control-label-short"
|
||||||
class="active">Split</button><button data-view="preview">Preview</button></div>
|
aria-hidden="true">M</span></button>
|
||||||
|
<button id="toolbar-collapse-toggle" class="toolbar-collapse-toggle" type="button"
|
||||||
|
aria-pressed="false" aria-label="Hide editor toolbar" title="Hide editor toolbar"><span
|
||||||
|
class="toolbar-collapse-toggle__icon" aria-hidden="true">⌃</span></button>
|
||||||
|
<div class="view-switch" aria-label="Editor view"><button data-view="edit" class="active"
|
||||||
|
aria-label="Edit" title="Edit"><span class="control-label-full">Edit</span><span
|
||||||
|
class="control-label-short" aria-hidden="true">E</span></button><button data-view="split"
|
||||||
|
aria-label="Split" title="Split"><span class="control-label-full">Split</span><span
|
||||||
|
class="control-label-short" aria-hidden="true">S</span></button><button data-view="preview"
|
||||||
|
aria-label="Preview" title="Preview"><span class="control-label-full">Preview</span><span
|
||||||
|
class="control-label-short" aria-hidden="true">P</span></button></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="editor-workspace" class="workspace view-split">
|
<div id="connection-notice" class="connection-notice" role="status" aria-live="polite" hidden>
|
||||||
|
<span class="connection-notice__signal"
|
||||||
|
aria-hidden="true"><span></span><span></span><span></span></span>
|
||||||
|
<span class="connection-notice__content"><strong id="connection-notice-title">Connection
|
||||||
|
interrupted</strong><span id="connection-notice-message">Trying to reconnect
|
||||||
|
automatically.</span></span>
|
||||||
|
</div>
|
||||||
|
<div id="editor-workspace" class="workspace view-edit">
|
||||||
<div class="editor-column">
|
<div class="editor-column">
|
||||||
<div class="column-label editor-column-label"><span>Editor</span>
|
<div class="column-label editor-column-label"><span>Editor</span>
|
||||||
<div class="authorship-controls"><label class="switch-control authorship-colors-switch"
|
<div class="authorship-controls"><label class="switch-control authorship-colors-switch"
|
||||||
@@ -125,16 +168,17 @@
|
|||||||
aria-hidden="true"></span><span id="authorship-colors-label">Colors
|
aria-hidden="true"></span><span id="authorship-colors-label">Colors
|
||||||
on</span></label>
|
on</span></label>
|
||||||
<div class="authorship-mode-control" role="group" aria-label="Authorship display"><button
|
<div class="authorship-mode-control" role="group" aria-label="Authorship display"><button
|
||||||
type="button" data-authorship-mode="simple" class="active" disabled>Simple</button><button
|
type="button" data-authorship-mode="simple" class="active"
|
||||||
type="button" data-authorship-mode="full" disabled>Full</button></div>
|
disabled>Simple</button><button type="button" data-authorship-mode="full"
|
||||||
|
disabled>Full</button></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="participant-badges" class="participant-badges" aria-label="Participants"></div>
|
<div id="participant-badges" class="participant-badges" aria-label="Participants" hidden></div>
|
||||||
<div class="editor-shell">
|
<div class="editor-shell">
|
||||||
<div id="line-gutter" class="line-gutter" aria-hidden="true"></div>
|
<div id="line-gutter" class="line-gutter" aria-hidden="true"></div>
|
||||||
<div id="authorship-layer" class="authorship-layer" aria-hidden="true"></div>
|
<div id="authorship-layer" class="authorship-layer" aria-hidden="true"></div>
|
||||||
<div id="owner-labels" class="owner-labels" aria-hidden="true"></div><textarea id="editor"
|
<div id="owner-labels" class="owner-labels" aria-hidden="true"></div><textarea id="editor"
|
||||||
wrap="off" placeholder="Start writing…" spellcheck="false"></textarea>
|
wrap="off" placeholder="Start writing…" spellcheck="false" readonly></textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="preview-column">
|
<div class="preview-column">
|
||||||
@@ -144,10 +188,48 @@
|
|||||||
</div>
|
</div>
|
||||||
<footer class="editor-footer">
|
<footer class="editor-footer">
|
||||||
<div class="footer-left"><span id="characters">0 characters</span> · <span id="words">0
|
<div class="footer-left"><span id="characters">0 characters</span> · <span id="words">0
|
||||||
words</span><span class="footer-connection-block"> · <span class="footer-status status"><span
|
words</span>
|
||||||
id="status-dot" class="status__dot"></span><span
|
<div class="footer-connection-block"> · <details id="connection-details" class="connection-details">
|
||||||
id="status-text">Connecting…</span></span> · <span id="socket-latency"
|
<summary title="WebSocket connection diagnostics"><span class="footer-status status"><span
|
||||||
class="footer-socket-latency" title="WebSocket round-trip time">— ms</span></span> ·
|
id="status-dot" class="status__dot"></span><span
|
||||||
|
id="status-text">Connecting…</span></span><span id="socket-latency"
|
||||||
|
class="footer-socket-latency">— ms</span><span class="connection-details__chevron"
|
||||||
|
aria-hidden="true">▾</span></summary>
|
||||||
|
<div class="connection-diagnostics-popover">
|
||||||
|
<strong>Connection diagnostics</strong>
|
||||||
|
<dl class="connection-diagnostics-grid">
|
||||||
|
<div class="connection-diagnostics-grid__wide">
|
||||||
|
<dt>Quality</dt>
|
||||||
|
<dd data-connection-diagnostic="quality">Waiting</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Latency</dt>
|
||||||
|
<dd data-connection-diagnostic="latency">—</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Jitter</dt>
|
||||||
|
<dd data-connection-diagnostic="jitter">—</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Uptime</dt>
|
||||||
|
<dd data-connection-diagnostic="uptime">—</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Reconnects</dt>
|
||||||
|
<dd data-connection-diagnostic="reconnects">0</dd>
|
||||||
|
</div>
|
||||||
|
<div class="connection-diagnostics-grid__wide">
|
||||||
|
<dt>Client</dt>
|
||||||
|
<dd data-connection-diagnostic="client">—</dd>
|
||||||
|
</div>
|
||||||
|
<div class="connection-diagnostics-grid__wide">
|
||||||
|
<dt>Last event</dt>
|
||||||
|
<dd data-connection-diagnostic="last-event">—</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div> ·
|
||||||
<details id="room-details" class="room-details">
|
<details id="room-details" class="room-details">
|
||||||
<summary><span id="room-count">0 users</span><span id="chat-unread" class="chat-unread"
|
<summary><span id="room-count">0 users</span><span id="chat-unread" class="chat-unread"
|
||||||
hidden></span></summary>
|
hidden></span></summary>
|
||||||
@@ -191,9 +273,11 @@
|
|||||||
</div><button id="close-shortcuts" class="icon-button" type="button">×</button>
|
</div><button id="close-shortcuts" class="icon-button" type="button">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="shortcut-grid">
|
<div class="shortcut-grid">
|
||||||
<kbd>Ctrl/Cmd+Z</kbd><span>Undo</span><kbd>Ctrl/Cmd+B</kbd><span>Bold</span><kbd>Ctrl/Cmd+I</kbd><span>Italic</span><kbd>Ctrl/Cmd+Shift+X</kbd><span>Strikethrough</span><kbd>Ctrl/Cmd+K</kbd><span>Link</span><kbd>Ctrl/Cmd+Shift+7</kbd><span>Numbered
|
<kbd>Ctrl/Cmd+Z</kbd><span>Undo last
|
||||||
|
change</span><kbd>Ctrl/Cmd+Shift+Z</kbd><span>Redo</span><kbd>Ctrl/Cmd+B</kbd><span>Bold</span><kbd>Ctrl/Cmd+I</kbd><span>Italic</span><kbd>Ctrl/Cmd+Shift+X</kbd><span>Strikethrough</span><kbd>Ctrl/Cmd+K</kbd><span>Link</span><kbd>Ctrl/Cmd+Shift+7</kbd><span>Numbered
|
||||||
list</span><kbd>Ctrl/Cmd+Shift+8</kbd><span>Bullet list</span><kbd>Ctrl/Cmd+Shift+9</kbd><span>Task
|
list</span><kbd>Ctrl/Cmd+Shift+8</kbd><span>Bullet list</span><kbd>Ctrl/Cmd+Shift+9</kbd><span>Task
|
||||||
list</span><kbd>Alt+1…4</kbd><span>Headings H1–H4</span>__EXTRA_SHORTCUTS__
|
list</span><kbd>Alt+1…4</kbd><span>Headings H1–H4</span><kbd>Tab</kbd><span>Indent by 2
|
||||||
|
spaces</span><kbd>Shift+Tab</kbd><span>Remove indentation</span>__EXTRA_SHORTCUTS__
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
@@ -202,8 +286,13 @@
|
|||||||
<div class="files-head">
|
<div class="files-head">
|
||||||
<div>
|
<div>
|
||||||
<h2>Note files</h2>
|
<h2>Note files</h2>
|
||||||
<p>Copy a direct link or ready Markdown/HTML code.</p>
|
<p>Copy a direct link or ready Markdown/Alias code.</p>
|
||||||
</div><button id="close-files" class="icon-button" type="button">×</button>
|
</div>
|
||||||
|
<div class="files-head-actions">
|
||||||
|
<button id="files-upload-button" class="action-button action-button--primary compact-button"
|
||||||
|
type="button">Upload file</button>
|
||||||
|
<button id="close-files" class="icon-button" type="button" aria-label="Close files">×</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="files-list" class="files-list"></div>
|
<div id="files-list" class="files-list"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -218,12 +307,12 @@
|
|||||||
guest</button><button id="show-register" class="text-button" type="button">Register</button><button
|
guest</button><button id="show-register" class="text-button" type="button">Register</button><button
|
||||||
id="show-login" class="text-button" type="button">Log in</button></div>
|
id="show-login" class="text-button" type="button">Log in</button></div>
|
||||||
<section id="auth-panel" class="auth-panel" hidden>
|
<section id="auth-panel" class="auth-panel" hidden>
|
||||||
<h3 id="auth-mode-title">Log in</h3><label id="auth-email-field">E-mail / organization login<input
|
<h3 id="auth-mode-title">Log in</h3><label id="auth-email-field"><span
|
||||||
id="auth-email" name="username" type="email" maxlength="320" autocomplete="username"
|
id="auth-email-label">E-mail</span><input id="auth-email" name="username" type="email"
|
||||||
placeholder="you@example.com"></label><label>Password<input
|
maxlength="320" autocomplete="username"
|
||||||
id="auth-password" name="password" type="password" minlength="8" maxlength="128"
|
placeholder="you@example.com"></label><label>Password<input id="auth-password" name="password"
|
||||||
autocomplete="current-password"></label><button id="auth-submit" class="primary-button"
|
type="password" minlength="8" maxlength="128" autocomplete="current-password"></label><button
|
||||||
type="submit">Log in and continue</button>
|
id="auth-submit" class="primary-button" type="submit">Log in and continue</button>
|
||||||
<div class="identity-links"><button id="show-reset" class="text-button" type="button">Forgot
|
<div class="identity-links"><button id="show-reset" class="text-button" type="button">Forgot
|
||||||
password?</button><button id="auth-back" class="text-button" type="button">Back to
|
password?</button><button id="auth-back" class="text-button" type="button">Back to
|
||||||
nickname</button><button id="logout-account" class="text-button" type="button">Log out saved
|
nickname</button><button id="logout-account" class="text-button" type="button">Log out saved
|
||||||
@@ -234,8 +323,8 @@
|
|||||||
</dialog>
|
</dialog>
|
||||||
<dialog id="password-dialog">
|
<dialog id="password-dialog">
|
||||||
<form id="password-form" class="dialog-panel">
|
<form id="password-form" class="dialog-panel">
|
||||||
<h2>Protected __PROTECTED_RESOURCE_LABEL__</h2><input id="open-password" type="password" autocomplete="current-password" required
|
<h2>Protected __PROTECTED_RESOURCE_LABEL__</h2><input id="open-password" type="password"
|
||||||
placeholder="Password">
|
autocomplete="current-password" required placeholder="Password">
|
||||||
<p id="password-error" class="form-message error"></p><button class="primary-button">Open</button><a
|
<p id="password-error" class="form-message error"></p><button class="primary-button">Open</button><a
|
||||||
class="dialog-link" href="/">Cancel</a>
|
class="dialog-link" href="/">Cancel</a>
|
||||||
</form>
|
</form>
|
||||||
@@ -261,10 +350,14 @@
|
|||||||
<option value="20">20</option>
|
<option value="20">20</option>
|
||||||
<option value="22">22</option>
|
<option value="22">22</option>
|
||||||
</select></label>
|
</select></label>
|
||||||
<label class="mobile-option-check"><input id="mobile-line-numbers-toggle" type="checkbox"> Editor lines</label>
|
<label class="public-task-toggle mobile-option-check"><input id="mobile-line-numbers-toggle"
|
||||||
<label class="mobile-option-check"><input id="mobile-preview-line-numbers-toggle" type="checkbox"> Preview lines</label>
|
type="checkbox"> Editor lines</label>
|
||||||
<label class="mobile-option-check"><input id="mobile-compact-toggle" type="checkbox"> Compact view</label>
|
<label class="public-task-toggle mobile-option-check"><input id="mobile-preview-line-numbers-toggle"
|
||||||
<label class="mobile-option-check"><input id="mobile-line-links-toggle" type="checkbox"> Line links</label>
|
type="checkbox"> Preview lines</label>
|
||||||
|
<label class="public-task-toggle mobile-option-check"><input id="mobile-compact-toggle" type="checkbox">
|
||||||
|
Compact view</label>
|
||||||
|
<label class="public-task-toggle mobile-option-check"><input id="mobile-line-links-toggle"
|
||||||
|
type="checkbox"> Line links</label>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
<label id="mobile-color-button" class="mobile-color-button" title="Editor color"
|
<label id="mobile-color-button" class="mobile-color-button" title="Editor color"
|
||||||
@@ -272,9 +365,44 @@
|
|||||||
id="mobile-color-picker" type="color" aria-label="Change editor color"></label>
|
id="mobile-color-picker" type="color" aria-label="Change editor color"></label>
|
||||||
<button id="mobile-chat-button" class="mobile-chat-button" type="button" title="Chat"
|
<button id="mobile-chat-button" class="mobile-chat-button" type="button" title="Chat"
|
||||||
aria-label="Open chat">💬<span id="mobile-chat-unread" class="mobile-chat-unread" hidden></span></button>
|
aria-label="Open chat">💬<span id="mobile-chat-unread" class="mobile-chat-unread" hidden></span></button>
|
||||||
<span id="mobile-connection-status" class="mobile-connection-status" title="WebSocket status"><span
|
<details id="mobile-connection-details" class="mobile-connection-details">
|
||||||
id="mobile-status-dot" class="status__dot"></span><span
|
<summary id="mobile-connection-status" class="mobile-connection-status"
|
||||||
id="mobile-status-text">Connecting…</span></span>
|
title="WebSocket connection diagnostics"><span id="mobile-status-dot" class="status__dot"></span><span
|
||||||
|
id="mobile-status-text">Connecting…</span><span id="mobile-socket-latency">—</span></summary>
|
||||||
|
<div class="connection-diagnostics-popover mobile-connection-diagnostics-popover">
|
||||||
|
<strong>Connection diagnostics</strong>
|
||||||
|
<dl class="connection-diagnostics-grid">
|
||||||
|
<div class="connection-diagnostics-grid__wide">
|
||||||
|
<dt>Quality</dt>
|
||||||
|
<dd data-connection-diagnostic="quality">Waiting</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Latency</dt>
|
||||||
|
<dd data-connection-diagnostic="latency">—</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Jitter</dt>
|
||||||
|
<dd data-connection-diagnostic="jitter">—</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Uptime</dt>
|
||||||
|
<dd data-connection-diagnostic="uptime">—</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Reconnects</dt>
|
||||||
|
<dd data-connection-diagnostic="reconnects">0</dd>
|
||||||
|
</div>
|
||||||
|
<div class="connection-diagnostics-grid__wide">
|
||||||
|
<dt>Client</dt>
|
||||||
|
<dd data-connection-diagnostic="client">—</dd>
|
||||||
|
</div>
|
||||||
|
<div class="connection-diagnostics-grid__wide">
|
||||||
|
<dt>Last event</dt>
|
||||||
|
<dd data-connection-diagnostic="last-event">—</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
<div id="toast" class="toast"></div>
|
<div id="toast" class="toast"></div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+2
-1
@@ -4,9 +4,10 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<meta name="color-scheme" content="dark">
|
<meta name="color-scheme" content="dark light">
|
||||||
<meta name="robots" content="noindex">
|
<meta name="robots" content="noindex">
|
||||||
<title>__ERROR_TITLE__ · RustPad</title>
|
<title>__ERROR_TITLE__ · RustPad</title>
|
||||||
|
__APP_THEME_BOOTSTRAP__
|
||||||
__APP_STYLESHEET__
|
__APP_STYLESHEET__
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
|||||||
+36
-11
@@ -4,8 +4,9 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<meta name="color-scheme" content="dark">
|
<meta name="color-scheme" content="dark light">
|
||||||
<title>RustPad</title>
|
<title>RustPad</title>
|
||||||
|
__APP_THEME_BOOTSTRAP__
|
||||||
__APP_STYLESHEET__
|
__APP_STYLESHEET__
|
||||||
__APP_IMPORT_MAP__
|
__APP_IMPORT_MAP__
|
||||||
__APP_ENTRYPOINT__
|
__APP_ENTRYPOINT__
|
||||||
@@ -29,8 +30,8 @@
|
|||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="pad-name">Note name</label>
|
<label for="pad-name">Note name</label>
|
||||||
<input id="pad-name" maxlength="80" required autocomplete="off" placeholder="Meeting notes">
|
<input id="pad-name" maxlength="80" required autocomplete="off" placeholder="Meeting notes">
|
||||||
<div class="field-meta"><span id="pad-slug-preview">/p/meeting-notes</span><span
|
<div class="field-meta"><span id="pad-slug-preview">/p/note</span><span id="pad-name-count">0/80</span>
|
||||||
id="pad-name-count">0/80</span></div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<div class="label-row"><label for="pad-password">Password</label><span>optional, min. 8 characters</span>
|
<div class="label-row"><label for="pad-password">Password</label><span>optional, min. 8 characters</span>
|
||||||
@@ -53,7 +54,7 @@
|
|||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="workspace-name">Workspace name</label>
|
<label for="workspace-name">Workspace name</label>
|
||||||
<input id="workspace-name" maxlength="80" required autocomplete="off" placeholder="My project">
|
<input id="workspace-name" maxlength="80" required autocomplete="off" placeholder="My project">
|
||||||
<div class="field-meta"><span id="workspace-slug-preview">/w/my-project</span><span
|
<div class="field-meta"><span id="workspace-slug-preview">/w/workspace</span><span
|
||||||
id="workspace-name-count">0/80</span></div>
|
id="workspace-name-count">0/80</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
@@ -73,9 +74,8 @@
|
|||||||
<footer class="home-footer">
|
<footer class="home-footer">
|
||||||
<div class="home-footer__inner">
|
<div class="home-footer__inner">
|
||||||
<div id="footer-account-guest" class="home-footer__account">
|
<div id="footer-account-guest" class="home-footer__account">
|
||||||
<button id="footer-login" class="footer-action" type="button">Log in</button>
|
<button id="footer-login" class="footer-action footer-action--primary" type="button">Log in</button>
|
||||||
<button id="footer-register" class="footer-action footer-action--primary" type="button">Register
|
<button id="footer-register" class="footer-action footer-action--primary" type="button">Register</button>
|
||||||
nickname</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div id="footer-account-user" class="home-footer__account" hidden>
|
<div id="footer-account-user" class="home-footer__account" hidden>
|
||||||
<span id="footer-user-label" class="home-footer__user">
|
<span id="footer-user-label" class="home-footer__user">
|
||||||
@@ -101,8 +101,8 @@
|
|||||||
<div class="identity-fields">
|
<div class="identity-fields">
|
||||||
<label>Nickname<input id="nickname" name="nickname" maxlength="40" autocomplete="off" data-bwignore="true"
|
<label>Nickname<input id="nickname" name="nickname" maxlength="40" autocomplete="off" data-bwignore="true"
|
||||||
placeholder="Your nickname"></label>
|
placeholder="Your nickname"></label>
|
||||||
<label id="auth-email-field">E-mail / LDAP Username<input id="auth-email" name="username" type="email"
|
<label id="auth-email-field"><span id="auth-email-label">E-mail</span><input id="auth-email" name="username"
|
||||||
maxlength="320" autocomplete="username" required placeholder="you@example.com or name.second-name"></label>
|
type="email" maxlength="320" autocomplete="username" required placeholder="you@example.com"></label>
|
||||||
<label>Password<input id="auth-password" name="password" type="password" minlength="8" maxlength="128"
|
<label>Password<input id="auth-password" name="password" type="password" minlength="8" maxlength="128"
|
||||||
autocomplete="current-password" required placeholder="At least 8 characters"></label>
|
autocomplete="current-password" required placeholder="At least 8 characters"></label>
|
||||||
</div>
|
</div>
|
||||||
@@ -125,7 +125,17 @@
|
|||||||
adds link-based protection. Private items are visible only to their owner and explicitly shared accounts or
|
adds link-based protection. Private items are visible only to their owner and explicitly shared accounts or
|
||||||
valid share links. Unauthorized visitors receive a not-found response.</p>
|
valid share links. Unauthorized visitors receive a not-found response.</p>
|
||||||
</header>
|
</header>
|
||||||
|
<div class="list-controls resources-controls">
|
||||||
|
<label class="list-search"><span class="sr-only">Search notes and workspaces</span><input id="resources-search"
|
||||||
|
type="search" placeholder="Search notes and workspaces…" autocomplete="off"></label>
|
||||||
|
<label class="page-size-label">Per page<select id="resources-per-page">
|
||||||
|
<option value="25">25</option>
|
||||||
|
<option value="50">50</option>
|
||||||
|
<option value="100">100</option>
|
||||||
|
</select></label>
|
||||||
|
</div>
|
||||||
<div id="resources-list" class="resources-list"></div>
|
<div id="resources-list" class="resources-list"></div>
|
||||||
|
<nav id="resources-pagination" class="pagination" aria-label="Resources pagination"></nav>
|
||||||
<p id="resources-error" class="form-message error" role="alert"></p>
|
<p id="resources-error" class="form-message error" role="alert"></p>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
@@ -144,6 +154,19 @@
|
|||||||
<label>Nickname<input id="profile-nickname" maxlength="40" required></label>
|
<label>Nickname<input id="profile-nickname" maxlength="40" required></label>
|
||||||
<label class="profile-color-field">Editor color<input id="profile-color" type="color"
|
<label class="profile-color-field">Editor color<input id="profile-color" type="color"
|
||||||
aria-label="Choose your editor color"></label>
|
aria-label="Choose your editor color"></label>
|
||||||
|
<fieldset class="profile-theme-field">
|
||||||
|
<legend>Interface theme</legend>
|
||||||
|
<div class="theme-options">
|
||||||
|
<label class="theme-option">
|
||||||
|
<input type="radio" name="profile-theme" value="dark" checked>
|
||||||
|
<span><strong>Dark</strong><small>Current RustPad appearance</small></span>
|
||||||
|
</label>
|
||||||
|
<label class="theme-option">
|
||||||
|
<input type="radio" name="profile-theme" value="light">
|
||||||
|
<span><strong>Light</strong><small>Warm cream surfaces and dark text</small></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
<label data-local-profile-field>Current e-mail<input id="profile-current-email" type="email" readonly></label>
|
<label data-local-profile-field>Current e-mail<input id="profile-current-email" type="email" readonly></label>
|
||||||
<label data-local-profile-field>New e-mail<input id="profile-email" type="email" maxlength="320"
|
<label data-local-profile-field>New e-mail<input id="profile-email" type="email" maxlength="320"
|
||||||
placeholder="Leave empty to keep current"></label>
|
placeholder="Leave empty to keep current"></label>
|
||||||
@@ -152,8 +175,10 @@
|
|||||||
<label data-local-profile-field>Current password<input id="profile-password" type="password" minlength="8"
|
<label data-local-profile-field>Current password<input id="profile-password" type="password" minlength="8"
|
||||||
maxlength="128" placeholder="Required for e-mail or password changes"></label>
|
maxlength="128" placeholder="Required for e-mail or password changes"></label>
|
||||||
</div>
|
</div>
|
||||||
<button class="primary-button" type="submit">Save profile</button>
|
<div class="profile-actions">
|
||||||
<button id="profile-delete" data-local-profile-field class="danger-button" type="button">Delete account</button>
|
<button class="primary-button" type="submit">Save profile</button>
|
||||||
|
<button id="profile-delete" data-local-profile-field class="danger-button" type="button">Delete account</button>
|
||||||
|
</div>
|
||||||
<p id="profile-message" class="form-message" role="status"></p>
|
<p id="profile-message" class="form-message" role="status"></p>
|
||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,12 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="g" x1="10" y1="8" x2="54" y2="58" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#f0aa74"/>
|
||||||
|
<stop offset=".55" stop-color="#cf633d"/>
|
||||||
|
<stop offset="1" stop-color="#8f3827"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect x="4" y="4" width="56" height="56" rx="15" fill="#11141a"/>
|
||||||
|
<path d="M19 16h17.5C44 16 49 20.4 49 27c0 4.7-2.6 8.2-7 9.9L51 48H39.5l-7.3-9.5H29V48H19V16Zm10 8v7h6.5c2.3 0 3.5-1.2 3.5-3.5S37.8 24 35.5 24H29Z" fill="url(#g)"/>
|
||||||
|
<rect x="4.75" y="4.75" width="54.5" height="54.5" rx="14.25" fill="none" stroke="#ffffff" stroke-opacity=".12" stroke-width="1.5"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 698 B |
+168
-125
@@ -26,6 +26,37 @@ const DEFAULT_ERRORS = {
|
|||||||
504: "The server took too long to respond. Try again.",
|
504: "The server took too long to respond. Try again.",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
||||||
|
const CSRF_REFRESH_MS = 20 * 60 * 1000;
|
||||||
|
let csrfTokenPromise = null;
|
||||||
|
let csrfTokenFetchedAt = 0;
|
||||||
|
|
||||||
|
async function csrfToken({ refresh = false } = {}) {
|
||||||
|
if (refresh || Date.now() - csrfTokenFetchedAt >= CSRF_REFRESH_MS) {
|
||||||
|
csrfTokenPromise = null;
|
||||||
|
csrfTokenFetchedAt = 0;
|
||||||
|
}
|
||||||
|
if (!csrfTokenPromise) {
|
||||||
|
csrfTokenPromise = fetch("/api/security/csrf", {
|
||||||
|
credentials: "same-origin",
|
||||||
|
cache: "no-store",
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
}).then(async response => {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok || typeof data.token !== "string" || !data.token) {
|
||||||
|
throw requestError(response.status, data);
|
||||||
|
}
|
||||||
|
csrfTokenFetchedAt = Date.now();
|
||||||
|
return data.token;
|
||||||
|
}).catch(error => {
|
||||||
|
csrfTokenPromise = null;
|
||||||
|
csrfTokenFetchedAt = 0;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return csrfTokenPromise;
|
||||||
|
}
|
||||||
|
|
||||||
function formatBytes(bytes) {
|
function formatBytes(bytes) {
|
||||||
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(bytes % (1024 * 1024) ? 1 : 0)} MB`;
|
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(bytes % (1024 * 1024) ? 1 : 0)} MB`;
|
||||||
if (bytes >= 1024) return `${Math.ceil(bytes / 1024)} KB`;
|
if (bytes >= 1024) return `${Math.ceil(bytes / 1024)} KB`;
|
||||||
@@ -33,6 +64,7 @@ function formatBytes(bytes) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function clearExpiredSession() {
|
function clearExpiredSession() {
|
||||||
|
localStorage.removeItem("rustpad:auth-state");
|
||||||
localStorage.removeItem("rustpad:auth-token");
|
localStorage.removeItem("rustpad:auth-token");
|
||||||
sessionStorage.removeItem("rustpad:auth-token");
|
sessionStorage.removeItem("rustpad:auth-token");
|
||||||
localStorage.removeItem("rustpad:nickname");
|
localStorage.removeItem("rustpad:nickname");
|
||||||
@@ -42,11 +74,9 @@ function clearExpiredSession() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function clearSessionIfInvalid() {
|
async function clearSessionIfInvalid() {
|
||||||
const token = localStorage.getItem("rustpad:auth-token") || sessionStorage.getItem("rustpad:auth-token");
|
if (!localStorage.getItem("rustpad:auth-state")) return;
|
||||||
if (!token) return;
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/auth/me", {
|
const response = await fetch("/api/auth/me", {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
signal: AbortSignal.timeout(5000),
|
signal: AbortSignal.timeout(5000),
|
||||||
});
|
});
|
||||||
if (response.status === 401) clearExpiredSession();
|
if (response.status === 401) clearExpiredSession();
|
||||||
@@ -55,9 +85,9 @@ async function clearSessionIfInvalid() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateUploadSize(body) {
|
function validateUploadSize(body, configuredMaxBytes) {
|
||||||
if (!(body instanceof FormData)) return;
|
if (!(body instanceof FormData)) return;
|
||||||
const maxBytes = Number(window.__RUSTPAD_CONFIG__?.uploadMaxSizeBytes || 0);
|
const maxBytes = Number(configuredMaxBytes ?? window.__RUSTPAD_CONFIG__?.uploadMaxSizeBytes ?? 0);
|
||||||
if (!Number.isFinite(maxBytes) || maxBytes <= 0) return;
|
if (!Number.isFinite(maxBytes) || maxBytes <= 0) return;
|
||||||
for (const value of body.values()) {
|
for (const value of body.values()) {
|
||||||
if (value instanceof File && value.size > maxBytes) {
|
if (value instanceof File && value.size > maxBytes) {
|
||||||
@@ -68,11 +98,14 @@ function validateUploadSize(body) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function requestHeaders(options, body) {
|
async function requestHeaders(options, body) {
|
||||||
const headers = new Headers(options.headers || {});
|
const headers = new Headers(options.headers || {});
|
||||||
const authToken = localStorage.getItem("rustpad:auth-token") || sessionStorage.getItem("rustpad:auth-token");
|
headers.delete("x-rustpad-user-token");
|
||||||
if (authToken && !headers.has("authorization")) headers.set("authorization", `Bearer ${authToken}`);
|
|
||||||
if (!(body instanceof FormData) && !headers.has("content-type")) headers.set("content-type", "application/json");
|
if (!(body instanceof FormData) && !headers.has("content-type")) headers.set("content-type", "application/json");
|
||||||
|
const method = String(options.method || "GET").toUpperCase();
|
||||||
|
if (!SAFE_METHODS.has(method) && !headers.has("x-rustpad-csrf")) {
|
||||||
|
headers.set("x-rustpad-csrf", await csrfToken());
|
||||||
|
}
|
||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,14 +123,22 @@ function formDataFileSize(body) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function api(path, options = {}) {
|
export async function api(path, options = {}) {
|
||||||
validateUploadSize(options.body);
|
validateUploadSize(options.body, options.uploadMaxSizeBytes);
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), 12000);
|
const timeout = setTimeout(() => controller.abort(), 12000);
|
||||||
try {
|
try {
|
||||||
const headers = requestHeaders(options, options.body);
|
let headers = await requestHeaders(options, options.body);
|
||||||
const started = performance.now();
|
const started = performance.now();
|
||||||
logDebug("api.request", { method: options.method || "GET", path });
|
logDebug("api.request", { method: options.method || "GET", path });
|
||||||
const response = await fetch(path, { ...options, headers, signal: controller.signal });
|
let response = await fetch(path, { ...options, headers, credentials: "same-origin", signal: controller.signal });
|
||||||
|
if (response.status === 403 && !SAFE_METHODS.has(String(options.method || "GET").toUpperCase())) {
|
||||||
|
const preview = await response.clone().json().catch(() => ({}));
|
||||||
|
if (/security token/i.test(preview.error || "")) {
|
||||||
|
headers = new Headers(headers);
|
||||||
|
headers.set("x-rustpad-csrf", await csrfToken({ refresh: true }));
|
||||||
|
response = await fetch(path, { ...options, headers, credentials: "same-origin", signal: controller.signal });
|
||||||
|
}
|
||||||
|
}
|
||||||
const durationMs = Math.round(performance.now() - started);
|
const durationMs = Math.round(performance.now() - started);
|
||||||
logDebug("api.response", { method: options.method || "GET", path, status: response.status, durationMs });
|
logDebug("api.response", { method: options.method || "GET", path, status: response.status, durationMs });
|
||||||
const contentType = response.headers.get("content-type") || "";
|
const contentType = response.headers.get("content-type") || "";
|
||||||
@@ -125,126 +166,128 @@ export async function api(path, options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function uploadWithProgress(path, options = {}) {
|
export function uploadWithProgress(path, options = {}) {
|
||||||
validateUploadSize(options.body);
|
validateUploadSize(options.body, options.uploadMaxSizeBytes);
|
||||||
const method = options.method || "POST";
|
const method = options.method || "POST";
|
||||||
const fallbackTotal = formDataFileSize(options.body);
|
const fallbackTotal = formDataFileSize(options.body);
|
||||||
const stallTimeoutMs = Number(options.stallTimeoutMs) > 0 ? Number(options.stallTimeoutMs) : 90000;
|
const stallTimeoutMs = Number(options.stallTimeoutMs) > 0 ? Number(options.stallTimeoutMs) : 90000;
|
||||||
const responseTimeoutMs = Number(options.responseTimeoutMs) > 0 ? Number(options.responseTimeoutMs) : 120000;
|
const responseTimeoutMs = Number(options.responseTimeoutMs) > 0 ? Number(options.responseTimeoutMs) : 120000;
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return (async () => {
|
||||||
const xhr = new XMLHttpRequest();
|
const headers = await requestHeaders({ ...options, method }, options.body);
|
||||||
const headers = requestHeaders(options, options.body);
|
return new Promise((resolve, reject) => {
|
||||||
const started = performance.now();
|
const xhr = new XMLHttpRequest();
|
||||||
let lastAt = started;
|
const started = performance.now();
|
||||||
let lastLoaded = 0;
|
let lastAt = started;
|
||||||
let speed = 0;
|
let lastLoaded = 0;
|
||||||
let stallTimer = null;
|
let speed = 0;
|
||||||
let responseTimer = null;
|
let stallTimer = null;
|
||||||
let stalled = false;
|
let responseTimer = null;
|
||||||
let responseTimedOut = false;
|
let stalled = false;
|
||||||
let externallyAborted = false;
|
let responseTimedOut = false;
|
||||||
|
let externallyAborted = false;
|
||||||
|
|
||||||
const clearStallTimer = () => {
|
const clearStallTimer = () => {
|
||||||
clearTimeout(stallTimer);
|
clearTimeout(stallTimer);
|
||||||
stallTimer = null;
|
stallTimer = null;
|
||||||
};
|
};
|
||||||
const armStallTimer = () => {
|
const armStallTimer = () => {
|
||||||
clearStallTimer();
|
clearStallTimer();
|
||||||
stallTimer = setTimeout(() => {
|
stallTimer = setTimeout(() => {
|
||||||
stalled = true;
|
stalled = true;
|
||||||
xhr.abort();
|
xhr.abort();
|
||||||
}, stallTimeoutMs);
|
}, stallTimeoutMs);
|
||||||
};
|
};
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
clearStallTimer();
|
clearStallTimer();
|
||||||
clearTimeout(responseTimer);
|
clearTimeout(responseTimer);
|
||||||
responseTimer = null;
|
responseTimer = null;
|
||||||
options.signal?.removeEventListener("abort", abortFromSignal);
|
options.signal?.removeEventListener("abort", abortFromSignal);
|
||||||
};
|
};
|
||||||
const abortFromSignal = () => {
|
const abortFromSignal = () => {
|
||||||
externallyAborted = true;
|
|
||||||
xhr.abort();
|
|
||||||
};
|
|
||||||
const fail = error => {
|
|
||||||
cleanup();
|
|
||||||
reject(error);
|
|
||||||
};
|
|
||||||
|
|
||||||
xhr.open(method, path, true);
|
|
||||||
xhr.responseType = "text";
|
|
||||||
for (const [name, value] of headers.entries()) xhr.setRequestHeader(name, value);
|
|
||||||
|
|
||||||
xhr.upload.addEventListener("loadstart", () => {
|
|
||||||
armStallTimer();
|
|
||||||
options.onProgress?.({ loaded: 0, total: fallbackTotal, speed: 0, percent: 0 });
|
|
||||||
});
|
|
||||||
xhr.upload.addEventListener("progress", event => {
|
|
||||||
const now = performance.now();
|
|
||||||
const elapsedSeconds = Math.max((now - lastAt) / 1000, 0.001);
|
|
||||||
const deltaBytes = Math.max(0, event.loaded - lastLoaded);
|
|
||||||
const instantaneousSpeed = deltaBytes / elapsedSeconds;
|
|
||||||
speed = speed > 0 ? speed * 0.72 + instantaneousSpeed * 0.28 : instantaneousSpeed;
|
|
||||||
lastAt = now;
|
|
||||||
lastLoaded = event.loaded;
|
|
||||||
const total = event.lengthComputable ? event.total : fallbackTotal;
|
|
||||||
const percent = total > 0 ? Math.min(100, (event.loaded / total) * 100) : null;
|
|
||||||
options.onProgress?.({ loaded: event.loaded, total, speed, percent });
|
|
||||||
if (total > 0 && event.loaded >= total) clearStallTimer();
|
|
||||||
else armStallTimer();
|
|
||||||
});
|
|
||||||
xhr.upload.addEventListener("load", event => {
|
|
||||||
clearStallTimer();
|
|
||||||
clearTimeout(responseTimer);
|
|
||||||
responseTimer = setTimeout(() => {
|
|
||||||
responseTimedOut = true;
|
|
||||||
xhr.abort();
|
|
||||||
}, responseTimeoutMs);
|
|
||||||
const total = event.lengthComputable ? event.total : fallbackTotal;
|
|
||||||
options.onProgress?.({ loaded: total || lastLoaded, total, speed, percent: total > 0 ? 100 : null, phase: "processing" });
|
|
||||||
});
|
|
||||||
|
|
||||||
xhr.addEventListener("load", () => {
|
|
||||||
cleanup();
|
|
||||||
const durationMs = Math.round(performance.now() - started);
|
|
||||||
logDebug("api.response", { method, path, status: xhr.status, durationMs });
|
|
||||||
let data = {};
|
|
||||||
try { data = xhr.responseText ? JSON.parse(xhr.responseText) : {}; } catch { }
|
|
||||||
if (xhr.status >= 200 && xhr.status < 300) {
|
|
||||||
resolve(data);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (xhr.status === 401) void clearSessionIfInvalid();
|
|
||||||
const error = requestError(xhr.status, data);
|
|
||||||
logWarn("api.failed", { method, path, status: xhr.status, message: error.message });
|
|
||||||
reject(error);
|
|
||||||
});
|
|
||||||
xhr.addEventListener("error", () => {
|
|
||||||
const error = new Error("Upload failed before the server returned a response. Check the connection and try again.");
|
|
||||||
logError("api.network_error", error, { method, path });
|
|
||||||
fail(error);
|
|
||||||
});
|
|
||||||
xhr.addEventListener("abort", () => {
|
|
||||||
const error = new Error(stalled
|
|
||||||
? "Upload stopped making progress. Check the connection and try again."
|
|
||||||
: responseTimedOut ? "The file was sent, but the server did not finish processing it. Try again."
|
|
||||||
: externallyAborted ? "Upload cancelled." : "Upload interrupted. Try again.");
|
|
||||||
error.name = externallyAborted ? "AbortError" : "UploadError";
|
|
||||||
logWarn(stalled ? "api.upload_stalled" : responseTimedOut ? "api.upload_response_timeout" : "api.upload_aborted", { method, path });
|
|
||||||
fail(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (options.signal) {
|
|
||||||
if (options.signal.aborted) {
|
|
||||||
externallyAborted = true;
|
externallyAborted = true;
|
||||||
const error = new Error("Upload cancelled.");
|
xhr.abort();
|
||||||
error.name = "AbortError";
|
};
|
||||||
fail(error);
|
const fail = error => {
|
||||||
return;
|
cleanup();
|
||||||
}
|
reject(error);
|
||||||
options.signal.addEventListener("abort", abortFromSignal, { once: true });
|
};
|
||||||
}
|
|
||||||
|
|
||||||
logDebug("api.request", { method, path });
|
xhr.open(method, path, true);
|
||||||
xhr.send(options.body ?? null);
|
xhr.responseType = "text";
|
||||||
});
|
for (const [name, value] of headers.entries()) xhr.setRequestHeader(name, value);
|
||||||
|
|
||||||
|
xhr.upload.addEventListener("loadstart", () => {
|
||||||
|
armStallTimer();
|
||||||
|
options.onProgress?.({ loaded: 0, total: fallbackTotal, speed: 0, percent: 0 });
|
||||||
|
});
|
||||||
|
xhr.upload.addEventListener("progress", event => {
|
||||||
|
const now = performance.now();
|
||||||
|
const elapsedSeconds = Math.max((now - lastAt) / 1000, 0.001);
|
||||||
|
const deltaBytes = Math.max(0, event.loaded - lastLoaded);
|
||||||
|
const instantaneousSpeed = deltaBytes / elapsedSeconds;
|
||||||
|
speed = speed > 0 ? speed * 0.72 + instantaneousSpeed * 0.28 : instantaneousSpeed;
|
||||||
|
lastAt = now;
|
||||||
|
lastLoaded = event.loaded;
|
||||||
|
const total = event.lengthComputable ? event.total : fallbackTotal;
|
||||||
|
const percent = total > 0 ? Math.min(100, (event.loaded / total) * 100) : null;
|
||||||
|
options.onProgress?.({ loaded: event.loaded, total, speed, percent });
|
||||||
|
if (total > 0 && event.loaded >= total) clearStallTimer();
|
||||||
|
else armStallTimer();
|
||||||
|
});
|
||||||
|
xhr.upload.addEventListener("load", event => {
|
||||||
|
clearStallTimer();
|
||||||
|
clearTimeout(responseTimer);
|
||||||
|
responseTimer = setTimeout(() => {
|
||||||
|
responseTimedOut = true;
|
||||||
|
xhr.abort();
|
||||||
|
}, responseTimeoutMs);
|
||||||
|
const total = event.lengthComputable ? event.total : fallbackTotal;
|
||||||
|
options.onProgress?.({ loaded: total || lastLoaded, total, speed, percent: total > 0 ? 100 : null, phase: "processing" });
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.addEventListener("load", () => {
|
||||||
|
cleanup();
|
||||||
|
const durationMs = Math.round(performance.now() - started);
|
||||||
|
logDebug("api.response", { method, path, status: xhr.status, durationMs });
|
||||||
|
let data = {};
|
||||||
|
try { data = xhr.responseText ? JSON.parse(xhr.responseText) : {}; } catch { }
|
||||||
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
|
resolve(data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (xhr.status === 401) void clearSessionIfInvalid();
|
||||||
|
const error = requestError(xhr.status, data);
|
||||||
|
logWarn("api.failed", { method, path, status: xhr.status, message: error.message });
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
xhr.addEventListener("error", () => {
|
||||||
|
const error = new Error("Upload failed before the server returned a response. Check the connection and try again.");
|
||||||
|
logError("api.network_error", error, { method, path });
|
||||||
|
fail(error);
|
||||||
|
});
|
||||||
|
xhr.addEventListener("abort", () => {
|
||||||
|
const error = new Error(stalled
|
||||||
|
? "Upload stopped making progress. Check the connection and try again."
|
||||||
|
: responseTimedOut ? "The file was sent, but the server did not finish processing it. Try again."
|
||||||
|
: externallyAborted ? "Upload cancelled." : "Upload interrupted. Try again.");
|
||||||
|
error.name = externallyAborted ? "AbortError" : "UploadError";
|
||||||
|
logWarn(stalled ? "api.upload_stalled" : responseTimedOut ? "api.upload_response_timeout" : "api.upload_aborted", { method, path });
|
||||||
|
fail(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (options.signal) {
|
||||||
|
if (options.signal.aborted) {
|
||||||
|
externallyAborted = true;
|
||||||
|
const error = new Error("Upload cancelled.");
|
||||||
|
error.name = "AbortError";
|
||||||
|
fail(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
options.signal.addEventListener("abort", abortFromSignal, { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
logDebug("api.request", { method, path });
|
||||||
|
xhr.send(options.body ?? null);
|
||||||
|
});
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
|
|||||||
+46
-39
@@ -13,10 +13,38 @@ import { askConfirm, askInput, showMessage } from "@rustpad/modal";
|
|||||||
|
|
||||||
const { getAuthToken, setAuthSession, setNickname } = sessionStore;
|
const { getAuthToken, setAuthSession, setNickname } = sessionStore;
|
||||||
const clearAuthSession = sessionStore.clearAuthSession || (() => {
|
const clearAuthSession = sessionStore.clearAuthSession || (() => {
|
||||||
|
localStorage.removeItem("rustpad:auth-state");
|
||||||
localStorage.removeItem("rustpad:auth-token");
|
localStorage.removeItem("rustpad:auth-token");
|
||||||
sessionStorage.removeItem("rustpad:auth-token");
|
sessionStorage.removeItem("rustpad:auth-token");
|
||||||
|
localStorage.removeItem("rustpad:nickname");
|
||||||
sessionStorage.removeItem("rustpad:nickname");
|
sessionStorage.removeItem("rustpad:nickname");
|
||||||
});
|
});
|
||||||
|
const clearResourceAccessState = sessionStore.clearResourceAccessState || (() => {
|
||||||
|
for (let i = localStorage.length - 1; i >= 0; i--) {
|
||||||
|
const key = localStorage.key(i);
|
||||||
|
if (key?.startsWith("rustpad:access:")) localStorage.removeItem(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function configureCredentialFields({ emailLabel, email, password, loginMode, externalAuth }) {
|
||||||
|
const directoryLogin = loginMode && externalAuth;
|
||||||
|
emailLabel.textContent = directoryLogin ? "E-mail / LDAP or AD Username" : "E-mail";
|
||||||
|
email.type = directoryLogin ? "text" : "email";
|
||||||
|
email.placeholder = directoryLogin ? "you@example.com or username" : "you@example.com";
|
||||||
|
email.autocomplete = loginMode ? "username" : "off";
|
||||||
|
password.autocomplete = loginMode ? "current-password" : "off";
|
||||||
|
|
||||||
|
if (directoryLogin) password.removeAttribute("minlength");
|
||||||
|
else password.minLength = 8;
|
||||||
|
|
||||||
|
if (loginMode) {
|
||||||
|
email.removeAttribute("data-bwignore");
|
||||||
|
password.removeAttribute("data-bwignore");
|
||||||
|
} else {
|
||||||
|
email.dataset.bwignore = "true";
|
||||||
|
password.dataset.bwignore = "true";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function bindIdentityDialog({ dialog, onIdentity, initialMode = "login" }) {
|
export function bindIdentityDialog({ dialog, onIdentity, initialMode = "login" }) {
|
||||||
if (dialog.querySelector("#auth-panel")) return bindLegacyIdentityDialog({ dialog, onIdentity });
|
if (dialog.querySelector("#auth-panel")) return bindLegacyIdentityDialog({ dialog, onIdentity });
|
||||||
@@ -26,6 +54,7 @@ export function bindIdentityDialog({ dialog, onIdentity, initialMode = "login" }
|
|||||||
const title = dialog.querySelector("#identity-title");
|
const title = dialog.querySelector("#identity-title");
|
||||||
const copy = dialog.querySelector("#identity-copy");
|
const copy = dialog.querySelector("#identity-copy");
|
||||||
const emailField = dialog.querySelector("#auth-email-field");
|
const emailField = dialog.querySelector("#auth-email-field");
|
||||||
|
const emailLabel = dialog.querySelector("#auth-email-label");
|
||||||
const email = dialog.querySelector("#auth-email");
|
const email = dialog.querySelector("#auth-email");
|
||||||
const password = dialog.querySelector("#auth-password");
|
const password = dialog.querySelector("#auth-password");
|
||||||
const submit = dialog.querySelector("#auth-submit");
|
const submit = dialog.querySelector("#auth-submit");
|
||||||
@@ -41,12 +70,14 @@ export function bindIdentityDialog({ dialog, onIdentity, initialMode = "login" }
|
|||||||
const resetting = mode === "reset";
|
const resetting = mode === "reset";
|
||||||
const registering = mode === "register";
|
const registering = mode === "register";
|
||||||
|
|
||||||
title.textContent = resetting ? "Reset password" : registering ? "Register nickname" : "Log in";
|
title.textContent = resetting ? "Reset password" : registering ? "Register" : "Log in";
|
||||||
copy.textContent = resetting
|
copy.textContent = resetting
|
||||||
? "Enter the e-mail address assigned to your local account."
|
? "Enter the e-mail address assigned to your local account."
|
||||||
: registering
|
: registering
|
||||||
? "Reserve your nickname with an e-mail address and password."
|
? "Register your account with an e-mail address and password."
|
||||||
: "Use your e-mail address or organization login and password.";
|
: externalAuth
|
||||||
|
? "Use your e-mail address or LDAP/AD username and password."
|
||||||
|
: "Use your e-mail address and password.";
|
||||||
|
|
||||||
nickname.closest("label").hidden = !registering;
|
nickname.closest("label").hidden = !registering;
|
||||||
nickname.disabled = !registering;
|
nickname.disabled = !registering;
|
||||||
@@ -60,21 +91,10 @@ export function bindIdentityDialog({ dialog, onIdentity, initialMode = "login" }
|
|||||||
resetButton.hidden = resetting || registering || externalAuth;
|
resetButton.hidden = resetting || registering || externalAuth;
|
||||||
backButton.hidden = !resetting;
|
backButton.hidden = !resetting;
|
||||||
const loginMode = mode === "login";
|
const loginMode = mode === "login";
|
||||||
email.type = externalAuth && loginMode ? "text" : "email";
|
configureCredentialFields({ emailLabel, email, password, loginMode, externalAuth });
|
||||||
if (externalAuth && loginMode) password.removeAttribute("minlength");
|
|
||||||
else password.minLength = 8;
|
|
||||||
form.autocomplete = loginMode ? "on" : "off";
|
form.autocomplete = loginMode ? "on" : "off";
|
||||||
nickname.autocomplete = "off";
|
nickname.autocomplete = "off";
|
||||||
nickname.dataset.bwignore = "true";
|
nickname.dataset.bwignore = "true";
|
||||||
email.autocomplete = loginMode ? "username" : "off";
|
|
||||||
password.autocomplete = loginMode ? "current-password" : "off";
|
|
||||||
if (loginMode) {
|
|
||||||
email.removeAttribute("data-bwignore");
|
|
||||||
password.removeAttribute("data-bwignore");
|
|
||||||
} else {
|
|
||||||
email.dataset.bwignore = "true";
|
|
||||||
password.dataset.bwignore = "true";
|
|
||||||
}
|
|
||||||
message.textContent = "";
|
message.textContent = "";
|
||||||
|
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
@@ -166,6 +186,7 @@ function bindLegacyIdentityDialog({ dialog, onIdentity }) {
|
|||||||
const message = dialog.querySelector("#identity-error");
|
const message = dialog.querySelector("#identity-error");
|
||||||
const modeTitle = dialog.querySelector("#auth-mode-title");
|
const modeTitle = dialog.querySelector("#auth-mode-title");
|
||||||
const emailField = dialog.querySelector("#auth-email-field");
|
const emailField = dialog.querySelector("#auth-email-field");
|
||||||
|
const emailLabel = dialog.querySelector("#auth-email-label");
|
||||||
const email = dialog.querySelector("#auth-email");
|
const email = dialog.querySelector("#auth-email");
|
||||||
const password = dialog.querySelector("#auth-password");
|
const password = dialog.querySelector("#auth-password");
|
||||||
const authSubmit = dialog.querySelector("#auth-submit");
|
const authSubmit = dialog.querySelector("#auth-submit");
|
||||||
@@ -200,24 +221,13 @@ function bindLegacyIdentityDialog({ dialog, onIdentity }) {
|
|||||||
nickname.required = mode === "register";
|
nickname.required = mode === "register";
|
||||||
emailField.hidden = false;
|
emailField.hidden = false;
|
||||||
email.disabled = false;
|
email.disabled = false;
|
||||||
modeTitle.textContent = mode === "register" ? "Register nickname" : "Log in";
|
modeTitle.textContent = mode === "register" ? "Register" : "Log in";
|
||||||
authSubmit.textContent = mode === "register" ? "Register and continue" : "Log in and continue";
|
authSubmit.textContent = mode === "register" ? "Register and continue" : "Log in and continue";
|
||||||
const loginMode = mode === "login";
|
const loginMode = mode === "login";
|
||||||
email.type = externalAuth && loginMode ? "text" : "email";
|
configureCredentialFields({ emailLabel, email, password, loginMode, externalAuth });
|
||||||
if (externalAuth && loginMode) password.removeAttribute("minlength");
|
|
||||||
else password.minLength = 8;
|
|
||||||
form.autocomplete = loginMode ? "on" : "off";
|
form.autocomplete = loginMode ? "on" : "off";
|
||||||
nickname.autocomplete = "off";
|
nickname.autocomplete = "off";
|
||||||
nickname.dataset.bwignore = "true";
|
nickname.dataset.bwignore = "true";
|
||||||
email.autocomplete = loginMode ? "username" : "off";
|
|
||||||
password.autocomplete = loginMode ? "current-password" : "off";
|
|
||||||
if (loginMode) {
|
|
||||||
email.removeAttribute("data-bwignore");
|
|
||||||
password.removeAttribute("data-bwignore");
|
|
||||||
} else {
|
|
||||||
email.dataset.bwignore = "true";
|
|
||||||
password.dataset.bwignore = "true";
|
|
||||||
}
|
|
||||||
message.textContent = "";
|
message.textContent = "";
|
||||||
queueMicrotask(() => (mode === "register" ? nickname : email).focus());
|
queueMicrotask(() => (mode === "register" ? nickname : email).focus());
|
||||||
};
|
};
|
||||||
@@ -287,7 +297,7 @@ function bindLegacyIdentityDialog({ dialog, onIdentity }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
nickname.disabled = false;
|
nickname.disabled = false;
|
||||||
const result = await api("/api/auth/identity", { method: "POST", body: JSON.stringify({ nickname: name, session_token: getAuthToken() || null }) });
|
const result = await api("/api/auth/identity", { method: "POST", body: JSON.stringify({ nickname: name }) });
|
||||||
setNickname(result.nickname);
|
setNickname(result.nickname);
|
||||||
await onIdentity(result.nickname, null);
|
await onIdentity(result.nickname, null);
|
||||||
dialog.close();
|
dialog.close();
|
||||||
@@ -305,25 +315,22 @@ function bindLegacyIdentityDialog({ dialog, onIdentity }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function validateCurrentSession() {
|
export async function validateCurrentSession() {
|
||||||
const token = getAuthToken();
|
const expectedSession = Boolean(getAuthToken());
|
||||||
if (!token) return null;
|
|
||||||
try {
|
try {
|
||||||
const session = await api("/api/auth/me", { headers: { Authorization: `Bearer ${token}` } });
|
const session = await api("/api/auth/me");
|
||||||
setAuthSession(session);
|
setAuthSession(session);
|
||||||
return session;
|
return session;
|
||||||
} catch {
|
} catch {
|
||||||
clearAuthSession();
|
if (expectedSession) clearAuthSession();
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function logoutCurrentSession() {
|
export async function logoutCurrentSession() {
|
||||||
const token = getAuthToken();
|
try {
|
||||||
if (token) {
|
await api("/api/auth/logout", { method: "POST" });
|
||||||
try {
|
} catch { }
|
||||||
await api("/api/auth/logout", { method: "POST", headers: { Authorization: `Bearer ${token}` } });
|
clearResourceAccessState();
|
||||||
} catch { }
|
|
||||||
}
|
|
||||||
clearAuthSession();
|
clearAuthSession();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -161,25 +161,6 @@ export function mapSelectionThroughEdit(previousText, nextText, start, end = sta
|
|||||||
end: Math.max(0, Math.min(nextText.length, map(end))),
|
end: Math.max(0, Math.min(nextText.length, map(end))),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
export function lineOwners(content, model) {
|
|
||||||
const starts = [0];
|
|
||||||
for (let i = 0; i < content.length; i++) if (content.charCodeAt(i) === 10) starts.push(i + 1);
|
|
||||||
return starts.map((start, index) => {
|
|
||||||
const end = index + 1 < starts.length ? starts[index + 1] : content.length;
|
|
||||||
const totals = new Map();
|
|
||||||
const representatives = new Map();
|
|
||||||
for (const span of model?.spans || []) {
|
|
||||||
const overlap = Math.max(0, Math.min(end, span.end) - Math.max(start, span.start));
|
|
||||||
if (!overlap) continue;
|
|
||||||
const identity = ownerIdentity(span.owner);
|
|
||||||
totals.set(identity, (totals.get(identity) || 0) + overlap);
|
|
||||||
representatives.set(identity, span.owner);
|
|
||||||
}
|
|
||||||
const identity = [...totals.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
|
|
||||||
return identity ? representatives.get(identity) : "";
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function syncAuthorshipLayer(layer, editor) {
|
export function syncAuthorshipLayer(layer, editor) {
|
||||||
if (!layer || !editor) return;
|
if (!layer || !editor) return;
|
||||||
const canvas = layer.querySelector(".authorship-canvas");
|
const canvas = layer.querySelector(".authorship-canvas");
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Mateusz Gruszczynski @linuxiarz.pl
|
||||||
|
* Source-Available Code / Dual-Licensed.
|
||||||
|
*
|
||||||
|
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||||
|
* Commercial or production use requires a valid paid license.
|
||||||
|
* See LICENSE file in repository root for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
applyOperationToDocument,
|
||||||
|
compareOperationKeys,
|
||||||
|
composeOperations,
|
||||||
|
documentAfterPending,
|
||||||
|
identityOperation,
|
||||||
|
normalizeOperation,
|
||||||
|
operationBaseLength,
|
||||||
|
operationFromEdit,
|
||||||
|
operationTargetLength,
|
||||||
|
transformOperations,
|
||||||
|
} from "@rustpad/collaboration";
|
||||||
|
import { parseAuthorship } from "@rustpad/authorship";
|
||||||
|
|
||||||
|
function hasTextEffect(operation) {
|
||||||
|
return normalizeOperation(operation).components.some(component => component.kind === "insert" || component.kind === "delete");
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeOwnerReplacements(previous = [], next = []) {
|
||||||
|
const replacements = new Map();
|
||||||
|
for (const item of [...previous, ...next]) {
|
||||||
|
const owner = String(item?.owner || "");
|
||||||
|
const replacement = String(item?.replacement || "");
|
||||||
|
if (owner && replacement) replacements.set(owner, { owner, replacement });
|
||||||
|
}
|
||||||
|
return [...replacements.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
function pendingEnvelope(clientId, updateId, operation, ownerReplacements = []) {
|
||||||
|
return {
|
||||||
|
clientId,
|
||||||
|
updateId,
|
||||||
|
operation: normalizeOperation(operation),
|
||||||
|
ownerReplacements: mergeOwnerReplacements([], ownerReplacements),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function serverEnvelope(message) {
|
||||||
|
return {
|
||||||
|
baseRevisionId: Number(message?.base_revision_id ?? message?.baseRevisionId),
|
||||||
|
revisionId: Number(message?.revision_id ?? message?.revisionId),
|
||||||
|
clientId: String(message?.client_id ?? message?.clientId ?? ""),
|
||||||
|
updateId: Number(message?.update_id ?? message?.updateId ?? 0),
|
||||||
|
operation: normalizeOperation(message?.operation),
|
||||||
|
ownerReplacements: mergeOwnerReplacements([], message?.owner_replacements ?? message?.ownerReplacements ?? []),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CollaborationRevisionGapError extends Error {
|
||||||
|
constructor(expected, actual) {
|
||||||
|
super(`Collaborative revision gap: expected ${expected}, received ${actual}`);
|
||||||
|
this.name = "CollaborationRevisionGapError";
|
||||||
|
this.expected = expected;
|
||||||
|
this.actual = actual;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CollaborationSession {
|
||||||
|
constructor(clientId) {
|
||||||
|
this.clientId = String(clientId || "");
|
||||||
|
this.ready = false;
|
||||||
|
this.serverContent = "";
|
||||||
|
this.serverOwnerMap = "[]";
|
||||||
|
this.revisionId = 0;
|
||||||
|
this.outstanding = null;
|
||||||
|
this.buffer = null;
|
||||||
|
this.nextUpdateId = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
initialize(content, ownerMap, revisionId, { clearPending = true } = {}) {
|
||||||
|
this.serverContent = String(content || "");
|
||||||
|
this.serverOwnerMap = ownerMap == null ? "[]" : String(ownerMap);
|
||||||
|
this.revisionId = Number(revisionId) || 0;
|
||||||
|
if (clearPending) {
|
||||||
|
this.outstanding = null;
|
||||||
|
this.buffer = null;
|
||||||
|
}
|
||||||
|
this.ready = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
localDocument() {
|
||||||
|
return documentAfterPending(
|
||||||
|
this.serverContent,
|
||||||
|
this.serverOwnerMap,
|
||||||
|
this.outstanding,
|
||||||
|
this.buffer,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
hasPending() {
|
||||||
|
return Boolean(this.outstanding || this.buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
queue(operation, ownerReplacements = []) {
|
||||||
|
operation = normalizeOperation(operation);
|
||||||
|
const replacements = mergeOwnerReplacements([], ownerReplacements);
|
||||||
|
if (!hasTextEffect(operation) && !replacements.length) return null;
|
||||||
|
|
||||||
|
const localLength = this.localDocument().content.length;
|
||||||
|
if (operationBaseLength(operation) !== localLength) {
|
||||||
|
throw new Error("Local operation base length does not match the collaborative document");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.buffer) {
|
||||||
|
this.buffer = pendingEnvelope(this.clientId, this.nextUpdateId++, operation, replacements);
|
||||||
|
} else {
|
||||||
|
this.buffer.operation = composeOperations(this.buffer.operation, operation);
|
||||||
|
this.buffer.ownerReplacements = mergeOwnerReplacements(this.buffer.ownerReplacements, replacements);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (operationTargetLength(this.buffer.operation) !== this.localDocument().content.length) {
|
||||||
|
throw new Error("Buffered operation target length does not match the collaborative document");
|
||||||
|
}
|
||||||
|
return this.buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendable() {
|
||||||
|
if (!this.ready || this.outstanding || !this.buffer) return null;
|
||||||
|
return {
|
||||||
|
baseRevisionId: this.revisionId,
|
||||||
|
updateId: this.buffer.updateId,
|
||||||
|
operation: this.buffer.operation,
|
||||||
|
ownerReplacements: this.buffer.ownerReplacements,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
markSent(updateId) {
|
||||||
|
if (this.outstanding || !this.buffer || this.buffer.updateId !== Number(updateId)) return false;
|
||||||
|
this.outstanding = this.buffer;
|
||||||
|
this.buffer = null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
integrate(message) {
|
||||||
|
const remote = serverEnvelope(message);
|
||||||
|
if (!Number.isFinite(remote.baseRevisionId) || !Number.isFinite(remote.revisionId)) {
|
||||||
|
throw new Error("Collaborative update is missing revision metadata");
|
||||||
|
}
|
||||||
|
if (remote.revisionId <= this.revisionId) return { duplicate: true, ownAck: false, remote: false };
|
||||||
|
if (remote.baseRevisionId !== this.revisionId) {
|
||||||
|
throw new CollaborationRevisionGapError(this.revisionId, remote.baseRevisionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextServer = applyOperationToDocument(
|
||||||
|
this.serverContent,
|
||||||
|
this.serverOwnerMap,
|
||||||
|
remote.operation,
|
||||||
|
remote.ownerReplacements,
|
||||||
|
);
|
||||||
|
const ownAck = Boolean(
|
||||||
|
this.outstanding
|
||||||
|
&& remote.clientId === this.clientId
|
||||||
|
&& remote.updateId === this.outstanding.updateId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (ownAck) {
|
||||||
|
this.outstanding = null;
|
||||||
|
} else {
|
||||||
|
let remoteForPending = remote.operation;
|
||||||
|
if (this.outstanding) {
|
||||||
|
const outstandingBeforeRemote = compareOperationKeys(this.outstanding, remote) < 0;
|
||||||
|
const [outstandingPrime, remotePrime] = transformOperations(
|
||||||
|
this.outstanding.operation,
|
||||||
|
remoteForPending,
|
||||||
|
outstandingBeforeRemote,
|
||||||
|
);
|
||||||
|
this.outstanding.operation = outstandingPrime;
|
||||||
|
remoteForPending = remotePrime;
|
||||||
|
}
|
||||||
|
if (this.buffer) {
|
||||||
|
const bufferBeforeRemote = compareOperationKeys(this.buffer, remote) < 0;
|
||||||
|
const [bufferPrime] = transformOperations(
|
||||||
|
this.buffer.operation,
|
||||||
|
remoteForPending,
|
||||||
|
bufferBeforeRemote,
|
||||||
|
);
|
||||||
|
this.buffer.operation = bufferPrime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.serverContent = nextServer.content;
|
||||||
|
this.serverOwnerMap = nextServer.ownerMap;
|
||||||
|
this.revisionId = remote.revisionId;
|
||||||
|
return { duplicate: false, ownAck, remote: !ownAck };
|
||||||
|
}
|
||||||
|
|
||||||
|
resynchronize(message) {
|
||||||
|
const canonicalContent = String(message?.content || "");
|
||||||
|
const canonicalOwnerMap = message?.owner_map == null ? "[]" : String(message.owner_map);
|
||||||
|
const canonicalRevisionId = Number(message?.revision_id) || 0;
|
||||||
|
const outstanding = this.outstanding;
|
||||||
|
const buffer = this.buffer;
|
||||||
|
const local = this.localDocument();
|
||||||
|
const acknowledgedIds = (message?.acknowledged_update_ids || [])
|
||||||
|
.map(Number)
|
||||||
|
.filter(Number.isFinite);
|
||||||
|
const acknowledgedThrough = acknowledgedIds.length ? Math.max(...acknowledgedIds) : 0;
|
||||||
|
const messageClientId = String(message?.client_id ?? message?.clientId ?? "");
|
||||||
|
const messageUpdateId = Number(message?.update_id ?? message?.updateId ?? 0);
|
||||||
|
const outstandingAcknowledged = Boolean(
|
||||||
|
outstanding
|
||||||
|
&& (
|
||||||
|
acknowledgedThrough >= outstanding.updateId
|
||||||
|
|| (messageClientId === this.clientId && messageUpdateId === outstanding.updateId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const pendingOwnerReplacements = mergeOwnerReplacements(
|
||||||
|
outstanding?.ownerReplacements || [],
|
||||||
|
buffer?.ownerReplacements || [],
|
||||||
|
);
|
||||||
|
let replayOperation = null;
|
||||||
|
let replayOwnerReplacements = [];
|
||||||
|
|
||||||
|
if (this.ready && (outstanding || buffer)) {
|
||||||
|
const canonicalAuthorship = parseAuthorship(canonicalContent, canonicalOwnerMap);
|
||||||
|
if (outstandingAcknowledged) {
|
||||||
|
if (buffer) {
|
||||||
|
const afterOutstanding = applyOperationToDocument(
|
||||||
|
this.serverContent,
|
||||||
|
this.serverOwnerMap,
|
||||||
|
outstanding.operation,
|
||||||
|
outstanding.ownerReplacements,
|
||||||
|
);
|
||||||
|
const missedRemote = operationFromEdit(
|
||||||
|
afterOutstanding.content,
|
||||||
|
canonicalContent,
|
||||||
|
canonicalAuthorship,
|
||||||
|
);
|
||||||
|
[replayOperation] = transformOperations(buffer.operation, missedRemote, false);
|
||||||
|
replayOwnerReplacements = buffer.ownerReplacements;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const localAuthorship = parseAuthorship(local.content, local.ownerMap);
|
||||||
|
const localOperation = operationFromEdit(this.serverContent, local.content, localAuthorship);
|
||||||
|
const missedRemote = operationFromEdit(
|
||||||
|
this.serverContent,
|
||||||
|
canonicalContent,
|
||||||
|
canonicalAuthorship,
|
||||||
|
);
|
||||||
|
[replayOperation] = transformOperations(localOperation, missedRemote, false);
|
||||||
|
replayOwnerReplacements = pendingOwnerReplacements;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.initialize(canonicalContent, canonicalOwnerMap, canonicalRevisionId);
|
||||||
|
if (replayOperation || replayOwnerReplacements.length) {
|
||||||
|
this.queue(
|
||||||
|
replayOperation || identityOperation(canonicalContent.length),
|
||||||
|
replayOwnerReplacements,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
replayed: Boolean(this.buffer),
|
||||||
|
outstandingAcknowledged,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
adoptCanonicalSnapshot(content, ownerMap, revisionId) {
|
||||||
|
revisionId = Number(revisionId) || 0;
|
||||||
|
if (revisionId !== this.revisionId) {
|
||||||
|
throw new CollaborationRevisionGapError(this.revisionId, revisionId);
|
||||||
|
}
|
||||||
|
if (String(content || "") !== this.serverContent) {
|
||||||
|
throw new Error("Canonical collaborative content does not match applied operations");
|
||||||
|
}
|
||||||
|
this.serverOwnerMap = ownerMap == null ? "[]" : String(ownerMap);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Mateusz Gruszczynski @linuxiarz.pl
|
||||||
|
* Source-Available Code / Dual-Licensed.
|
||||||
|
*
|
||||||
|
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||||
|
* Commercial or production use requires a valid paid license.
|
||||||
|
* See LICENSE file in repository root for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { parseAuthorship, serializeAuthorship } from "@rustpad/authorship";
|
||||||
|
|
||||||
|
function normalizeOwnerSpans(spans, length) {
|
||||||
|
const result = [];
|
||||||
|
const sorted = [...(Array.isArray(spans) ? spans : [])].sort((left, right) => (Number(left?.start) || 0) - (Number(right?.start) || 0) || (Number(left?.end) || 0) - (Number(right?.end) || 0));
|
||||||
|
for (const source of sorted) {
|
||||||
|
const start = Math.max(0, Math.min(length, Number(source?.start) || 0));
|
||||||
|
const end = Math.max(start, Math.min(length, Number(source?.end) || 0));
|
||||||
|
const owner = String(source?.owner || "");
|
||||||
|
if (!owner || end <= start) continue;
|
||||||
|
const previous = result.at(-1);
|
||||||
|
if (previous && previous.owner === owner && start <= previous.end) {
|
||||||
|
previous.end = Math.max(previous.end, end);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const clippedStart = previous && start < previous.end ? previous.end : start;
|
||||||
|
if (end > clippedStart) result.push({ start: clippedStart, end, owner });
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sliceOwnerSpans(spans, start, length) {
|
||||||
|
const end = start + length;
|
||||||
|
return normalizeOwnerSpans((spans || []).flatMap(span => {
|
||||||
|
const overlapStart = Math.max(start, span.start);
|
||||||
|
const overlapEnd = Math.min(end, span.end);
|
||||||
|
return overlapEnd > overlapStart
|
||||||
|
? [{ start: overlapStart - start, end: overlapEnd - start, owner: span.owner }]
|
||||||
|
: [];
|
||||||
|
}), length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shiftOwnerSpans(spans, offset) {
|
||||||
|
return (spans || []).map(span => ({ start: span.start + offset, end: span.end + offset, owner: span.owner }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendComponent(components, component) {
|
||||||
|
if (!component) return;
|
||||||
|
if (component.kind === "retain" || component.kind === "delete") {
|
||||||
|
const count = Number(component.count) || 0;
|
||||||
|
if (count <= 0) return;
|
||||||
|
const previous = components.at(-1);
|
||||||
|
if (previous?.kind === component.kind) previous.count += count;
|
||||||
|
else components.push({ kind: component.kind, count });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (component.kind !== "insert") throw new Error("Unknown operation component");
|
||||||
|
const text = String(component.text || "");
|
||||||
|
if (!text) return;
|
||||||
|
const owners = normalizeOwnerSpans(component.owners, text.length);
|
||||||
|
const previous = components.at(-1);
|
||||||
|
if (previous?.kind === "insert") {
|
||||||
|
const offset = previous.text.length;
|
||||||
|
previous.text += text;
|
||||||
|
previous.owners = normalizeOwnerSpans([
|
||||||
|
...(previous.owners || []),
|
||||||
|
...shiftOwnerSpans(owners, offset),
|
||||||
|
], previous.text.length);
|
||||||
|
} else components.push({ kind: "insert", text, owners });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeOperation(operation) {
|
||||||
|
const components = [];
|
||||||
|
for (const component of operation?.components || []) appendComponent(components, component);
|
||||||
|
return { components };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function operationBaseLength(operation) {
|
||||||
|
return normalizeOperation(operation).components.reduce((length, component) =>
|
||||||
|
length + (component.kind === "retain" || component.kind === "delete" ? component.count : 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function operationTargetLength(operation) {
|
||||||
|
return normalizeOperation(operation).components.reduce((length, component) =>
|
||||||
|
length + (component.kind === "retain" ? component.count : component.kind === "insert" ? component.text.length : 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function identityOperation(length) {
|
||||||
|
return normalizeOperation({ components: length > 0 ? [{ kind: "retain", count: length }] : [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUtf16Boundary(value, offset) {
|
||||||
|
if (offset <= 0 || offset >= value.length) return true;
|
||||||
|
const previous = value.charCodeAt(offset - 1);
|
||||||
|
const next = value.charCodeAt(offset);
|
||||||
|
return !(previous >= 0xd800 && previous <= 0xdbff && next >= 0xdc00 && next <= 0xdfff);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function operationFromEdit(previousText, nextText, nextAuthorship) {
|
||||||
|
previousText = String(previousText || "");
|
||||||
|
nextText = String(nextText || "");
|
||||||
|
let prefix = 0;
|
||||||
|
const shared = Math.min(previousText.length, nextText.length);
|
||||||
|
while (prefix < shared && previousText.charCodeAt(prefix) === nextText.charCodeAt(prefix)) prefix++;
|
||||||
|
while (prefix > 0 && (!isUtf16Boundary(previousText, prefix) || !isUtf16Boundary(nextText, prefix))) prefix--;
|
||||||
|
|
||||||
|
let oldSuffix = previousText.length;
|
||||||
|
let newSuffix = nextText.length;
|
||||||
|
while (oldSuffix > prefix && newSuffix > prefix && previousText.charCodeAt(oldSuffix - 1) === nextText.charCodeAt(newSuffix - 1)) {
|
||||||
|
oldSuffix--;
|
||||||
|
newSuffix--;
|
||||||
|
}
|
||||||
|
while (!isUtf16Boundary(previousText, oldSuffix) || !isUtf16Boundary(nextText, newSuffix)) {
|
||||||
|
oldSuffix++;
|
||||||
|
newSuffix++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const components = [];
|
||||||
|
appendComponent(components, { kind: "retain", count: prefix });
|
||||||
|
appendComponent(components, { kind: "delete", count: oldSuffix - prefix });
|
||||||
|
const insertedText = nextText.slice(prefix, newSuffix);
|
||||||
|
appendComponent(components, {
|
||||||
|
kind: "insert",
|
||||||
|
text: insertedText,
|
||||||
|
owners: sliceOwnerSpans(nextAuthorship?.spans || [], prefix, insertedText.length),
|
||||||
|
});
|
||||||
|
appendComponent(components, { kind: "retain", count: previousText.length - oldSuffix });
|
||||||
|
return { components };
|
||||||
|
}
|
||||||
|
|
||||||
|
class OperationCursor {
|
||||||
|
constructor(operation) {
|
||||||
|
this.components = normalizeOperation(operation).components;
|
||||||
|
this.index = 0;
|
||||||
|
this.offset = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
get current() { return this.components[this.index] || null; }
|
||||||
|
get kind() { return this.current?.kind || null; }
|
||||||
|
get remaining() {
|
||||||
|
const component = this.current;
|
||||||
|
if (!component) return 0;
|
||||||
|
return (component.kind === "insert" ? component.text.length : component.count) - this.offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
take(count = this.remaining) {
|
||||||
|
const component = this.current;
|
||||||
|
if (!component || count <= 0 || count > this.remaining) throw new Error("Invalid operation cursor read");
|
||||||
|
let part;
|
||||||
|
if (component.kind === "insert") {
|
||||||
|
part = {
|
||||||
|
kind: "insert",
|
||||||
|
text: component.text.slice(this.offset, this.offset + count),
|
||||||
|
owners: sliceOwnerSpans(component.owners, this.offset, count),
|
||||||
|
};
|
||||||
|
} else part = { kind: component.kind, count };
|
||||||
|
this.offset += count;
|
||||||
|
if (this.offset === (component.kind === "insert" ? component.text.length : component.count)) {
|
||||||
|
this.index++;
|
||||||
|
this.offset = 0;
|
||||||
|
}
|
||||||
|
return part;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function composeOperations(first, second) {
|
||||||
|
first = normalizeOperation(first);
|
||||||
|
second = normalizeOperation(second);
|
||||||
|
if (operationTargetLength(first) !== operationBaseLength(second)) throw new Error("Cannot compose operations with different lengths");
|
||||||
|
const left = new OperationCursor(first);
|
||||||
|
const right = new OperationCursor(second);
|
||||||
|
const components = [];
|
||||||
|
|
||||||
|
while (left.current || right.current) {
|
||||||
|
if (right.kind === "insert") {
|
||||||
|
appendComponent(components, right.take());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (left.kind === "delete") {
|
||||||
|
appendComponent(components, left.take());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!left.current || !right.current) throw new Error("Incomplete operation composition");
|
||||||
|
const count = Math.min(left.remaining, right.remaining);
|
||||||
|
const leftKind = left.kind;
|
||||||
|
const rightKind = right.kind;
|
||||||
|
if (leftKind === "retain" && rightKind === "retain") {
|
||||||
|
appendComponent(components, { kind: "retain", count });
|
||||||
|
left.take(count);
|
||||||
|
right.take(count);
|
||||||
|
} else if (leftKind === "retain" && rightKind === "delete") {
|
||||||
|
appendComponent(components, { kind: "delete", count });
|
||||||
|
left.take(count);
|
||||||
|
right.take(count);
|
||||||
|
} else if (leftKind === "insert" && rightKind === "retain") {
|
||||||
|
appendComponent(components, left.take(count));
|
||||||
|
right.take(count);
|
||||||
|
} else if (leftKind === "insert" && rightKind === "delete") {
|
||||||
|
left.take(count);
|
||||||
|
right.take(count);
|
||||||
|
} else throw new Error("Unsupported operation composition");
|
||||||
|
}
|
||||||
|
return { components };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function transformOperations(leftOperation, rightOperation, leftBeforeRight = true) {
|
||||||
|
leftOperation = normalizeOperation(leftOperation);
|
||||||
|
rightOperation = normalizeOperation(rightOperation);
|
||||||
|
if (operationBaseLength(leftOperation) !== operationBaseLength(rightOperation)) throw new Error("Cannot transform operations with different base lengths");
|
||||||
|
const left = new OperationCursor(leftOperation);
|
||||||
|
const right = new OperationCursor(rightOperation);
|
||||||
|
const leftPrime = [];
|
||||||
|
const rightPrime = [];
|
||||||
|
|
||||||
|
while (left.current || right.current) {
|
||||||
|
if (left.kind === "insert" && (right.kind !== "insert" || leftBeforeRight)) {
|
||||||
|
const part = left.take();
|
||||||
|
appendComponent(leftPrime, part);
|
||||||
|
appendComponent(rightPrime, { kind: "retain", count: part.text.length });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (right.kind === "insert") {
|
||||||
|
const part = right.take();
|
||||||
|
appendComponent(leftPrime, { kind: "retain", count: part.text.length });
|
||||||
|
appendComponent(rightPrime, part);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!left.current || !right.current) throw new Error("Incomplete operation transform");
|
||||||
|
const count = Math.min(left.remaining, right.remaining);
|
||||||
|
if (left.kind === "retain" && right.kind === "retain") {
|
||||||
|
appendComponent(leftPrime, { kind: "retain", count });
|
||||||
|
appendComponent(rightPrime, { kind: "retain", count });
|
||||||
|
} else if (left.kind === "delete" && right.kind === "retain") {
|
||||||
|
appendComponent(leftPrime, { kind: "delete", count });
|
||||||
|
} else if (left.kind === "retain" && right.kind === "delete") {
|
||||||
|
appendComponent(rightPrime, { kind: "delete", count });
|
||||||
|
} else if (left.kind !== "delete" || right.kind !== "delete") throw new Error("Unsupported operation transform");
|
||||||
|
left.take(count);
|
||||||
|
right.take(count);
|
||||||
|
}
|
||||||
|
return [{ components: leftPrime }, { components: rightPrime }];
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyRetainedSpans(target, spans, sourceStart, length, outputStart) {
|
||||||
|
const sourceEnd = sourceStart + length;
|
||||||
|
for (const span of spans || []) {
|
||||||
|
const start = Math.max(sourceStart, span.start);
|
||||||
|
const end = Math.min(sourceEnd, span.end);
|
||||||
|
if (end > start) target.push({ start: outputStart + start - sourceStart, end: outputStart + end - sourceStart, owner: span.owner });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyOperationToDocument(content, ownerMap, operation, ownerReplacements = []) {
|
||||||
|
content = String(content || "");
|
||||||
|
operation = normalizeOperation(operation);
|
||||||
|
if (operationBaseLength(operation) !== content.length) throw new Error("Operation base length does not match document");
|
||||||
|
const sourceModel = parseAuthorship(content, ownerMap);
|
||||||
|
const outputSpans = [];
|
||||||
|
let sourceOffset = 0;
|
||||||
|
let outputOffset = 0;
|
||||||
|
let nextContent = "";
|
||||||
|
for (const component of operation.components) {
|
||||||
|
if (component.kind === "retain") {
|
||||||
|
nextContent += content.slice(sourceOffset, sourceOffset + component.count);
|
||||||
|
copyRetainedSpans(outputSpans, sourceModel.spans, sourceOffset, component.count, outputOffset);
|
||||||
|
sourceOffset += component.count;
|
||||||
|
outputOffset += component.count;
|
||||||
|
} else if (component.kind === "delete") sourceOffset += component.count;
|
||||||
|
else {
|
||||||
|
nextContent += component.text;
|
||||||
|
outputSpans.push(...shiftOwnerSpans(component.owners, outputOffset));
|
||||||
|
outputOffset += component.text.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (sourceOffset !== content.length) throw new Error("Operation did not consume the whole document");
|
||||||
|
const replacementMap = new Map((ownerReplacements || []).map(item => [String(item?.owner || ""), String(item?.replacement || "")]));
|
||||||
|
const replacedSpans = outputSpans.map(span => {
|
||||||
|
const identity = String(span.owner || "").split("\u001f", 1)[0];
|
||||||
|
const replacement = replacementMap.get(identity);
|
||||||
|
return replacement ? { ...span, owner: replacement } : span;
|
||||||
|
});
|
||||||
|
const model = { version: 2, spans: normalizeOwnerSpans(replacedSpans, nextContent.length) };
|
||||||
|
return { content: nextContent, ownerMap: serializeAuthorship(model, nextContent.length), authorship: model };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compareOperationKeys(left, right) {
|
||||||
|
const leftClient = String(left?.clientId || left?.client_id || "");
|
||||||
|
const rightClient = String(right?.clientId || right?.client_id || "");
|
||||||
|
if (leftClient !== rightClient) return leftClient < rightClient ? -1 : 1;
|
||||||
|
const leftUpdate = Number(left?.updateId ?? left?.update_id ?? 0);
|
||||||
|
const rightUpdate = Number(right?.updateId ?? right?.update_id ?? 0);
|
||||||
|
return leftUpdate === rightUpdate ? 0 : leftUpdate < rightUpdate ? -1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function documentAfterPending(serverContent, serverOwnerMap, outstanding, buffer) {
|
||||||
|
let documentState = { content: serverContent, ownerMap: serverOwnerMap };
|
||||||
|
for (const pending of [outstanding, buffer]) {
|
||||||
|
if (!pending) continue;
|
||||||
|
documentState = applyOperationToDocument(
|
||||||
|
documentState.content,
|
||||||
|
documentState.ownerMap,
|
||||||
|
pending.operation,
|
||||||
|
pending.ownerReplacements,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return documentState;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user