Compare commits

...
49 Commits
Author SHA1 Message Date
Mateusz Gruszczyński 249e5a4fcd public pages links 2026-08-06 14:20:38 +02:00
Mateusz Gruszczyński 017513db8a public pages links 2026-08-06 14:04:21 +02:00
Mateusz Gruszczyński 9880b64659 hidden navbar 2026-08-06 13:49:50 +02:00
Mateusz Gruszczyński ce368cf3fd new functions and fixes 2026-08-06 09:43:49 +02:00
Mateusz Gruszczyński 7989a873e7 new functions and fixes 2026-08-06 09:08:02 +02:00
Mateusz Gruszczyński 1e80fb2bcb cursor focus 2026-08-06 00:21:21 +02:00
Mateusz Gruszczyński a7329f5535 fixes and functions 2026-08-05 23:52:41 +02:00
Mateusz Gruszczyński 4643de4309 fixes and functions 2026-08-05 23:07:51 +02:00
Mateusz Gruszczyński b41ec22aab fixes and functions 2026-08-05 22:56:02 +02:00
Mateusz Gruszczyński 19d481fe72 new functions and fixes 2026-08-05 22:24:54 +02:00
Mateusz Gruszczyński e842b26978 new functions and fixes 2026-08-05 22:13:04 +02:00
Mateusz Gruszczyński 9fc32d0d31 fixes and functions 2026-08-05 11:11:08 +02:00
Mateusz Gruszczyński c4d04adb40 change modal on error 2026-08-05 10:38:17 +02:00
Mateusz Gruszczyński 73286d921b fix in mysql 2026-08-05 10:29:31 +02:00
Mateusz Gruszczyński a9911da9a3 improvements 2026-08-05 09:53:28 +02:00
Mateusz Gruszczyński 248f4a3977 fixes and functions 2026-08-04 23:35:54 +02:00
Mateusz Gruszczyński 4b25085bb5 fixes and functions 2026-08-04 23:21:27 +02:00
Mateusz Gruszczyński 6fc408ddf7 guest password protect 2026-08-04 16:41:45 +02:00
Mateusz Gruszczyński 8d58549d11 fix in db and collaborate 2026-08-04 14:22:26 +02:00
Mateusz Gruszczyński 1a77ccd1bf paste files, images 2026-08-04 10:42:45 +02:00
Mateusz Gruszczyński 83e10129c3 mobile fixes 2026-08-04 09:22:41 +02:00
Mateusz Gruszczyński 825edeab92 fixes in css 2026-08-03 13:13:11 +02:00
Mateusz Gruszczyński 8b8b3b82b8 fixes in styles 2026-08-03 12:32:55 +02:00
Mateusz Gruszczyński 55e58c4bff enterdb 2026-08-03 10:04:04 +02:00
Mateusz Gruszczyński fe5d00fcdd big update in share links 2026-08-03 09:56:47 +02:00
Mateusz Gruszczyński e3ee6319b9 fix2 tokens 2026-08-03 01:35:05 +02:00
Mateusz Gruszczyński 49dad1a5f4 fix1 tokens 2026-08-03 01:34:13 +02:00
Mateusz Gruszczyński 13c1aba161 pagnation 2026-08-02 14:42:32 +02:00
Mateusz Gruszczyński f71ebefa82 pagnation 2026-08-02 14:26:32 +02:00
Mateusz Gruszczyński 464660a274 changesin headers 2026-08-02 11:04:04 +02:00
Mateusz Gruszczyński ede6d1de92 changes in headers 2026-08-02 01:21:31 +02:00
Mateusz Gruszczyński f1ef3c5a11 wyklucz ikouny z nagowkow 2026-08-02 01:09:50 +02:00
Mateusz Gruszczyński dfd045919f wyklucz ikouny z nagowkow 2026-08-02 01:03:13 +02:00
Mateusz Gruszczyński f8b870a0cb icons 2026-08-02 00:50:48 +02:00
Mateusz Gruszczyński aba2e0c621 icons 2026-08-01 23:52:08 +02:00
Mateusz Gruszczyński aaa7b1aaa5 icons 2026-08-01 23:40:41 +02:00
Mateusz Gruszczyński 9eb3e1f9ff icon 2026-08-01 23:20:12 +02:00
Mateusz Gruszczyński 7006b4ea09 fixes 2026-08-01 09:41:10 +02:00
Mateusz Gruszczyński 50adb291d6 register 2026-08-01 00:25:49 +02:00
Mateusz Gruszczyński 1401054c71 tokens and more 2026-08-01 00:15:37 +02:00
Mateusz Gruszczyński 6c5232ccc5 fix moile 2026-07-31 15:57:27 +02:00
Mateusz Gruszczyński eab9d5c7b6 better links to workspaces 2026-07-31 14:29:37 +02:00
Mateusz Gruszczyński c6e9a3dd43 icons and logos 2026-07-31 14:15:44 +02:00
Mateusz Gruszczyński 618e5ae0fb page style 2026-07-31 11:22:00 +02:00
Mateusz Gruszczyński f7afee886b multi mode 2026-07-31 10:10:19 +02:00
Mateusz Gruszczyński e39a1154e6 example chost for ezternal files domain 2026-07-31 00:22:11 +02:00
Mateusz Gruszczyński 4edc511272 some changes 2026-07-30 23:57:35 +02:00
Mateusz Gruszczyński c5ca8e8d0b security upgrade 2026-07-30 11:55:33 +02:00
Mateusz Gruszczyński a9d97fa763 security upgrade 2026-07-30 11:55:22 +02:00
127 changed files with 15709 additions and 1829 deletions
+6 -1
View File
@@ -11,4 +11,9 @@ README.md
Dockerfile*
docker-compose*.yml
migrate/
scripts/*.txt
scripts/*.txt
tests/
# Downloaded in the browser-libs Docker stage
static/libs/mermaid/
static/libs/highlight/
+5
View File
@@ -32,10 +32,15 @@ RUST_LOG=rustpad=info,tower_http=warn
# Maximum upload size
UPLOAD_MAX_SIZE_MB=20
GUEST_UPLOAD_ENABLED=false
GUEST_UPLOAD_MAX_SIZE_MB=5
# Attachment storage: local or s3
STORAGE_DRIVER=local
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...)
# For Docker Garage run: docker compose --profile s3 up -d
+5 -1
View File
@@ -14,4 +14,8 @@ venv
.venv
migrate/etherpad-dry-run-report.json
data/garage
scripts/*.txt
scripts/*.txt
# Generated by scripts/update_browser_libs.py
/static/libs/mermaid/
/static/libs/highlight/
Generated
+1 -1
View File
@@ -2581,7 +2581,7 @@ dependencies = [
[[package]]
name = "rustpad"
version = "0.2.6"
version = "0.2.51"
dependencies = [
"argon2",
"aws-config",
+2 -2
View File
@@ -1,10 +1,10 @@
[package]
name = "rustpad"
version = "0.2.6"
version = "0.2.51"
edition = "2024"
rust-version = "1.94"
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
license = "MIT"
license = "Source-Available Code / Dual-Licensed"
[dependencies]
argon2 = "0.5"
+11
View File
@@ -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
WORKDIR /app
@@ -5,6 +14,8 @@ COPY Cargo.toml Cargo.lock ./
COPY migrations ./migrations
COPY src ./src
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
+48 -7
View File
@@ -8,7 +8,7 @@ RustPad is a collaborative Markdown editor with standalone notes and workspaces.
./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
@@ -16,14 +16,16 @@ The script creates `data/db` and `data/files`, builds the project, and starts it
- Nicknames stored in `localStorage`.
- Change authors shown in history.
- 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.
- Owner color displayed next to each line.
- 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.
- History with snippets, previews, and version restore.
- 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.
## Fenced code blocks and language aliases
@@ -80,19 +82,26 @@ In Docker, both directories are located under `/data`.
## 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.
## 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
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
@@ -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.
## 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
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=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>`.
@@ -276,3 +299,21 @@ SQL is selected explicitly by database engine. Application code uses logical que
- `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.
## 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
Regular → Executable
+30 -5
View File
@@ -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.
export ASSET_VERSION="${ASSET_VERSION:-dev-$(date +%s)}"
if command -v cargo >/dev/null 2>&1; then
echo "Cleaning RustPad build artifacts..."
cargo clean --package rustpad
update_browser_libs() {
local python_bin=""
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..."
exec cargo run --package rustpad
if [[ -z "$python_bin" ]]; then
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
if command -v docker >/dev/null 2>&1; then
export IMAGE_TAG="${IMAGE_TAG:-dev}"
export BROWSER_LIBS_REFRESH="${BROWSER_LIBS_REFRESH:-dev-$(date +%s)}"
echo "Starting RustPad with Docker..."
exec docker compose up --build --force-recreate --remove-orphans
+2
View File
@@ -3,6 +3,8 @@ services:
build:
context: .
dockerfile: Dockerfile
args:
BROWSER_LIBS_REFRESH: ${BROWSER_LIBS_REFRESH:-manual}
image: rustpad:${IMAGE_TAG:-local}
restart: unless-stopped
env_file:
+29
View 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";
}
}
+26
View File
@@ -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
+5
View File
@@ -12,3 +12,8 @@ root_domain = ".s3.garage.localhost"
[admin]
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"
+2 -1
View File
@@ -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;
+2
View File
@@ -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;
+3
View File
@@ -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;
@@ -0,0 +1 @@
ALTER TABLE user_editor_preferences ADD COLUMN navbar_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;
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE notes ADD COLUMN created_by_guest_id TEXT;
ALTER TABLE pads ADD COLUMN created_by_guest_id TEXT;
+3
View File
@@ -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;
@@ -0,0 +1 @@
ALTER TABLE user_editor_preferences ADD COLUMN navbar_collapsed BOOLEAN NOT NULL DEFAULT FALSE;
+2 -1
View File
@@ -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;
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE notes ADD COLUMN created_by_guest_id TEXT;
ALTER TABLE pads ADD COLUMN created_by_guest_id TEXT;
+3
View File
@@ -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 @@
ALTER TABLE user_editor_preferences ADD COLUMN navbar_collapsed INTEGER NOT NULL DEFAULT 0;
+21
View File
@@ -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
}
+83
View File
@@ -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
+651
View File
@@ -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())
+17 -9
View File
@@ -96,7 +96,7 @@ pub async fn create_resource_access_token(
Ok(response)
}
pub async fn verify_resource_access_token(
pub(crate) async fn verify_password_access_token(
state: &SharedState,
kind: &str,
slug: &str,
@@ -105,13 +105,6 @@ pub async fn verify_resource_access_token(
let Some(token) = token.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(false);
};
if crate::auth::share_link_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(
state.db.kind(),
queries::RESOURCE_ACCESS_TOKENS_VALID_COUNT,
@@ -122,7 +115,22 @@ pub async fn verify_resource_access_token(
.bind(Utc::now().to_rfc3339())
.fetch_one(state.db.pool())
.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 {
+246 -120
View File
@@ -16,6 +16,7 @@ pub async fn upload_pad_file(
Path(slug): Path<String>,
mut multipart: Multipart,
) -> 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 access_token: Option<String> = None;
let mut file: Option<(String, Vec<u8>)> = None;
@@ -45,8 +46,8 @@ pub async fn upload_pad_file(
.bytes()
.await
.map_err(|_| ApiError::bad_request("Failed to read the file"))?;
if bytes.len() > state.upload_max_size_bytes {
return Err(ApiError::payload_too_large(state.upload_max_size_bytes));
if bytes.len() > upload_max_size_bytes {
return Err(ApiError::payload_too_large(upload_max_size_bytes));
}
file = Some((filename, bytes.to_vec()));
}
@@ -60,21 +61,14 @@ pub async fn upload_pad_file(
&headers,
)
.await?;
require_upload_permission(
&state,
&headers,
"pad",
&slug,
resource_request_token(&headers, "pad", &slug, access_token.as_deref()),
)
.await?;
let level = if db::verify_pad_password(&pad, password.as_deref())
|| (pad.is_private == 0 && pad.password_hash.is_none())
{
AccessLevel::Write
} else {
combined_token_access_level(
request_access_level(
&state,
&headers,
"pad",
&slug,
resource_request_token(&headers, "pad", &slug, access_token.as_deref()),
@@ -106,7 +100,11 @@ pub async fn upload_pad_file(
stored = format!("{stem}-{}{}", db::random_suffix(6), ext);
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)
.first_or_octet_stream()
.to_string();
@@ -116,15 +114,23 @@ pub async fn upload_pad_file(
.put(&key, bytes.clone().into(), &mime, &cache_control)
.await
.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?;
Ok(Json(serde_json::json!({"name": stored, "url": url, "mime_type": mime})))
db::register_pad_file(
&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 {
if content.contains(url) {
return true;
}
for marker in ["[file=", "[image=", "[img="] {
for marker in ["[file=", "[image=", "[img=", "[video="] {
let mut remaining = content;
while let Some(index) = remaining.find(marker) {
let after = &remaining[index + marker.len()..];
@@ -143,6 +149,26 @@ pub(super) fn content_references_file(content: &str, filename: &str, url: &str)
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(
State(state): State<SharedState>,
headers: HeaderMap,
@@ -160,7 +186,12 @@ pub async fn pad_files(
.await?;
let mut files = db::list_pad_files(&state.db, pad.id).await?;
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 {
db::set_pad_file_attached(&state.db, file.id, attached).await?;
file.is_attached = attached;
@@ -171,6 +202,10 @@ pub async fn pad_files(
};
}
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))
}
@@ -190,10 +225,18 @@ pub async fn delete_pad_file(
&headers,
)
.await?;
if !crate::auth::is_resource_owner(&state, "pad", &pad.slug, bearer_token(&headers))
.await
.unwrap_or(false)
{
let account_owner = crate::auth::is_resource_owner(
&state,
"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"));
}
let file = db::find_pad_file(&state.db, pad.id, file_id)
@@ -212,6 +255,7 @@ pub async fn upload_note_file(
Path((workspace_slug, note_slug)): Path<(String, String)>,
mut multipart: Multipart,
) -> 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 access_token: Option<String> = None;
let mut file: Option<(String, Vec<u8>)> = None;
@@ -241,8 +285,8 @@ pub async fn upload_note_file(
.bytes()
.await
.map_err(|_| ApiError::bad_request("Failed to read the file"))?;
if bytes.len() > state.upload_max_size_bytes {
return Err(ApiError::payload_too_large(state.upload_max_size_bytes));
if bytes.len() > upload_max_size_bytes {
return Err(ApiError::payload_too_large(upload_max_size_bytes));
}
file = Some((filename, bytes.to_vec()));
}
@@ -258,27 +302,14 @@ pub async fn upload_note_file(
)
.await?;
require_upload_permission(
&state,
&headers,
"workspace",
&workspace_slug,
resource_request_token(
&headers,
"workspace",
&workspace_slug,
access_token.as_deref(),
),
)
.await?;
let level = if db::verify_workspace_password(&workspace, password.as_deref())
|| (workspace.is_private == 0 && workspace.password_hash.is_none())
{
AccessLevel::Write
} else {
combined_token_access_level(
request_access_level(
&state,
&headers,
"workspace",
&workspace_slug,
resource_request_token(&headers, "workspace", &workspace_slug, access_token.as_deref()),
@@ -310,7 +341,11 @@ pub async fn upload_note_file(
stored = format!("{stem}-{}{}", db::random_suffix(6), ext);
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)
.first_or_octet_stream()
.to_string();
@@ -320,8 +355,16 @@ pub async fn upload_note_file(
.put(&key, bytes.clone().into(), &mime, &cache_control)
.await
.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?;
Ok(Json(serde_json::json!({"name": stored, "url": url, "mime_type": mime})))
db::register_note_file(
&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(
@@ -346,8 +389,9 @@ pub async fn delete_note(
{
AccessLevel::Write
} else {
combined_token_access_level(
request_access_level(
&state,
&headers,
"workspace",
&workspace_slug,
resource_request_token(&headers, "workspace", &workspace_slug, payload.access_token.as_deref()),
@@ -356,9 +400,9 @@ pub async fn delete_note(
.await?
};
require_write(level)?;
if note.protected {
return Err(ApiError::bad_request(
"This note is protected and cannot be deleted",
if note.protected && !requester_owns_note(&state, &headers, &workspace, &note).await? {
return Err(ApiError::forbidden(
"This note is protected. Only its owner can delete it.",
));
}
for file in db::list_note_files(&state.db, note.id).await? {
@@ -394,7 +438,12 @@ pub async fn note_files(
.await?;
let mut files = db::list_note_files(&state.db, note.id).await?;
for file in &mut files {
let attached = content_references_file(&note.content, &file.filename, &file.url);
let attached = content_references_stored_file(
&note.content,
&file.filename,
&file.url,
state.files_public_url.as_deref(),
);
if attached != file.is_attached {
db::set_note_file_attached(&state.db, file.id, attached).await?;
file.is_attached = attached;
@@ -405,6 +454,10 @@ pub async fn note_files(
};
}
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))
}
@@ -425,27 +478,30 @@ pub async fn delete_note_file(
&headers,
)
.await?;
let workspace_owner = crate::auth::is_resource_owner(
&state,
"workspace",
&workspace.slug,
bearer_token(&headers),
)
.await
.unwrap_or(false);
let note_owner = crate::auth::optional_user(&state, &headers)
.await
.ok()
.flatten()
.and_then(|user| {
note.created_by
.as_deref()
.map(|creator| creator == user.nickname)
})
.unwrap_or(false);
if !workspace_owner && !note_owner {
let level = if db::verify_workspace_password(&workspace, payload.password.as_deref())
|| (workspace.is_private == 0 && workspace.password_hash.is_none())
{
AccessLevel::Write
} else {
request_access_level(
&state,
&headers,
"workspace",
&workspace_slug,
resource_request_token(
&headers,
"workspace",
&workspace_slug,
payload.access_token.as_deref(),
),
bearer_token(&headers),
)
.await?
};
require_write(level)?;
if note.protected && !requester_owns_note(&state, &headers, &workspace, &note).await? {
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)
@@ -461,60 +517,44 @@ pub async fn delete_note_file(
async fn require_upload_permission(
state: &SharedState,
headers: &HeaderMap,
kind: &str,
slug: &str,
resource_token: Option<&str>,
) -> Result<(), ApiError> {
let permission = crate::auth::share_link_permission(state, kind, slug, resource_token)
.await
.map_err(|error| ApiError::forbidden(&error.message))?;
if permission.as_deref() == Some("rw") {
return Ok(());
}
) -> Result<usize, ApiError> {
upload_limit_for_request(state, headers)
.await?
.ok_or_else(|| ApiError::forbidden("File uploads are disabled for guests."))
}
let user = crate::auth::optional_user(state, headers)
.await
.map_err(|error| ApiError::forbidden(&error.message))?;
if user.is_some() {
return Ok(());
}
#[derive(Debug, Default, Deserialize)]
pub struct FileDownloadQuery {
#[serde(default)]
download: Option<String>,
}
match permission.as_deref() {
Some("ro") => Err(ApiError::forbidden("Read-only access.")),
_ => Err(ApiError::forbidden(
"Log in or use a read-write share link to upload files.",
)),
impl FileDownloadQuery {
fn force_download(&self) -> bool {
self.download.as_deref().is_some_and(|value| {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "download"
)
})
}
}
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, &token, &filename).await
}
pub async fn download_legacy_file(
State(state): State<SharedState>,
Path((directory, filename)): Path<(String, String)>,
) -> Result<Response, ApiError> {
let Some((id_part, token)) = directory.split_once('_') else {
return Err(ApiError::not_found_file());
};
let id: i64 = id_part.parse().map_err(|_| ApiError::not_found_file())?;
let owner = db::find_file_owner(&state.db, token)
.await?
.ok_or_else(ApiError::not_found_file)?;
if owner.id != id {
return Err(ApiError::not_found_file());
}
serve_token_file(&state, token, &filename).await
serve_token_file(&state, &headers, &token, &filename, query.force_download()).await
}
async fn serve_token_file(
state: &SharedState,
headers: &HeaderMap,
token: &str,
filename: &str,
force_download: bool,
) -> Result<Response, ApiError> {
let safe = sanitize_filename(filename);
if safe != filename {
@@ -534,15 +574,44 @@ async fn serve_token_file(
.get_local_with_legacy(&key, &legacy_key)
.await
.map_err(|_| ApiError::not_found_file())?;
let total_len = bytes.len();
let guessed_mime = mime_guess::from_path(&safe).first_or_octet_stream();
let inline_image = is_safe_inline_image_mime(guessed_mime.essence_str());
let served_mime = if inline_image {
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 inline_image { "inline" } else { "attachment" };
let mut response = bytes.into_response();
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(
header::CONTENT_TYPE,
HeaderValue::from_str(served_mime)
@@ -553,6 +622,20 @@ async fn serve_token_file(
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"),
@@ -575,6 +658,52 @@ async fn serve_token_file(
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,
@@ -588,6 +717,13 @@ fn is_safe_inline_image_mime(value: &str) -> bool {
)
}
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 {
let name = std::path::Path::new(value)
.file_name()
@@ -611,15 +747,5 @@ fn sanitize_filename(value: &str) -> String {
}
#[cfg(test)]
mod tests {
use super::is_safe_inline_image_mime;
#[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"));
}
}
#[path = "../tests/api_files.rs"]
mod tests;
+641 -132
View File
File diff suppressed because it is too large Load Diff
+326 -64
View File
@@ -14,6 +14,15 @@ pub struct CreatePadRequest {
name: String,
#[serde(default)]
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)]
@@ -27,6 +36,7 @@ pub struct PadInfo {
slug: String,
title: String,
protected: bool,
access_level: String,
allow_public_task_updates: bool,
public_page_unprotected: bool,
public_page_enabled: bool,
@@ -34,6 +44,8 @@ pub struct PadInfo {
created_at: String,
updated_at: String,
can_delete_files: bool,
can_upload_files: bool,
upload_max_size_bytes: Option<usize>,
global_color: Option<String>,
note_color: Option<String>,
authorship_mode: String,
@@ -42,11 +54,14 @@ pub struct PadInfo {
editor_line_numbers: bool,
preview_line_numbers: bool,
line_links: bool,
toolbar_collapsed: bool,
navbar_collapsed: bool,
font_family: String,
font_size: i64,
personal_editor_settings: bool,
can_save_editor_settings: bool,
can_manage_authorship: bool,
can_set_password: bool,
files: Vec<MarkdownFileReference>,
}
@@ -57,6 +72,7 @@ pub async fn create_pad(
) -> Result<(StatusCode, Json<CreatePadResponse>), ApiError> {
let title = validate_name(&payload.name, "Note name")?;
let password = validate_password(payload.password.as_deref())?;
let initial_content = validate_initial_content(payload.content.as_deref())?;
let base = slugify(title);
if base.is_empty() {
return Err(ApiError::bad_request(
@@ -64,17 +80,26 @@ pub async fn create_pad(
));
}
let slug = unique_pad_slug(&state, &base).await?;
let pad = db::create_pad(&state.db, &slug, title, password).await?;
if let Some(user) = crate::auth::optional_user(&state, &headers)
let account_user = crate::auth::optional_user(&state, &headers)
.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))
.bind(user.id)
.bind(&pad.slug)
.execute(state.db.pool())
.await?;
}
if let Some(content) = initial_content {
db::save_pad_revision(&state.db, pad.id, content, author.as_deref(), "[]").await?;
}
Ok((
StatusCode::CREATED,
Json(CreatePadResponse {
@@ -92,34 +117,36 @@ pub async fn pad_info(
let pad = db::find_pad(&state.db, &slug)
.await?
.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,
&headers,
"pad",
&pad.slug,
pad.is_private,
pad.password_hash.is_some(),
)
.await?;
let (global_color, note_color) = editor_colors(&state, &headers, "pad", &slug).await?;
let (editor_preferences, personal_editor_settings) = user_editor_preferences(
&state,
&headers,
db::EditorPreferenceResource::Pad(pad.id),
)
.await?;
let (editor_preferences, personal_editor_settings) =
user_editor_preferences(&state, &headers, db::EditorPreferenceResource::Pad(pad.id))
.await?;
let resource_editor_settings =
db::load_resource_editor_settings(&state.db, "pad", &slug).await?;
let can_manage_authorship = crate::auth::is_resource_owner(
&state,
"pad",
&slug,
user_session_token(&headers),
)
.await
.unwrap_or(false);
let can_save_editor_settings = personal_editor_settings
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);
let password_write_access = has_password_write_access(&state, &headers, "pad", &slug).await?;
let can_manage_authorship =
can_manage_resource_settings(account_owner, guest_owner, password_write_access);
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?;
if pad.is_private == 0
&& pad.password_hash.is_some()
&& !db::pad_public_page_disabled(&state.db, pad.id).await?
&& !db::pad_public_page_enabled(&state.db, pad.id).await?
{
@@ -129,6 +156,7 @@ pub async fn pad_info(
slug: pad.slug,
title: pad.title,
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?,
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?,
@@ -136,6 +164,8 @@ pub async fn pad_info(
created_at: db::normalize_timestamp(&pad.created_at),
updated_at: db::normalize_timestamp(&pad.updated_at),
can_delete_files: can_manage_authorship,
can_upload_files,
upload_max_size_bytes,
global_color,
note_color,
authorship_mode: resource_editor_settings.authorship_mode,
@@ -144,15 +174,52 @@ pub async fn pad_info(
editor_line_numbers: editor_preferences.editor_line_numbers,
preview_line_numbers: editor_preferences.preview_line_numbers,
line_links: editor_preferences.line_links,
toolbar_collapsed: editor_preferences.toolbar_collapsed,
navbar_collapsed: editor_preferences.navbar_collapsed,
font_family: editor_preferences.font_family,
font_size: editor_preferences.font_size,
personal_editor_settings,
can_save_editor_settings,
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?,
}))
}
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(
State(state): State<SharedState>,
headers: HeaderMap,
@@ -162,6 +229,11 @@ pub async fn set_pad_editor_settings(
let pad = db::find_pad(&state.db, &slug)
.await?
.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(
&state,
&headers,
@@ -170,6 +242,7 @@ pub async fn set_pad_editor_settings(
"pad",
&slug,
db::EditorPreferenceResource::Pad(pad.id),
creator_can_manage_authorship,
payload,
)
.await
@@ -248,8 +321,9 @@ pub async fn publish_pad_page(
{
AccessLevel::Write
} else {
combined_token_access_level(
request_access_level(
&state,
&headers,
"pad",
&slug,
resource_request_token(&headers, "pad", &slug, payload.access_token.as_deref()),
@@ -259,6 +333,11 @@ pub async fn publish_pad_page(
};
require_write(level)?;
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 {
db::unpublish_pad(&state.db, pad.id).await?;
db::set_pad_public_page_disabled(&state.db, pad.id, true).await?;
@@ -303,8 +382,9 @@ pub async fn publish_note_page(
{
AccessLevel::Write
} else {
combined_token_access_level(
request_access_level(
&state,
&headers,
"workspace",
&workspace_slug,
resource_request_token(
@@ -319,6 +399,11 @@ pub async fn publish_note_page(
};
require_write(level)?;
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 {
db::unpublish_note(&state.db, note.id).await?;
db::set_note_public_page_disabled(&state.db, note.id, true).await?;
@@ -352,9 +437,7 @@ async fn ensure_public_page_access(
) -> Result<(), ApiError> {
let password = page_password(headers);
if let Some(pad_id) = page.pad_id {
if db::pad_public_page_unprotected(&state.db, pad_id).await? {
return Ok(());
}
let page_unprotected = db::pad_public_page_unprotected(&state.db, pad_id).await?;
let sql = match state.db.kind() {
crate::database::DatabaseKind::Postgres => "SELECT slug FROM pads WHERE id = $1",
_ => "SELECT slug FROM pads WHERE id = ?",
@@ -369,7 +452,12 @@ async fn ensure_public_page_access(
let pad = db::find_pad(&state.db, &slug)
.await?
.ok_or_else(ApiError::not_found_note)?;
if has_header_resource_access(state, headers, "pad", &slug).await? {
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(());
}
let password_ok = db::verify_pad_password(&pad, password);
@@ -385,9 +473,7 @@ async fn ensure_public_page_access(
};
}
if let Some(note_id) = page.note_id {
if db::note_public_page_unprotected(&state.db, note_id).await? {
return Ok(());
}
let page_unprotected = db::note_public_page_unprotected(&state.db, note_id).await?;
let sql = match state.db.kind() {
crate::database::DatabaseKind::Postgres => {
"SELECT w.slug FROM notes n JOIN workspaces w ON w.id = n.workspace_id WHERE n.id = $1"
@@ -406,19 +492,19 @@ async fn ensure_public_page_access(
let workspace = db::find_workspace(&state.db, &slug)
.await?
.ok_or_else(ApiError::not_found_workspace)?;
if has_header_resource_access(state, headers, "workspace", &slug).await? {
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(());
}
let password_ok = db::verify_workspace_password(&workspace, password);
check_resource_password_attempt(
state,
headers,
"workspace",
&slug,
password,
password_ok,
)
.await?;
check_resource_password_attempt(state, headers, "workspace", &slug, password, password_ok)
.await?;
if password_ok {
return Ok(());
}
@@ -440,13 +526,8 @@ pub async fn public_page(
.await?
.ok_or_else(ApiError::not_found_note)?;
ensure_public_page_access(&state, &headers, &page).await?;
let files = markdown_file_references(
&state,
page.pad_id,
page.note_id,
Some(&page.content),
)
.await?;
let files =
markdown_file_references(&state, page.pad_id, page.note_id, Some(&page.content)).await?;
Ok(Json(PublicPageResponse {
title: page.title,
content: page.content,
@@ -456,6 +537,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(
State(state): State<SharedState>,
headers: HeaderMap,
@@ -471,16 +690,18 @@ pub async fn update_public_task(
"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, &current, payload.source_line, payload.checked)
.await?;
} else {
commit_public_note_task_update(&state, &current, payload.source_line, payload.checked)
.await?;
}
let page = db::find_published_page(&state.db, &token)
.await?
.ok_or_else(ApiError::not_found_note)?;
let files = markdown_file_references(
&state,
page.pad_id,
page.note_id,
Some(&page.content),
)
.await?;
let files =
markdown_file_references(&state, page.pad_id, page.note_id, Some(&page.content)).await?;
Ok(Json(PublicPageResponse {
title: page.title,
content: page.content,
@@ -536,8 +757,9 @@ pub async fn pad_restore(
{
AccessLevel::Write
} else {
combined_token_access_level(
request_access_level(
&state,
&headers,
"pad",
&slug,
resource_request_token(&headers, "pad", &slug, payload.access_token.as_deref()),
@@ -559,19 +781,59 @@ pub async fn pad_restore(
.fetch_optional(state.db.pool())
.await?;
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) =
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 {
content,
base_revision_id,
revision_id,
updated_at,
author: Some("restore".into()),
owner_map,
client_id: "server_restore".into(),
update_id,
operation,
owner_replacements: Vec::new(),
};
let _ = state
.pad_channel(&slug)
.await
.send(RoomEvent::Document(update));
let _ = channel.send(RoomEvent::Document(update));
drop(document);
Ok(Json(serde_json::json!({"ok": true})))
}
@@ -586,14 +848,14 @@ pub(super) async fn authorized_pad(
let pad = db::find_pad(&state.db, slug)
.await?
.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 {
return Err(ApiError::not_found_note());
}
if pad.password_hash.is_some() && token_level < AccessLevel::Write {
let password_ok = db::verify_pad_password(&pad, password);
check_resource_password_attempt(state, headers, "pad", slug, password, password_ok)
.await?;
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."));
}
+146 -56
View File
@@ -10,24 +10,43 @@
mod pages;
use axum::{
Router,
Json, Router,
extract::{DefaultBodyLimit, Request},
http::{HeaderName, HeaderValue, StatusCode, header},
http::{HeaderMap, HeaderValue, Method, StatusCode, header},
middleware::{self, Next},
response::Response,
response::{IntoResponse, Response},
routing::{get, post},
};
use pages::*;
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 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(
state: SharedState,
static_dir: &str,
upload_max_size_bytes: usize,
upload_body_limit_bytes: usize,
asset_cache_max_age_seconds: u64,
) -> Router {
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(
asset_cache_max_age_seconds,
))
.expect("valid asset cache-control header");
let asset_cache_control =
HeaderValue::from_str(&crate::cache::cache_control(asset_cache_max_age_seconds))
.expect("valid asset cache-control header");
Router::new()
.route("/", get(home))
@@ -63,12 +81,19 @@ pub fn router(
.route("/health", get(health))
.route("/robots.txt", get(robots_txt))
.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(
"/files/{directory}/{filename}",
get(api::download_legacy_file),
)
.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/auth/register", post(auth::register))
.route("/api/auth/login", post(auth::login))
@@ -133,6 +158,7 @@ pub fn router(
post(api::set_pad_editor_settings),
)
.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}/files",
@@ -144,6 +170,10 @@ pub fn router(
)
.route("/api/workspaces", post(api::create_workspace))
.route("/api/workspaces/{workspace_slug}", get(api::workspace_info))
.route(
"/api/workspaces/{workspace_slug}/password",
post(api::set_workspace_password),
)
.route(
"/api/workspaces/{workspace_slug}/open",
post(api::open_workspace),
@@ -185,6 +215,10 @@ pub fn router(
axum::routing::delete(api::delete_note_file),
)
.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("/static", get(static_not_found))
.route("/static/{*path}", get(static_not_found))
@@ -199,53 +233,109 @@ pub fn router(
)
.fallback(not_found)
.method_not_allowed_fallback(method_not_allowed)
.layer(DefaultBodyLimit::max(
upload_max_size_bytes.saturating_add(1024 * 1024),
))
.layer(SetResponseHeaderLayer::if_not_present(
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))
.layer(DefaultBodyLimit::max(upload_body_limit_bytes))
.layer(TraceLayer::new_for_http().make_span_with(PathOnlyMakeSpan))
.layer(middleware::from_fn(require_csrf_token))
.layer(middleware::from_fn(apply_response_header_policy))
.with_state(state)
}
async fn add_non_asset_security_headers(request: Request, next: Next) -> Response {
let path = request.uri().path();
let is_asset = path.starts_with("/assets/");
let is_file = path.starts_with("/f/");
let mut response = next.run(request).await;
if is_file {
let headers = response.headers_mut();
headers.remove("x-frame-options");
headers.remove("cross-origin-opener-policy");
headers.remove("cross-origin-resource-policy");
headers.remove("referrer-policy");
headers.remove("permissions-policy");
} else if !is_asset {
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=()",
));
async fn require_csrf_token(request: Request, next: Next) -> Response {
let method = request.method();
let unsafe_method = method == Method::POST
|| method == Method::PUT
|| method == Method::PATCH
|| method == Method::DELETE;
if unsafe_method && !crate::security::csrf_request_is_valid(request.headers()) {
return (
StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "Security token is missing or expired. Refresh the page and try again."
})),
)
.into_response();
}
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
}
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
View File
@@ -8,12 +8,122 @@
*/
use axum::{
extract::{Path, State},
http::{HeaderValue, StatusCode, header},
extract::{OriginalUri, Path, RawQuery, State},
http::{HeaderMap, HeaderValue, StatusCode, Uri, header},
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(
state: &SharedState,
@@ -26,8 +136,25 @@ fn render_editor_page(
protected_resource_label: &str,
extra_shortcuts: &str,
) -> 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")
.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("__PARENT_TITLE__", &escape_html(parent_title))
.replace("__PARENT_URL__", &escape_html(parent_url))
@@ -49,12 +176,74 @@ pub(super) async fn health() -> &'static str {
"ok"
}
pub(super) async fn favicon() -> StatusCode {
StatusCode::NO_CONTENT
pub(super) async fn favicon() -> Response {
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 {
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(
header::CONTENT_TYPE,
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 {
Ok(Some(pad)) => 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(Some(pad)) => {
let (has_share, share) = share_token_from_query(query.as_deref());
if has_share {
return share_session_redirect(
&state,
&headers,
&uri,
"pad",
&slug,
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(
StatusCode::NOT_FOUND,
"404",
@@ -135,10 +344,25 @@ pub(super) async fn public_page(
pub(super) async fn workspace(
State(state): State<SharedState>,
headers: HeaderMap,
Path(workspace_slug): Path<String>,
RawQuery(query): RawQuery,
OriginalUri(uri): OriginalUri,
) -> Response {
match db::find_workspace(&state.db, &workspace_slug).await {
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(
"__WORKSPACE_TITLE__",
&escape_html(if workspace.is_private != 0 {
@@ -175,7 +399,10 @@ pub(super) async fn workspace(
pub(super) async fn note(
State(state): State<SharedState>,
headers: HeaderMap,
Path((workspace_slug, note_slug)): Path<(String, String)>,
RawQuery(query): RawQuery,
OriginalUri(uri): OriginalUri,
) -> Response {
let workspace = match db::find_workspace(&state.db, &workspace_slug).await {
Ok(Some(workspace)) => workspace,
@@ -197,17 +424,39 @@ pub(super) async fn note(
};
match db::find_note(&state.db, workspace.id, &note_slug).await {
Ok(Some(note)) => render_editor_page(
&state,
"note",
"note",
if workspace.is_private != 0 { "Note" } else { &note.title },
if workspace.is_private != 0 { "Workspace" } else { &workspace.title },
&format!("/w/{workspace_slug}"),
"",
"workspace",
"",
),
Ok(Some(note)) => {
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;
}
render_editor_page(
&state,
"note",
"note",
if workspace.is_private != 0 {
"Note"
} else {
&note.title
},
if workspace.is_private != 0 {
"Workspace"
} else {
&workspace.title
},
&format!("/w/{workspace_slug}"),
"",
"workspace",
"",
)
}
Ok(None) => error_response(
StatusCode::NOT_FOUND,
"404",
@@ -282,6 +531,7 @@ pub(super) fn error_response(
asset_version: &str,
) -> Response {
let html = include_str!("../../static/error.html")
.replace("__APP_THEME_BOOTSTRAP__", assets::theme_bootstrap())
.replace(
"__APP_STYLESHEET__",
&assets::stylesheet_tag(asset_version, "styles"),
@@ -312,3 +562,7 @@ fn escape_html(value: &str) -> String {
.replace('"', "&quot;")
.replace('\'', "&#39;")
}
#[cfg(test)]
#[path = "../tests/app_pages.rs"]
mod tests;
+39 -9
View File
@@ -17,9 +17,12 @@ const MODULES: &[&str] = &[
"authorship",
"auth-ui",
"clipboard",
"collaboration",
"collaboration-session",
"editor-format",
"emoji-data",
"emoji-picker",
"image-alias",
"image-upload",
"logger",
"line-links",
@@ -28,10 +31,14 @@ const MODULES: &[&str] = &[
"note-api",
"note-editor",
"note-files",
"preview-edit",
"render-queue",
"session",
"socket",
"toast",
"theme",
"url-state",
"vendor-libs",
"security",
];
@@ -45,9 +52,20 @@ pub fn render_html(
entrypoint: &str,
) -> Response {
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
.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_ENTRYPOINT__", &urls.entrypoint(entrypoint))
.replace(
@@ -58,16 +76,25 @@ pub fn render_html(
"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();
response.headers_mut().insert(
header::CACHE_CONTROL,
HeaderValue::from_static("private, no-store"),
HeaderValue::from_static("private, no-cache, no-store"),
);
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 {
AssetUrls::new(asset_version).stylesheet(name)
}
@@ -76,12 +103,14 @@ fn frontend_config(
frontend_log_level: &str,
upload_max_size_bytes: usize,
external_auth: bool,
asset_version: &str,
) -> String {
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),
upload_max_size_bytes,
external_auth,
escape_js_string(asset_version),
)
}
@@ -99,10 +128,11 @@ impl<'a> AssetUrls<'a> {
}
fn stylesheet(&self, name: &str) -> String {
format!(
r#"<link rel="stylesheet" href="{}">"#,
self.url(&format!("css/{name}.css"))
)
self.stylesheet_path(&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 {
+604 -80
View File
File diff suppressed because it is too large Load Diff
+826
View File
@@ -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
View File
@@ -55,9 +55,12 @@ pub struct Config {
pub files_dir: String,
pub storage: crate::storage::StorageConfig,
pub upload_max_size_bytes: usize,
pub guest_upload_enabled: bool,
pub guest_upload_max_size_bytes: usize,
pub asset_version: String,
pub asset_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 registration_enabled: bool,
pub account_confirmation_required: bool,
@@ -76,13 +79,17 @@ impl Config {
let host = values.get("APP_HOST", "127.0.0.1").parse()?;
let port = values.get("APP_PORT", "3000").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 =
values.positive_i64("ANONYMOUS_ACCESS_TOKEN_TTL_DAYS", 7)?;
let user_session_ttl_days = values.positive_i64("USER_SESSION_TTL_DAYS", 3)?;
let unconfirmed_account_ttl_days =
values.positive_i64("UNCONFIRMED_ACCOUNT_TTL_DAYS", 3)?;
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
.get("STORAGE_DRIVER", "local")
@@ -104,10 +111,6 @@ impl Config {
_ => 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 ldap = match authorization_type {
AuthorizationType::Local => None,
@@ -164,14 +167,18 @@ impl Config {
static_dir: values.get("STATIC_DIR", "static"),
files_dir,
storage,
upload_max_size_bytes: upload_max_size_mb
.checked_mul(1024 * 1024)
.ok_or("UPLOAD_MAX_SIZE_MB is too large")?,
upload_max_size_bytes: megabytes_to_bytes("UPLOAD_MAX_SIZE_MB", upload_max_size_mb)?,
guest_upload_enabled,
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_cache_max_age_seconds: values
.nonnegative_u64("ASSET_CACHE_MAX_AGE_SECONDS", 600)?,
file_cache_max_age_seconds: values
.nonnegative_u64("FILE_CACHE_MAX_AGE_SECONDS", 600)?,
files_public_url,
smtp,
registration_enabled: values.bool("REGISTRATION_ENABLED", false)?,
account_confirmation_required: values.bool("ACCOUNT_CONFIRMATION_REQUIRED", false)?,
@@ -209,4 +216,39 @@ impl Config {
}
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
View File
@@ -79,34 +79,5 @@ fn normalize_smtp_from(value: String) -> Result<String, Box<dyn std::error::Erro
}
#[cfg(test)]
mod tests {
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());
}
}
#[path = "../tests/config_smtp.rs"]
mod tests;
+3
View File
@@ -16,8 +16,11 @@ const KNOWN_CONFIG_KEYS: &[&str] = &[
"DATABASE_MAX_CONNECTIONS",
"STATIC_DIR",
"FILES_DIR",
"FILES_PUBLIC_URL",
"STORAGE_DRIVER",
"UPLOAD_MAX_SIZE_MB",
"GUEST_UPLOAD_ENABLED",
"GUEST_UPLOAD_MAX_SIZE_MB",
"ASSET_CACHE_MAX_AGE_SECONDS",
"FILE_CACHE_MAX_AGE_SECONDS",
"REGISTRATION_ENABLED",
+12 -4
View File
@@ -29,10 +29,18 @@ impl Database {
sqlx::any::install_default_drivers();
let kind = DatabaseKind::from_url(url)?;
debug!(?kind, max_connections, "initializing database pool");
let pool = AnyPoolOptions::new()
.max_connections(max_connections)
.connect(url)
.await?;
let mut options = AnyPoolOptions::new().max_connections(max_connections);
if kind == DatabaseKind::MySql {
options = options.after_connect(|connection, _metadata| {
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 {
debug!("applying SQLite connection pragmas");
sqlx::query(queries::get(kind, queries::SQLITE_FOREIGN_KEYS_ON))
+30 -25
View File
@@ -15,6 +15,8 @@ pub struct EditorPreferences {
pub editor_line_numbers: bool,
pub preview_line_numbers: bool,
pub line_links: bool,
pub toolbar_collapsed: bool,
pub navbar_collapsed: bool,
pub font_family: String,
pub font_size: i64,
}
@@ -26,6 +28,8 @@ impl Default for EditorPreferences {
editor_line_numbers: true,
preview_line_numbers: false,
line_links: false,
toolbar_collapsed: false,
navbar_collapsed: false,
font_family: "mono".into(),
font_size: 14,
}
@@ -78,14 +82,11 @@ pub async fn load_editor_preferences(
user_id: i64,
resource: EditorPreferenceResource,
) -> Result<Option<EditorPreferences>, sqlx::Error> {
let Some(row) = sqlx::query(queries::get(
pool.kind(),
preference_select_query(resource),
))
.bind(user_id)
.bind(resource_id(resource))
.fetch_optional(pool.pool())
.await?
let Some(row) = sqlx::query(queries::get(pool.kind(), preference_select_query(resource)))
.bind(user_id)
.bind(resource_id(resource))
.fetch_optional(pool.pool())
.await?
else {
return Ok(None);
};
@@ -95,14 +96,16 @@ pub async fn load_editor_preferences(
editor_line_numbers: row.try_get::<i64, _>(1)? != 0,
preview_line_numbers: row.try_get::<i64, _>(2)? != 0,
line_links: row.try_get::<i64, _>(3)? != 0,
font_family: crate::row_decode::text(&row, 4)?,
font_size: row.try_get(5)?,
toolbar_collapsed: row.try_get::<i64, _>(4)? != 0,
navbar_collapsed: row.try_get::<i64, _>(5)? != 0,
font_family: crate::row_decode::text(&row, 6)?,
font_size: row.try_get(7)?,
}))
}
pub async fn save_editor_configuration(
pool: &Database,
user_id: i64,
user_id: Option<i64>,
resource: EditorPreferenceResource,
preferences: Option<&EditorPreferences>,
resource_settings: Option<(&str, &str, &ResourceEditorSettings)>,
@@ -110,20 +113,22 @@ pub async fn save_editor_configuration(
let mut tx = pool.pool().begin().await?;
if let Some(preferences) = preferences {
sqlx::query(queries::get(
pool.kind(),
preference_upsert_query(resource),
))
.bind(user_id)
.bind(resource_id(resource))
.bind(preferences.compact_view)
.bind(preferences.editor_line_numbers)
.bind(preferences.preview_line_numbers)
.bind(preferences.line_links)
.bind(&preferences.font_family)
.bind(preferences.font_size)
.execute(&mut *tx)
.await?;
let user_id = user_id.ok_or_else(|| {
sqlx::Error::Protocol("user id is required for personal editor preferences".into())
})?;
sqlx::query(queries::get(pool.kind(), preference_upsert_query(resource)))
.bind(user_id)
.bind(resource_id(resource))
.bind(preferences.compact_view)
.bind(preferences.editor_line_numbers)
.bind(preferences.preview_line_numbers)
.bind(preferences.line_links)
.bind(preferences.toolbar_collapsed)
.bind(preferences.navbar_collapsed)
.bind(&preferences.font_family)
.bind(preferences.font_size)
.execute(&mut *tx)
.await?;
}
if let Some((resource_kind, resource_slug, settings)) = resource_settings {
+188 -4
View File
@@ -52,6 +52,7 @@ pub struct Workspace {
pub created_at: String,
pub updated_at: String,
pub is_private: i64,
pub created_by_guest_id: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
@@ -68,6 +69,15 @@ pub struct Note {
pub owner_map: String,
pub protected: bool,
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)]
@@ -82,6 +92,7 @@ struct SqliteNote {
owner_map: String,
protected: i64,
created_by: Option<String>,
created_by_guest_id: Option<String>,
}
impl From<SqliteNote> for Note {
@@ -97,6 +108,7 @@ impl From<SqliteNote> for Note {
owner_map: value.owner_map,
protected: value.protected != 0,
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,
title: &str,
password: Option<&str>,
created_by_guest_id: Option<&str>,
) -> Result<Workspace, sqlx::Error> {
let password_hash = password
.filter(|value| !value.is_empty())
@@ -139,6 +152,7 @@ pub async fn create_workspace(
.bind(slug)
.bind(title)
.bind(password_hash)
.bind(created_by_guest_id)
.execute(pool.pool())
.await?;
@@ -148,12 +162,29 @@ pub async fn create_workspace(
.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 {
match (
&workspace.password_hash,
password.filter(|value| !value.is_empty()),
) {
(None, _) => true,
(None, _) => false,
(Some(hash), Some(password)) => PasswordHash::new(hash)
.ok()
.and_then(|parsed| {
@@ -260,6 +291,7 @@ pub async fn create_note(
title: &str,
protected: bool,
created_by: Option<&str>,
created_by_guest_id: Option<&str>,
) -> Result<Note, sqlx::Error> {
sqlx::query(queries::get(pool.kind(), queries::Q005))
.bind(workspace_id)
@@ -267,6 +299,7 @@ pub async fn create_note(
.bind(title)
.bind(protected)
.bind(created_by)
.bind(created_by_guest_id)
.execute(pool.pool())
.await?;
@@ -310,6 +343,68 @@ pub async fn save_revision(
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> {
sqlx::query_as::<_, Revision>(queries::get(pool.kind(), queries::Q010))
.bind(note_id)
@@ -386,8 +481,8 @@ pub struct Pad {
pub password_hash: Option<String>,
pub created_at: String,
pub updated_at: String,
pub owner_map: String,
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> {
@@ -402,6 +497,7 @@ pub async fn create_pad(
slug: &str,
title: &str,
password: Option<&str>,
created_by_guest_id: Option<&str>,
) -> Result<Pad, sqlx::Error> {
let password_hash = password
.filter(|value| !value.is_empty())
@@ -410,6 +506,7 @@ pub async fn create_pad(
.bind(slug)
.bind(title)
.bind(password_hash)
.bind(created_by_guest_id)
.execute(pool.pool())
.await?;
@@ -419,12 +516,26 @@ pub async fn create_pad(
.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 {
match (
&pad.password_hash,
password.filter(|value| !value.is_empty()),
) {
(None, _) => true,
(None, _) => false,
(Some(hash), Some(password)) => PasswordHash::new(hash)
.ok()
.and_then(|parsed| {
@@ -467,6 +578,63 @@ pub async fn save_pad_revision(
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(
pool: &Database,
pad_id: i64,
@@ -477,6 +645,16 @@ pub async fn list_pad_revisions(
.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 {
fn from_row(row: &'r AnyRow) -> Result<Self, sqlx::Error> {
Ok(Self {
@@ -487,6 +665,7 @@ impl<'r> sqlx::FromRow<'r, AnyRow> for Workspace {
created_at: crate::row_decode::text(row, "created_at")?,
updated_at: crate::row_decode::text(row, "updated_at")?,
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")?,
protected: protected != 0,
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")?,
created_at: crate::row_decode::text(row, "created_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")?,
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
View File
@@ -15,6 +15,10 @@ pub struct PublishedPage {
pub pad_id: Option<i64>,
pub note_id: Option<i64>,
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 content: String,
pub updated_at: String,
@@ -26,6 +30,10 @@ struct PublishedPageRow {
pad_id: Option<i64>,
note_id: Option<i64>,
allow_task_updates: i64,
resource_slug: String,
workspace_id: Option<i64>,
workspace_slug: Option<String>,
owner_map: String,
title: String,
content: String,
updated_at: String,
@@ -37,6 +45,10 @@ struct PostgresPublishedPageRow {
pad_id: Option<i64>,
note_id: Option<i64>,
allow_task_updates: bool,
resource_slug: String,
workspace_id: Option<i64>,
workspace_slug: Option<String>,
owner_map: String,
title: String,
content: String,
updated_at: String,
@@ -49,6 +61,10 @@ impl From<PostgresPublishedPageRow> for PublishedPage {
pad_id: value.pad_id,
note_id: value.note_id,
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,
content: value.content,
updated_at: value.updated_at,
@@ -62,6 +78,10 @@ impl From<PublishedPageRow> for PublishedPage {
pad_id: value.pad_id,
note_id: value.note_id,
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,
content: value.content,
updated_at: value.updated_at,
@@ -310,29 +330,23 @@ pub async fn set_note_public_page_unprotected(
Ok(())
}
pub async fn update_public_task(
pool: &Database,
token: &str,
pub fn updated_public_task_content(
content: &str,
source_line: usize,
checked: bool,
) -> Result<Option<PublishedPage>, sqlx::Error> {
let Some(mut page) = find_published_page(pool, token).await? else {
return Ok(None);
};
if !page.allow_task_updates || source_line == 0 {
return Ok(Some(page));
) -> Option<String> {
if source_line == 0 {
return None;
}
let mut lines: Vec<String> = page.content.split('\n').map(str::to_owned).collect();
let Some(line) = lines.get_mut(source_line - 1) else {
return Ok(Some(page));
};
let mut lines: Vec<String> = content.split('\n').map(str::to_owned).collect();
let line = lines.get_mut(source_line - 1)?;
let bytes = line.as_bytes();
let mut i = 0usize;
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
if i >= bytes.len() || !matches!(bytes[i], b'-' | b'*' | b'+') {
return Ok(Some(page));
return None;
}
i += 1;
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')
|| bytes[i + 2] != b']'
{
return Ok(Some(page));
return None;
}
line.replace_range(i + 1..i + 2, if checked { "x" } else { " " });
page.content = 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
Some(lines.join("\n"))
}
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")?,
note_id: row.try_get("note_id")?,
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")?,
content: crate::row_decode::text(row, "content")?,
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")?,
note_id: row.try_get("note_id")?,
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")?,
content: crate::row_decode::text(row, "content")?,
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> {
let sql = match pool.kind() {
DatabaseKind::Postgres => "SELECT public_page_disabled FROM pads WHERE id = $1",
_ => "SELECT public_page_disabled FROM pads WHERE id = ?",
};
let query = queries::get(pool.kind(), queries::PAD_PUBLIC_PAGE_DISABLED);
if pool.kind() == DatabaseKind::Postgres {
return Ok(sqlx::query_scalar::<_, bool>(sql)
return Ok(sqlx::query_scalar::<_, bool>(query)
.bind(pad_id)
.fetch_one(pool.pool())
.await?);
}
Ok(sqlx::query_scalar::<_, i64>(sql)
Ok(sqlx::query_scalar::<_, i64>(query)
.bind(pad_id)
.fetch_one(pool.pool())
.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> {
let sql = match pool.kind() {
DatabaseKind::Postgres => "SELECT public_page_disabled FROM notes WHERE id = $1",
_ => "SELECT public_page_disabled FROM notes WHERE id = ?",
};
let query = queries::get(pool.kind(), queries::NOTE_PUBLIC_PAGE_DISABLED);
if pool.kind() == DatabaseKind::Postgres {
return Ok(sqlx::query_scalar::<_, bool>(sql)
return Ok(sqlx::query_scalar::<_, bool>(query)
.bind(note_id)
.fetch_one(pool.pool())
.await?);
}
Ok(sqlx::query_scalar::<_, i64>(sql)
Ok(sqlx::query_scalar::<_, i64>(query)
.bind(note_id)
.fetch_one(pool.pool())
.await?
+91
View File
@@ -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;
+44 -5
View File
@@ -12,9 +12,11 @@ mod app;
mod assets;
mod auth;
mod cache;
mod collab;
mod config;
mod database;
mod db;
mod file_urls;
mod queries;
mod row_decode;
mod security;
@@ -40,6 +42,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
dotenvy::dotenv().ok();
let cli = parse_command()?;
init_tracing();
print_startup_credential();
let config = Config::load(cli.config.as_deref())?;
if matches!(cli.command, Command::CheckConfig) {
@@ -53,8 +56,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
return Ok(());
}
info!(
"RustPad version" = %config.asset_version,
Copyright="@linuxiarz.pl Mateusz Gruszczyński",
host = %config.host,
port = config.port,
database_kind = %database_kind_label(&config.database_url),
@@ -63,8 +64,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
files_dir = %config.files_dir,
storage_driver = match &config.storage { storage::StorageConfig::Local { .. } => "local", storage::StorageConfig::S3 { .. } => "s3" },
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,
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,
account_confirmation_required = config.account_confirmation_required,
share_confirmation_required = config.share_confirmation_required,
@@ -73,7 +77,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
user_session_ttl_days = config.user_session_ttl_days,
smtp_configured = config.smtp.is_some(),
authorization_type = config.authorization_type.as_str(),
asset_version = %config.asset_version,
"configuration loaded"
);
if let Some(path) = config
@@ -105,7 +108,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
config.asset_version.clone(),
storage,
config.upload_max_size_bytes,
config.guest_upload_enabled,
config.guest_upload_max_size_bytes,
config.file_cache_max_age_seconds,
config.files_public_url.clone(),
config.smtp.clone(),
config.registration_enabled && config.ldap.is_none(),
config.account_confirmation_required,
@@ -141,18 +147,36 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
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(
state,
&config.static_dir,
config.upload_max_size_bytes,
config.upload_body_limit_bytes(),
config.asset_cache_max_age_seconds,
);
let address = SocketAddr::new(config.host, config.port);
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)
.with_graceful_shutdown(shutdown_signal())
.await?;
@@ -160,6 +184,17 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
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)]
enum Command {
Run,
@@ -265,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> {
let path = match db.kind() {
DatabaseKind::Sqlite => std::path::Path::new("migrations/sqlite"),
+33 -24
View File
@@ -26,6 +26,7 @@ pub enum Query {
AUTH_LATEST_CONFIRMATION_CREATED_AT,
AUTH_UPDATE_NICKNAME,
AUTH_UPDATE_EDITOR_COLOR,
AUTH_UPDATE_THEME,
AUTH_EDITOR_COLOR_BY_USER,
RESOURCE_COLOR_BY_USER,
RESOURCE_COLOR_DELETE,
@@ -87,6 +88,7 @@ pub enum Query {
USER_SET_WORKSPACE_PRIVACY,
USER_SET_PAD_PRIVACY,
RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE,
RESOURCE_ACCESS_TOKENS_DELETE_BY_TOKEN_HASH,
RESOURCE_ACCESS_TOKENS_INSERT,
RESOURCE_ACCESS_TOKENS_VALID_COUNT,
RESOURCE_PERMISSION_DELETE_USER,
@@ -104,6 +106,13 @@ pub enum Query {
SHARE_LINK_REVOKE,
RESOURCE_PERMISSION_BY_USER,
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,
Q002,
Q003,
@@ -147,14 +156,18 @@ pub enum Query {
Q047,
Q040,
Q041,
Q042,
Q043,
Q044,
Q045,
Q048,
Q049,
Q050,
Q051,
Q054,
Q055,
Q056,
Q057,
Q058,
Q059,
}
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_UPDATE_NICKNAME: Query = Query::AUTH_UPDATE_NICKNAME;
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 RESOURCE_COLOR_BY_USER: Query = Query::RESOURCE_COLOR_BY_USER;
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 RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE: Query =
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_VALID_COUNT: Query = Query::RESOURCE_ACCESS_TOKENS_VALID_COUNT;
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 RESOURCE_PERMISSION_BY_USER: Query = Query::RESOURCE_PERMISSION_BY_USER;
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 Q002: Query = Query::Q002;
pub const Q003: Query = Query::Q003;
@@ -300,33 +323,19 @@ pub const Q046: Query = Query::Q046;
pub const Q047: Query = Query::Q047;
pub const Q040: Query = Query::Q040;
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 Q045: Query = Query::Q045;
pub const Q048: Query = Query::Q048;
pub const Q049: Query = Query::Q049;
pub const Q050: Query = Query::Q050;
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)]
mod tests {
use super::*;
#[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());
}
}
}
#[path = "../tests/queries.rs"]
mod tests;
+73 -29
View File
@@ -31,6 +31,7 @@ pub fn get(query: Query) -> &'static str {
Query::AUTH_UPDATE_EDITOR_COLOR => {
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::RESOURCE_COLOR_BY_USER => {
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 = ?"#
}
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(CASE WHEN navbar_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 => {
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(CASE WHEN navbar_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 => {
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, navbar_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), navbar_collapsed = VALUES(navbar_collapsed), font_family = VALUES(font_family), font_size = VALUES(font_size), updated_at = CURRENT_TIMESTAMP"#
}
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, navbar_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), navbar_collapsed = VALUES(navbar_collapsed), font_family = VALUES(font_family), font_size = VALUES(font_size), updated_at = CURRENT_TIMESTAMP"#
}
Query::EDITOR_PREFERENCES_DELETE_BY_USER => {
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 = ?"#
}
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_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_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 => {
r#"INSERT INTO user_sessions (token, user_id, expires_at) VALUES (?, ?, ?)"#
}
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 => {
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 => {
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 => {
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 = ?"#
}
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 => {
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 => {
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 => {
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 => {
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"#
}
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 => {
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 => {
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 => {
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 => {
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 = ?"#
}
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 => {
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 => {
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 => {
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 => {
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 => {
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"#
}
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 => {
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::Q020 => r#"INSERT INTO published_pages (token, note_id) VALUES (?, ?)"#,
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::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::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::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 => {
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 = ?"#
}
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 = ?"#,
}
}
#[cfg(test)]
#[path = "../tests/queries_mysql.rs"]
mod tests;
+65 -27
View File
@@ -31,6 +31,7 @@ pub fn get(query: Query) -> &'static str {
Query::AUTH_UPDATE_EDITOR_COLOR => {
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::RESOURCE_COLOR_BY_USER => {
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"#
}
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, (CASE WHEN navbar_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 => {
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, (CASE WHEN navbar_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 => {
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, navbar_collapsed, font_family, font_size, updated_at) VALUES ($1, $2, NULL, $3, $4, $5, $6, $7, $8, $9, $10, 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, navbar_collapsed = EXCLUDED.navbar_collapsed, font_family = EXCLUDED.font_family, font_size = EXCLUDED.font_size, updated_at = CURRENT_TIMESTAMP::text"#
}
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, navbar_collapsed, font_family, font_size, updated_at) VALUES ($1, NULL, $2, $3, $4, $5, $6, $7, $8, $9, $10, 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, navbar_collapsed = EXCLUDED.navbar_collapsed, font_family = EXCLUDED.font_family, font_size = EXCLUDED.font_size, updated_at = CURRENT_TIMESTAMP::text"#
}
Query::EDITOR_PREFERENCES_DELETE_BY_USER => {
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"#
}
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_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_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 => {
r#"INSERT INTO user_sessions (token, user_id, expires_at) VALUES ($1, $2, $3)"#
}
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 => {
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 => {
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 => {
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 => {
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 => {
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"#
}
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 => {
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 => {
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 => {
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 => {
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"#
}
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 => {
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 => {
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 => {
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 => {
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 => {
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"#
}
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 => {
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::Q020 => r#"INSERT INTO published_pages (token, note_id) VALUES ($1, $2)"#,
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::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::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::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::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::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::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"#,
}
}
+67 -27
View File
@@ -31,6 +31,7 @@ pub fn get(query: Query) -> &'static str {
Query::AUTH_UPDATE_EDITOR_COLOR => {
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::RESOURCE_COLOR_BY_USER => {
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 = ?"#
}
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, CASE WHEN navbar_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 => {
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, CASE WHEN navbar_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 => {
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, navbar_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, navbar_collapsed = excluded.navbar_collapsed, font_family = excluded.font_family, font_size = excluded.font_size, updated_at = CURRENT_TIMESTAMP"#
}
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, navbar_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, navbar_collapsed = excluded.navbar_collapsed, font_family = excluded.font_family, font_size = excluded.font_size, updated_at = CURRENT_TIMESTAMP"#
}
Query::EDITOR_PREFERENCES_DELETE_BY_USER => {
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 = ?"#
}
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_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_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 => {
r#"INSERT INTO user_sessions (token, user_id, expires_at) VALUES (?, ?, ?)"#
}
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 => {
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 => {
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 => {
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 => {
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 => {
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"#
}
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 => {
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 => {
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 => {
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 => {
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 = ?"#
}
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 => {
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 => {
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 => {
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 => {
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 => {
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"#
}
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 => {
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::Q020 => r#"INSERT INTO published_pages (token, note_id) VALUES (?, ?)"#,
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::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::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::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 => {
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 = ?"#
}
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 = ?"#,
}
}
+148 -88
View File
@@ -7,10 +7,29 @@
* See LICENSE file in repository root for details.
*/
use axum::http::{HeaderMap, HeaderValue, Uri, header};
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)
@@ -34,6 +53,31 @@ pub fn resource_token<'a>(headers: &'a HeaderMap, kind: &str, slug: &str) -> Opt
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))
}
@@ -42,6 +86,48 @@ 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),
@@ -50,6 +136,19 @@ pub fn resource_cookie(kind: &str, slug: &str, token: &str, ttl_days: i64) -> He
)
}
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"))
@@ -104,10 +203,23 @@ pub fn websocket_origin_allowed(headers: &HeaderMap) -> bool {
fn resource_cookie_name(kind: &str, slug: &str) -> String {
let digest = Sha256::digest(format!("{kind}:{slug}").as_bytes());
format!("__Host-rustpad_access_{}", hex::encode(&digest[..12]))
format!("{RESOURCE_ACCESS_COOKIE_PREFIX}{}", hex::encode(&digest[..12]))
}
fn cookie_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
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())
@@ -127,6 +239,37 @@ fn secure_cookie(name: &str, value: &str, max_age: i64) -> HeaderValue {
.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"
@@ -148,88 +291,5 @@ fn first_header_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str>
}
#[cfg(test)]
mod tests {
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;"));
}
}
#[path = "tests/security.rs"]
mod tests;
+104 -4
View File
@@ -7,12 +7,12 @@
* See LICENSE file in repository root for details.
*/
use crate::database::Database;
use crate::{collab::CollaborativeDocument, database::Database};
use serde::Serialize;
use std::{
collections::HashMap,
sync::{
Arc,
Arc, Weak,
atomic::{AtomicU64, Ordering},
},
time::{Duration, Instant},
@@ -49,11 +49,14 @@ pub struct SmtpConfig {
#[derive(Debug, Clone)]
pub struct NoteUpdate {
pub content: String,
pub base_revision_id: i64,
pub revision_id: i64,
pub updated_at: 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)]
@@ -77,6 +80,14 @@ fn compact_presence_name(name: &str) -> String {
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)]
struct PresenceConnection {
identity: String,
@@ -88,6 +99,7 @@ pub enum RoomEvent {
Document(NoteUpdate),
Presence(Vec<PresenceUser>),
Chat { sender: String, text: String },
PasswordRequired { except_client_id: Option<String> },
}
#[derive(Debug)]
@@ -96,7 +108,10 @@ pub struct AppState {
pub asset_version: String,
pub storage: crate::storage::Storage,
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 files_public_url: Option<String>,
pub smtp: Option<SmtpConfig>,
pub registration_enabled: bool,
pub account_confirmation_required: bool,
@@ -107,6 +122,7 @@ pub struct AppState {
pub unconfirmed_account_ttl_days: i64,
pub ldap: Option<crate::auth::ldap::LdapConfig>,
channels: RwLock<HashMap<String, broadcast::Sender<RoomEvent>>>,
collaborative_documents: RwLock<HashMap<String, Weak<Mutex<CollaborativeDocument>>>>,
presence: RwLock<HashMap<String, HashMap<u64, PresenceConnection>>>,
next_connection_id: AtomicU64,
rate_limits: Mutex<HashMap<String, RateLimitEntry>>,
@@ -118,7 +134,10 @@ impl AppState {
asset_version: String,
storage: crate::storage::Storage,
upload_max_size_bytes: usize,
guest_upload_enabled: bool,
guest_upload_max_size_bytes: usize,
file_cache_max_age_seconds: u64,
files_public_url: Option<String>,
smtp: Option<SmtpConfig>,
registration_enabled: bool,
account_confirmation_required: bool,
@@ -134,7 +153,10 @@ impl AppState {
asset_version,
storage,
upload_max_size_bytes,
guest_upload_enabled,
guest_upload_max_size_bytes,
file_cache_max_age_seconds,
files_public_url,
smtp,
registration_enabled,
account_confirmation_required,
@@ -145,6 +167,7 @@ impl AppState {
unconfirmed_account_ttl_days,
ldap,
channels: RwLock::new(HashMap::new()),
collaborative_documents: RwLock::new(HashMap::new()),
presence: RwLock::new(HashMap::new()),
next_connection_id: AtomicU64::new(1),
rate_limits: Mutex::new(HashMap::new()),
@@ -194,6 +217,38 @@ impl AppState {
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> {
if let Some(sender) = self.channels.read().await.get(&key) {
return sender.clone();
@@ -207,9 +262,16 @@ impl AppState {
pub fn note_room_key(workspace_slug: &str, note_slug: &str) -> String {
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 {
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(
&self,
workspace_slug: &str,
@@ -221,6 +283,40 @@ impl AppState {
pub async fn pad_channel(&self, slug: &str) -> broadcast::Sender<RoomEvent> {
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(
&self,
key: &str,
@@ -281,6 +377,10 @@ impl AppState {
}
}
#[cfg(test)]
#[path = "tests/state.rs"]
mod tests;
fn sorted_users(room: &HashMap<u64, PresenceConnection>) -> Vec<PresenceUser> {
let mut by_identity: HashMap<&str, PresenceUser> = HashMap::new();
for connection in room.values() {
+4 -1
View File
@@ -231,7 +231,10 @@ pub async fn delete_url_file(
owner_id: i64,
url: &str,
) -> 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" {
return Ok(());
}
+62
View File
@@ -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));
}
+80
View File
@@ -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(
"![diagram](https://old-files.example.com/f/token/image.png)",
"image.png",
stored,
None,
));
assert!(content_references_stored_file(
"![diagram](/f/token/image.png)",
"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(()));
}
+121
View File
@@ -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"));
}
+33
View File
@@ -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");
}
+170
View File
@@ -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",
&current_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(&current_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)));
}
+92
View File
@@ -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]
);
}
+36
View File
@@ -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
);
}
+38
View File
@@ -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());
}
+62
View File
@@ -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")));
}
+66
View File
@@ -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"
);
}
+20
View File
@@ -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")
);
}
+35
View File
@@ -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());
}
}
+49
View File
@@ -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}"
);
}
}
+173
View File
@@ -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"));
}
+37
View File
@@ -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,
));
}
+55
View File
@@ -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"),
}
}
+868 -82
View File
File diff suppressed because it is too large Load Diff
+378 -70
View File
@@ -7,14 +7,27 @@ enum PadServerMessage {
title: String,
content: String,
owner_map: String,
revision_id: i64,
access_level: String,
catchup_operations: Vec<AppliedOperation>,
acknowledged_update_ids: Vec<u64>,
resync_required: bool,
},
Document {
content: String,
base_revision_id: i64,
revision_id: i64,
updated_at: 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,
acknowledged_update_ids: Vec<u64>,
},
Presence {
users: Vec<PresenceUser>,
@@ -26,6 +39,11 @@ enum PadServerMessage {
Pong {
nonce: u64,
},
Diagnostics {
diagnostics: ConnectionDiagnostics,
},
PasswordRequired,
PasswordChanged,
Error {
message: String,
},
@@ -41,11 +59,22 @@ pub async fn upgrade_pad(
return (StatusCode::FORBIDDEN, "Invalid WebSocket origin").into_response();
}
let account_token = crate::security::session_token(&headers).map(str::to_owned);
let resource_token = crate::security::resource_token(&headers, "pad", &slug)
.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, resource_token, client_key)
handle_pad_socket(
socket,
state,
slug,
account_token,
share_session_token,
resource_token,
client_key,
client_context,
)
})
}
async fn handle_pad_socket(
@@ -53,8 +82,10 @@ async fn handle_pad_socket(
state: SharedState,
slug: String,
cookie_session_token: Option<String>,
cookie_access_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");
let Some(pad) = db::find_pad(&state.db, &slug).await.ok().flatten() else {
@@ -68,42 +99,61 @@ async fn handle_pad_socket(
.await;
return;
};
let (password, access_token, nickname, guest_id, color) =
match socket.recv().await {
Some(Ok(Message::Text(text))) => match serde_json::from_str::<ClientMessage>(&text) {
Ok(ClientMessage::Authenticate {
password,
access_token,
nickname,
guest_id,
color,
}) => (
password,
access_token,
clean_nickname(nickname),
clean_guest_id(guest_id),
clean_color(color),
),
_ => {
let _ = send_pad(
&mut socket,
&PadServerMessage::Error {
message: "Wymagane uwierzytelnienie".into(),
},
)
.await;
return;
}
},
_ => return,
};
let (
password,
access_token,
nickname,
guest_id,
color,
client_diagnostics,
collaboration_client_id,
known_revision_id,
) = match socket.recv().await {
Some(Ok(Message::Text(text))) => match serde_json::from_str::<ClientMessage>(&text) {
Ok(ClientMessage::Authenticate {
password,
access_token,
nickname,
guest_id,
color,
diagnostics,
client_id,
known_revision_id,
}) => (
password,
access_token,
clean_nickname(nickname),
clean_guest_id(guest_id),
clean_color(color),
diagnostics,
clean_collaboration_client_id(client_id)
.unwrap_or_else(|| format!("legacy_{}", db::random_suffix(24))),
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 access_token = explicit_access_token.or(cookie_access_token);
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 {
Ok(value) => value,
Err(message) => {
@@ -127,18 +177,12 @@ async fn handle_pad_socket(
&state,
"pad",
&slug,
access_token.as_deref(),
&external_tokens,
session_token.as_deref(),
)
.await;
let anonymous_token_ok = permission.is_none()
&& anonymous_access_from_tokens(
&state,
"pad",
&slug,
access_token.as_deref(),
)
.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()
@@ -157,9 +201,7 @@ async fn handle_pad_socket(
let _ = send_pad(
&mut socket,
&PadServerMessage::Error {
message: format!(
"Too many password attempts. Try again in {seconds} seconds."
),
message: format!("Too many password attempts. Try again in {seconds} seconds."),
},
)
.await;
@@ -172,9 +214,7 @@ async fn handle_pad_socket(
let _ = send_pad(
&mut socket,
&PadServerMessage::Error {
message: format!(
"Too many password attempts. Try again in {seconds} seconds."
),
message: format!("Too many password attempts. Try again in {seconds} seconds."),
},
)
.await;
@@ -206,17 +246,98 @@ async fn handle_pad_socket(
.await;
return;
}
let write_allowed = permission.as_deref() == Some("rw")
|| anonymous_token_ok
|| password_ok
|| (pad.is_private == 0 && pad.password_hash.is_none() && permission.is_none());
let (_, write_allowed) = current_resource_access(
&state,
"pad",
&slug,
&external_tokens,
session_token.as_deref(),
password_ok,
)
.await;
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(
&mut socket,
&PadServerMessage::Authenticated {
title: pad.title.clone(),
content: pad.content.clone(),
owner_map: pad.owner_map.clone(),
content: authenticated_content,
owner_map: authenticated_owner_map,
revision_id: authenticated_revision_id,
catchup_operations,
acknowledged_update_ids,
resync_required,
access_level: if write_allowed {
"full".into()
} else {
@@ -229,25 +350,156 @@ async fn handle_pad_socket(
{
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 (connection_id, users) = state
.join_room(&room_key, display_name.clone(), color, presence_identity)
.await;
let _ = channel.send(RoomEvent::Presence(users));
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();
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 {
tokio::select! {
incoming=receiver.next()=>match incoming{
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;}
if content.len()>2_000_000 { let _=send_pad_split(&mut sender,&PadServerMessage::Error{message:"The document is too large".into()}).await; continue; }
let owner_map=owner_map.unwrap_or_else(||"[]".into());
if let Ok((revision_id,updated_at))=db::save_pad_revision(&state.db,pad.id,&content,nickname.as_deref(),&owner_map).await{
let _=channel.send(RoomEvent::Document(NoteUpdate{content,revision_id,updated_at,author:nickname.clone(),owner_map}));
Ok(ClientMessage::Update{base_revision_id,update_id,operation,owner_replacements})=>{
let (read_allowed, current_write_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; }
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; },
@@ -263,12 +515,68 @@ async fn handle_pad_socket(
Some(Ok(_))=>{},
Some(Err(error))=>{debug!(%error,"pad websocket receive error");break;}
},
update=updates.recv()=>match update{
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;},
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;},
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;} },
Err(tokio::sync::broadcast::error::RecvError::Closed)=>break,
_=access_refresh.tick()=>{
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;
}
},
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,
}
}
}
}
+2506 -422
View File
File diff suppressed because it is too large Load Diff
+157 -32
View File
@@ -4,8 +4,9 @@
<head>
<meta charset="utf-8">
<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>
__APP_THEME_BOOTSTRAP__
__APP_STYLESHEET__
__APP_IMPORT_MAP__
__APP_ENTRYPOINT__
@@ -13,8 +14,12 @@
<body class="pad-page" data-resource-kind="__RESOURCE_KIND__" data-registration-enabled="__REGISTRATION_ENABLED__">
<header class="app-header">
<div class="app-header__main"><a id="resource-parent-link" class="brand __PARENT_CLASS__"
href="__PARENT_URL__">__PARENT_TITLE__</a><span class="header-divider"></span>
<div class="app-header__main">
<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"
title="Copy this link" aria-label="Copy this link">
<h1 id="document-title">__DOCUMENT_TITLE__</h1>
@@ -33,23 +38,44 @@
<span aria-hidden="true"></span><span aria-hidden="true"></span><span aria-hidden="true"></span>
</button>
<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">
<summary class="secondary-button">Page settings</summary>
<div class="page-settings-menu"><label class="public-task-toggle"
<summary class="secondary-button" aria-label="Page options"><span class="page-settings__label"><span
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"
type="checkbox"> Enable Page</label><label class="public-task-toggle"
title="Allow visitors to update task checkboxes on the published page"><input
id="public-task-updates" type="checkbox"> Editable tasks</label><label
class="public-task-toggle"
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"
class="secondary-button danger-button" hidden>Delete</button><button id="history-button"
class="secondary-button">History</button>
</div>
</div>
</header>
<button id="navbar-collapse-toggle" class="navbar-collapse-toggle" type="button" aria-pressed="false"
aria-label="Hide navigation bar" title="Hide navigation bar"><span aria-hidden="true"></span></button>
<main class="editor-layout">
<section class="editor-panel">
<div class="editor-toolbar">
@@ -92,6 +118,8 @@
data-format="horizontal-rule">Horizontal rule</button></div>
</details>
</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">
<option value="mono">Mono</option>
<option value="system">System</option>
@@ -105,18 +133,35 @@
<option value="20">20</option>
<option value="22">22</option>
</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
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>
Compact</label><label class="line-toggle"><input id="line-links-toggle" type="checkbox">
Line links</label>
<div class="toolbar-fill"></div><button id="mode-toggle" class="markdown-toggle active"
aria-pressed="true">Markdown</button>
<div class="view-switch"><button data-view="edit">Edit</button><button data-view="split"
class="active">Split</button><button data-view="preview">Preview</button></div>
<div class="toolbar-fill"></div><button id="mode-toggle" class="toolbar-action active"
aria-pressed="true" aria-label="Markdown" title="Markdown"><span
class="control-label-full">Markdown</span><span class="control-label-short"
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 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="column-label editor-column-label"><span>Editor</span>
<div class="authorship-controls"><label class="switch-control authorship-colors-switch"
@@ -130,12 +175,12 @@
disabled>Full</button></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 id="line-gutter" class="line-gutter" 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"
wrap="off" placeholder="Start writing…" spellcheck="false"></textarea>
wrap="off" placeholder="Start writing…" spellcheck="false" readonly></textarea>
</div>
</div>
<div class="preview-column">
@@ -145,10 +190,48 @@
</div>
<footer class="editor-footer">
<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
id="status-dot" class="status__dot"></span><span
id="status-text">Connecting…</span></span> · <span id="socket-latency"
class="footer-socket-latency" title="WebSocket round-trip time">— ms</span></span> ·
words</span>
<div class="footer-connection-block"> · <details id="connection-details" class="connection-details">
<summary title="WebSocket connection diagnostics"><span class="footer-status status"><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">
<summary><span id="room-count">0 users</span><span id="chat-unread" class="chat-unread"
hidden></span></summary>
@@ -192,9 +275,11 @@
</div><button id="close-shortcuts" class="icon-button" type="button">×</button>
</div>
<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>Alt+1…4</kbd><span>Headings H1H4</span>__EXTRA_SHORTCUTS__
list</span><kbd>Alt+1…4</kbd><span>Headings H1H4</span><kbd>Tab</kbd><span>Indent by 2
spaces</span><kbd>Shift+Tab</kbd><span>Remove indentation</span>__EXTRA_SHORTCUTS__
</div>
</div>
</dialog>
@@ -203,8 +288,13 @@
<div class="files-head">
<div>
<h2>Note files</h2>
<p>Copy a direct link or ready Markdown/HTML code.</p>
</div><button id="close-files" class="icon-button" type="button">×</button>
<p>Copy a direct link or ready Markdown/Alias code.</p>
</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 id="files-list" class="files-list"></div>
</div>
@@ -219,12 +309,12 @@
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>
<section id="auth-panel" class="auth-panel" hidden>
<h3 id="auth-mode-title">Log in</h3><label id="auth-email-field">E-mail / LDAP or AD Username<input
id="auth-email" name="username" type="email" maxlength="320" autocomplete="username"
placeholder="you@example.com or name.second-name"></label><label>Password<input
id="auth-password" name="password" type="password" minlength="8" maxlength="128"
autocomplete="current-password"></label><button id="auth-submit" class="primary-button"
type="submit">Log in and continue</button>
<h3 id="auth-mode-title">Log in</h3><label id="auth-email-field"><span
id="auth-email-label">E-mail</span><input id="auth-email" name="username" type="email"
maxlength="320" autocomplete="username"
placeholder="you@example.com"></label><label>Password<input id="auth-password" name="password"
type="password" minlength="8" maxlength="128" autocomplete="current-password"></label><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
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
@@ -277,9 +367,44 @@
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"
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
id="mobile-status-dot" class="status__dot"></span><span
id="mobile-status-text">Connecting…</span></span>
<details id="mobile-connection-details" class="mobile-connection-details">
<summary id="mobile-connection-status" class="mobile-connection-status"
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 id="toast" class="toast"></div>
</body>
+2 -1
View File
@@ -4,9 +4,10 @@
<head>
<meta charset="utf-8">
<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">
<title>__ERROR_TITLE__ · RustPad</title>
__APP_THEME_BOOTSTRAP__
__APP_STYLESHEET__
</head>
+36 -11
View File
@@ -4,8 +4,9 @@
<head>
<meta charset="utf-8">
<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>
__APP_THEME_BOOTSTRAP__
__APP_STYLESHEET__
__APP_IMPORT_MAP__
__APP_ENTRYPOINT__
@@ -29,8 +30,8 @@
<div class="field">
<label for="pad-name">Note name</label>
<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
id="pad-name-count">0/80</span></div>
<div class="field-meta"><span id="pad-slug-preview">/p/note</span><span id="pad-name-count">0/80</span>
</div>
</div>
<div class="field">
<div class="label-row"><label for="pad-password">Password</label><span>optional, min. 8 characters</span>
@@ -53,7 +54,7 @@
<div class="field">
<label for="workspace-name">Workspace name</label>
<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>
</div>
<div class="field">
@@ -73,9 +74,8 @@
<footer class="home-footer">
<div class="home-footer__inner">
<div id="footer-account-guest" class="home-footer__account">
<button id="footer-login" class="footer-action" type="button">Log in</button>
<button id="footer-register" class="footer-action footer-action--primary" type="button">Register
nickname</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>
</div>
<div id="footer-account-user" class="home-footer__account" hidden>
<span id="footer-user-label" class="home-footer__user">
@@ -101,8 +101,8 @@
<div class="identity-fields">
<label>Nickname<input id="nickname" name="nickname" maxlength="40" autocomplete="off" data-bwignore="true"
placeholder="Your nickname"></label>
<label id="auth-email-field">E-mail / LDAP or AD Username<input id="auth-email" name="username" type="email"
maxlength="320" autocomplete="username" required placeholder="you@example.com or name.second-name"></label>
<label id="auth-email-field"><span id="auth-email-label">E-mail</span><input id="auth-email" name="username"
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"
autocomplete="current-password" required placeholder="At least 8 characters"></label>
</div>
@@ -125,7 +125,17 @@
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>
</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>
<nav id="resources-pagination" class="pagination" aria-label="Resources pagination"></nav>
<p id="resources-error" class="form-message error" role="alert"></p>
</div>
</dialog>
@@ -144,6 +154,19 @@
<label>Nickname<input id="profile-nickname" maxlength="40" required></label>
<label class="profile-color-field">Editor color<input id="profile-color" type="color"
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>New e-mail<input id="profile-email" type="email" maxlength="320"
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"
maxlength="128" placeholder="Required for e-mail or password changes"></label>
</div>
<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 class="profile-actions">
<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>
</form>
</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

+12
View File
@@ -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

+165 -120
View File
@@ -26,6 +26,37 @@ const DEFAULT_ERRORS = {
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) {
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(bytes % (1024 * 1024) ? 1 : 0)} MB`;
if (bytes >= 1024) return `${Math.ceil(bytes / 1024)} KB`;
@@ -54,9 +85,9 @@ async function clearSessionIfInvalid() {
}
}
function validateUploadSize(body) {
function validateUploadSize(body, configuredMaxBytes) {
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;
for (const value of body.values()) {
if (value instanceof File && value.size > maxBytes) {
@@ -67,10 +98,14 @@ function validateUploadSize(body) {
}
}
function requestHeaders(options, body) {
async function requestHeaders(options, body) {
const headers = new Headers(options.headers || {});
headers.delete("x-rustpad-user-token");
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;
}
@@ -88,14 +123,22 @@ function formDataFileSize(body) {
}
export async function api(path, options = {}) {
validateUploadSize(options.body);
validateUploadSize(options.body, options.uploadMaxSizeBytes);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 12000);
try {
const headers = requestHeaders(options, options.body);
let headers = await requestHeaders(options, options.body);
const started = performance.now();
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);
logDebug("api.response", { method: options.method || "GET", path, status: response.status, durationMs });
const contentType = response.headers.get("content-type") || "";
@@ -123,126 +166,128 @@ export async function api(path, options = {}) {
}
export function uploadWithProgress(path, options = {}) {
validateUploadSize(options.body);
validateUploadSize(options.body, options.uploadMaxSizeBytes);
const method = options.method || "POST";
const fallbackTotal = formDataFileSize(options.body);
const stallTimeoutMs = Number(options.stallTimeoutMs) > 0 ? Number(options.stallTimeoutMs) : 90000;
const responseTimeoutMs = Number(options.responseTimeoutMs) > 0 ? Number(options.responseTimeoutMs) : 120000;
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const headers = requestHeaders(options, options.body);
const started = performance.now();
let lastAt = started;
let lastLoaded = 0;
let speed = 0;
let stallTimer = null;
let responseTimer = null;
let stalled = false;
let responseTimedOut = false;
let externallyAborted = false;
return (async () => {
const headers = await requestHeaders({ ...options, method }, options.body);
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const started = performance.now();
let lastAt = started;
let lastLoaded = 0;
let speed = 0;
let stallTimer = null;
let responseTimer = null;
let stalled = false;
let responseTimedOut = false;
let externallyAborted = false;
const clearStallTimer = () => {
clearTimeout(stallTimer);
stallTimer = null;
};
const armStallTimer = () => {
clearStallTimer();
stallTimer = setTimeout(() => {
stalled = true;
xhr.abort();
}, stallTimeoutMs);
};
const cleanup = () => {
clearStallTimer();
clearTimeout(responseTimer);
responseTimer = null;
options.signal?.removeEventListener("abort", 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) {
const clearStallTimer = () => {
clearTimeout(stallTimer);
stallTimer = null;
};
const armStallTimer = () => {
clearStallTimer();
stallTimer = setTimeout(() => {
stalled = true;
xhr.abort();
}, stallTimeoutMs);
};
const cleanup = () => {
clearStallTimer();
clearTimeout(responseTimer);
responseTimer = null;
options.signal?.removeEventListener("abort", abortFromSignal);
};
const abortFromSignal = () => {
externallyAborted = true;
const error = new Error("Upload cancelled.");
error.name = "AbortError";
fail(error);
return;
}
options.signal.addEventListener("abort", abortFromSignal, { once: true });
}
xhr.abort();
};
const fail = error => {
cleanup();
reject(error);
};
logDebug("api.request", { method, path });
xhr.send(options.body ?? null);
});
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;
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);
});
})();
}
+37 -28
View File
@@ -19,6 +19,32 @@ const clearAuthSession = sessionStore.clearAuthSession || (() => {
localStorage.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" }) {
if (dialog.querySelector("#auth-panel")) return bindLegacyIdentityDialog({ dialog, onIdentity });
@@ -28,6 +54,7 @@ export function bindIdentityDialog({ dialog, onIdentity, initialMode = "login" }
const title = dialog.querySelector("#identity-title");
const copy = dialog.querySelector("#identity-copy");
const emailField = dialog.querySelector("#auth-email-field");
const emailLabel = dialog.querySelector("#auth-email-label");
const email = dialog.querySelector("#auth-email");
const password = dialog.querySelector("#auth-password");
const submit = dialog.querySelector("#auth-submit");
@@ -43,12 +70,14 @@ export function bindIdentityDialog({ dialog, onIdentity, initialMode = "login" }
const resetting = mode === "reset";
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
? "Enter the e-mail address assigned to your local account."
: registering
? "Reserve your nickname with an e-mail address and password."
: "Use your e-mail address or organization login and password.";
? "Register your account with an e-mail address 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.disabled = !registering;
@@ -62,21 +91,10 @@ export function bindIdentityDialog({ dialog, onIdentity, initialMode = "login" }
resetButton.hidden = resetting || registering || externalAuth;
backButton.hidden = !resetting;
const loginMode = mode === "login";
email.type = externalAuth && loginMode ? "text" : "email";
if (externalAuth && loginMode) password.removeAttribute("minlength");
else password.minLength = 8;
configureCredentialFields({ emailLabel, email, password, loginMode, externalAuth });
form.autocomplete = loginMode ? "on" : "off";
nickname.autocomplete = "off";
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 = "";
queueMicrotask(() => {
@@ -168,6 +186,7 @@ function bindLegacyIdentityDialog({ dialog, onIdentity }) {
const message = dialog.querySelector("#identity-error");
const modeTitle = dialog.querySelector("#auth-mode-title");
const emailField = dialog.querySelector("#auth-email-field");
const emailLabel = dialog.querySelector("#auth-email-label");
const email = dialog.querySelector("#auth-email");
const password = dialog.querySelector("#auth-password");
const authSubmit = dialog.querySelector("#auth-submit");
@@ -202,24 +221,13 @@ function bindLegacyIdentityDialog({ dialog, onIdentity }) {
nickname.required = mode === "register";
emailField.hidden = 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";
const loginMode = mode === "login";
email.type = externalAuth && loginMode ? "text" : "email";
if (externalAuth && loginMode) password.removeAttribute("minlength");
else password.minLength = 8;
configureCredentialFields({ emailLabel, email, password, loginMode, externalAuth });
form.autocomplete = loginMode ? "on" : "off";
nickname.autocomplete = "off";
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 = "";
queueMicrotask(() => (mode === "register" ? nickname : email).focus());
};
@@ -322,6 +330,7 @@ export async function logoutCurrentSession() {
try {
await api("/api/auth/logout", { method: "POST" });
} catch { }
clearResourceAccessState();
clearAuthSession();
}
-19
View File
@@ -161,25 +161,6 @@ export function mapSelectionThroughEdit(previousText, nextText, start, end = sta
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) {
if (!layer || !editor) return;
const canvas = layer.querySelector(".authorship-canvas");

Some files were not shown because too many files have changed in this diff Show More