Compare commits

...
22 Commits
Author SHA1 Message Date
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
106 changed files with 10897 additions and 1398 deletions
+5 -1
View File
@@ -12,4 +12,8 @@ Dockerfile*
docker-compose*.yml
migrate/
scripts/*.txt
tests/
tests/
# Downloaded in the browser-libs Docker stage
static/libs/mermaid/
static/libs/highlight/
+2
View File
@@ -32,6 +32,8 @@ 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
+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.21"
version = "0.2.45"
dependencies = [
"argon2",
"aws-config",
+2 -2
View File
@@ -1,10 +1,10 @@
[package]
name = "rustpad"
version = "0.2.21"
version = "0.2.45"
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
+26 -5
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,10 +16,12 @@ 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`.
@@ -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.
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:
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:
+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;
@@ -0,0 +1,11 @@
CREATE TABLE resource_share_sessions (
session_token_hash VARCHAR(64) PRIMARY KEY,
share_token_hash VARCHAR(64) NOT NULL,
resource_kind VARCHAR(16) NOT NULL,
resource_slug VARCHAR(255) NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP),
CONSTRAINT fk_resource_share_sessions_link FOREIGN KEY(share_token_hash) REFERENCES resource_share_links(token_hash) ON DELETE CASCADE
);
CREATE INDEX idx_resource_share_sessions_link ON resource_share_sessions(share_token_hash);
CREATE INDEX idx_resource_share_sessions_expiry ON resource_share_sessions(expires_at(32));
@@ -0,0 +1 @@
ALTER TABLE resource_share_links ADD COLUMN label VARCHAR(120) NULL;
@@ -0,0 +1,11 @@
ALTER TABLE note_revisions
ADD COLUMN collaboration_client_id VARCHAR(64) NULL,
ADD COLUMN collaboration_update_id BIGINT NULL,
ADD UNIQUE INDEX idx_note_revisions_collaboration_update
(note_id, collaboration_client_id, collaboration_update_id);
ALTER TABLE revisions
ADD COLUMN collaboration_client_id VARCHAR(64) NULL,
ADD COLUMN collaboration_update_id BIGINT NULL,
ADD UNIQUE INDEX idx_revisions_collaboration_update
(pad_id, collaboration_client_id, collaboration_update_id);
@@ -0,0 +1 @@
ALTER TABLE workspaces ADD COLUMN created_by_guest_id VARCHAR(64) NULL;
@@ -0,0 +1,8 @@
-- Existing MySQL databases may have inherited utf8mb3 from the database default.
-- Convert all persisted document text to utf8mb4 so emoji and other 4-byte
-- Unicode characters can be stored in both current documents and revisions.
ALTER TABLE pads CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE revisions CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE workspaces CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE notes CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE note_revisions CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
@@ -0,0 +1 @@
ALTER TABLE user_editor_preferences ADD COLUMN toolbar_collapsed BOOLEAN NOT NULL DEFAULT FALSE;
@@ -1 +1,2 @@
ALTER TABLE resource_share_links ADD COLUMN token TEXT;
-- Legacy migration retained for numbering only. Plaintext share tokens are not stored.
SELECT 1;
@@ -0,0 +1,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;
+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;
@@ -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;
+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 {
+140 -54
View File
@@ -16,7 +16,7 @@ pub async fn upload_pad_file(
Path(slug): Path<String>,
mut multipart: Multipart,
) -> Result<Json<serde_json::Value>, ApiError> {
require_upload_permission(&state, &headers).await?;
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;
@@ -46,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()));
}
@@ -66,8 +66,9 @@ pub async fn upload_pad_file(
{
AccessLevel::Write
} else {
combined_token_access_level(
request_access_level(
&state,
&headers,
"pad",
&slug,
resource_request_token(&headers, "pad", &slug, access_token.as_deref()),
@@ -129,7 +130,7 @@ pub(super) fn content_references_file(content: &str, filename: &str, url: &str)
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()..];
@@ -254,7 +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> {
require_upload_permission(&state, &headers).await?;
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;
@@ -284,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()));
}
@@ -306,8 +307,9 @@ pub async fn upload_note_file(
{
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()),
@@ -387,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()),
@@ -504,30 +507,44 @@ pub async fn delete_note_file(
async fn require_upload_permission(
state: &SharedState,
headers: &HeaderMap,
) -> Result<(), ApiError> {
let user = crate::auth::optional_user(state, headers)
.await
.map_err(|error| ApiError::forbidden(&error.message))?;
if user.is_some() {
Ok(())
} else {
Err(ApiError::forbidden(
"Log in with read-write access to upload files.",
))
) -> Result<usize, ApiError> {
upload_limit_for_request(state, headers)
.await?
.ok_or_else(|| ApiError::forbidden("File uploads are disabled for guests."))
}
#[derive(Debug, Default, Deserialize)]
pub struct FileDownloadQuery {
#[serde(default)]
download: Option<String>,
}
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
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 {
@@ -547,15 +564,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)
@@ -566,6 +612,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"),
@@ -588,6 +648,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,
@@ -601,6 +707,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()
@@ -624,32 +737,5 @@ fn sanitize_filename(value: &str) -> String {
}
#[cfg(test)]
mod tests {
use super::{content_references_stored_file, 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"));
}
#[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"),
));
}
}
#[path = "../tests/api_files.rs"]
mod tests;
+350 -87
View File
@@ -30,6 +30,7 @@ use sha2::{Digest, Sha256};
use slug::slugify;
use crate::{
collab::{self, AppliedOperation},
db, queries,
state::{NoteUpdate, RoomEvent, SharedState},
};
@@ -88,14 +89,35 @@ fn requester_guest_id(headers: &HeaderMap) -> Option<&str> {
})
}
fn guest_owner_is_requester(headers: &HeaderMap, owner_guest_id: Option<&str>) -> bool {
owner_guest_id
.zip(requester_guest_id(headers))
.is_some_and(|(owner_guest_id, requester_guest_id)| owner_guest_id == requester_guest_id)
}
fn can_set_resource_password(
password_protected: bool,
account_owner: bool,
guest_owner: bool,
) -> bool {
!password_protected && (account_owner || guest_owner)
}
fn can_manage_resource_settings(
account_owner: bool,
guest_owner: bool,
password_write_access: bool,
) -> bool {
account_owner || guest_owner || password_write_access
}
async fn note_creator_is_requester(
state: &SharedState,
headers: &HeaderMap,
note: &db::Note,
) -> Result<bool, ApiError> {
if let Some(owner_guest_id) = note.created_by_guest_id.as_deref() {
return Ok(requester_guest_id(headers)
.is_some_and(|requester_guest_id| requester_guest_id == owner_guest_id));
return Ok(guest_owner_is_requester(headers, Some(owner_guest_id)));
}
let Some(user) = session_user(state, headers).await? else {
return Ok(false);
@@ -107,10 +129,31 @@ async fn note_creator_is_requester(
}
fn pad_creator_is_requester(headers: &HeaderMap, pad: &db::Pad) -> bool {
pad.created_by_guest_id
.as_deref()
.zip(requester_guest_id(headers))
.is_some_and(|(owner_guest_id, requester_guest_id)| owner_guest_id == requester_guest_id)
guest_owner_is_requester(headers, pad.created_by_guest_id.as_deref())
}
fn workspace_creator_is_requester(headers: &HeaderMap, workspace: &db::Workspace) -> bool {
guest_owner_is_requester(headers, workspace.created_by_guest_id.as_deref())
}
async fn can_set_workspace_password(
state: &SharedState,
headers: &HeaderMap,
workspace: &db::Workspace,
) -> bool {
let account_owner = crate::auth::is_resource_owner(
state,
"workspace",
&workspace.slug,
user_session_token(headers),
)
.await
.unwrap_or(false);
can_set_resource_password(
workspace.password_hash.is_some(),
account_owner,
workspace_creator_is_requester(headers, workspace),
)
}
async fn has_write_permission(
@@ -119,23 +162,19 @@ async fn has_write_permission(
kind: &str,
slug: &str,
) -> Result<bool, ApiError> {
let resource = crate::security::resource_token(headers, kind, slug);
if external_token_access_level(state, kind, slug, resource).await? >= AccessLevel::Write {
return Ok(true);
}
let authorization = authorization_token(headers);
if authorization != resource
&& external_token_access_level(state, kind, slug, authorization).await?
>= AccessLevel::Write
if request_access_level(
state,
headers,
kind,
slug,
None,
crate::security::session_cookie_token(headers),
)
.await?
>= AccessLevel::Write
{
return Ok(true);
}
let session = crate::security::session_cookie_token(headers);
if session != resource && session != authorization {
if account_token_access_level(state, kind, slug, session).await? >= AccessLevel::Write {
return Ok(true);
}
}
match kind {
"workspace" => Ok(db::find_workspace(&state.db, slug)
.await?
@@ -149,6 +188,32 @@ async fn has_write_permission(
}
}
async fn upload_limit_for_request(
state: &SharedState,
headers: &HeaderMap,
) -> Result<Option<usize>, ApiError> {
if session_user(state, headers).await?.is_some() {
return Ok(Some(state.upload_max_size_bytes));
}
Ok(state
.guest_upload_enabled
.then_some(state.guest_upload_max_size_bytes))
}
async fn resource_upload_limit(
state: &SharedState,
headers: &HeaderMap,
kind: &str,
slug: &str,
) -> Result<Option<usize>, ApiError> {
let Some(limit) = upload_limit_for_request(state, headers).await? else {
return Ok(None);
};
Ok(has_write_permission(state, headers, kind, slug)
.await?
.then_some(limit))
}
#[derive(Debug, Serialize)]
pub struct PublishResponse {
url: Option<String>,
@@ -261,6 +326,13 @@ pub struct CreateNoteRequest {
created_by: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct SetWorkspacePasswordRequest {
password: String,
#[serde(default)]
client_id: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct RestoreRequest {
#[serde(default)]
@@ -275,6 +347,8 @@ pub struct WorkspaceInfo {
slug: String,
title: String,
protected: bool,
access_level: String,
can_set_password: bool,
created_at: String,
updated_at: String,
}
@@ -304,10 +378,17 @@ pub struct ListPaginationMeta {
total_pages: usize,
}
fn default_list_page() -> usize { 1 }
fn default_list_per_page() -> usize { 25 }
fn default_list_page() -> usize {
1
}
fn default_list_per_page() -> usize {
25
}
fn normalize_list_per_page(value: usize) -> usize {
match value { 25 | 50 | 100 => value, _ => 25 }
match value {
25 | 50 | 100 => value,
_ => 25,
}
}
#[derive(Debug, Serialize)]
@@ -332,6 +413,7 @@ pub struct NoteInfo {
slug: String,
title: String,
protected: bool,
access_level: String,
note_protected: bool,
allow_public_task_updates: bool,
public_page_unprotected: bool,
@@ -341,6 +423,7 @@ pub struct NoteInfo {
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,
@@ -349,11 +432,13 @@ pub struct NoteInfo {
editor_line_numbers: bool,
preview_line_numbers: bool,
line_links: bool,
toolbar_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>,
}
@@ -377,6 +462,8 @@ pub struct EditorSettingsRequest {
#[serde(default)]
line_links: Option<bool>,
#[serde(default)]
toolbar_collapsed: Option<bool>,
#[serde(default)]
font_family: Option<String>,
#[serde(default)]
font_size: Option<i64>,
@@ -418,6 +505,7 @@ async fn save_editor_settings(
|| payload.editor_line_numbers.is_some()
|| payload.preview_line_numbers.is_some()
|| payload.line_links.is_some()
|| payload.toolbar_collapsed.is_some()
|| payload.font_family.is_some()
|| payload.font_size.is_some();
let wants_global_update = payload.authorship_mode.is_some() || payload.colors_enabled.is_some();
@@ -469,6 +557,9 @@ async fn save_editor_settings(
if let Some(value) = payload.line_links {
preferences.line_links = value;
}
if let Some(value) = payload.toolbar_collapsed {
preferences.toolbar_collapsed = value;
}
if let Some(value) = payload.font_family {
preferences.font_family = match value.as_str() {
"mono" | "system" | "serif" | "arial" | "georgia" => value,
@@ -530,11 +621,23 @@ pub async fn create_workspace(
let password = validate_password(payload.password.as_deref())?;
let slug = unique_workspace_slug(&state, title).await?;
let workspace = db::create_workspace(&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 created_by_guest_id = if account_user.is_none() {
requester_guest_id(&headers)
} else {
None
};
let workspace = db::create_workspace(
&state.db,
&slug,
title,
password,
created_by_guest_id,
)
.await?;
if let Some(user) = account_user {
sqlx::query(queries::get(
state.db.kind(),
queries::USER_ATTACH_WORKSPACE,
@@ -570,7 +673,51 @@ pub async fn workspace_info(
workspace.is_private,
)
.await?;
Ok(Json(workspace_info_from(&workspace)))
let access_level = effective_header_access_level(
&state,
&headers,
"workspace",
&workspace.slug,
workspace.is_private,
workspace.password_hash.is_some(),
)
.await?;
let can_set_password = can_set_workspace_password(&state, &headers, &workspace).await;
Ok(Json(workspace_info_from(
&workspace,
access_level,
can_set_password,
)))
}
pub async fn set_workspace_password(
State(state): State<SharedState>,
headers: HeaderMap,
Path(workspace_slug): Path<String>,
Json(payload): Json<SetWorkspacePasswordRequest>,
) -> Result<Json<serde_json::Value>, ApiError> {
let workspace = db::find_workspace(&state.db, &workspace_slug)
.await?
.ok_or_else(ApiError::not_found_workspace)?;
if workspace.password_hash.is_some() {
return Err(ApiError::bad_request(
"This workspace already has a password.",
));
}
if !can_set_workspace_password(&state, &headers, &workspace).await {
return Err(ApiError::forbidden(
"Only the workspace 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_workspace_password(&state.db, &workspace_slug, password).await?;
state
.notify_workspace_password_required(&workspace_slug, except_client_id)
.await;
Ok(Json(serde_json::json!({"ok": true, "protected": true})))
}
pub async fn open_workspace(
@@ -607,7 +754,12 @@ pub async fn open_workspace(
search.is_empty()
|| note.title.to_lowercase().contains(&search)
|| note.slug.to_lowercase().contains(&search)
|| note.created_by.as_deref().unwrap_or_default().to_lowercase().contains(&search)
|| note
.created_by
.as_deref()
.unwrap_or_default()
.to_lowercase()
.contains(&search)
})
.map(|note| {
let stats = stats.get(&note.id);
@@ -636,10 +788,28 @@ pub async fn open_workspace(
let start = (page - 1) * per_page;
let notes = notes.into_iter().skip(start).take(per_page).collect();
let mut access_level = effective_header_access_level(
&state,
&headers,
"workspace",
&workspace.slug,
workspace.is_private,
workspace.password_hash.is_some(),
)
.await?;
if db::verify_workspace_password(&workspace, payload.password.as_deref()) {
access_level = AccessLevel::Write;
}
let can_set_password = can_set_workspace_password(&state, &headers, &workspace).await;
Ok(Json(WorkspaceOpenResponse {
workspace: workspace_info_from(&workspace),
workspace: workspace_info_from(&workspace, access_level, can_set_password),
notes,
pagination: ListPaginationMeta { page, per_page, total, total_pages },
pagination: ListPaginationMeta {
page,
per_page,
total,
total_pages,
},
}))
}
@@ -668,8 +838,9 @@ pub async fn create_note(
{
AccessLevel::Write
} else {
combined_token_access_level(
request_access_level(
&state,
&headers,
"workspace",
&workspace_slug,
resource_request_token(
@@ -845,6 +1016,15 @@ pub async fn note_info(
workspace.is_private,
)
.await?;
let access_level = effective_header_access_level(
&state,
&headers,
"workspace",
&workspace.slug,
workspace.is_private,
workspace.password_hash.is_some(),
)
.await?;
let note = db::find_note(&state.db, workspace.id, &note_slug)
.await?
.ok_or_else(ApiError::not_found_note)?;
@@ -866,17 +1046,21 @@ pub async fn note_info(
)
.await
.unwrap_or(false);
let workspace_guest_owner = workspace_creator_is_requester(&headers, &workspace);
let note_owner = note_creator_is_requester(&state, &headers, &note).await?;
let password_write_access =
has_password_write_access(&state, &headers, "workspace", &workspace_slug).await?;
let can_manage_authorship = workspace_owner || note_owner || password_write_access;
let can_manage_authorship =
can_manage_resource_settings(workspace_owner, note_owner, password_write_access);
let can_delete_files = can_manage_authorship;
let can_upload_files = session_user(&state, &headers).await?.is_some()
&& has_write_permission(&state, &headers, "workspace", &workspace_slug).await?;
let upload_max_size_bytes =
resource_upload_limit(&state, &headers, "workspace", &workspace_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, "workspace", &workspace_slug).await?;
if workspace.is_private == 0
&& workspace.password_hash.is_some()
&& !db::note_public_page_disabled(&state.db, note.id).await?
&& !db::note_public_page_enabled(&state.db, note.id).await?
{
@@ -888,6 +1072,7 @@ pub async fn note_info(
slug: note.slug,
title: note.title,
protected: workspace.password_hash.is_some(),
access_level: access_level_name(access_level).into(),
note_protected: note.protected,
allow_public_task_updates: db::note_public_task_updates(&state.db, note.id).await?,
public_page_unprotected: db::note_public_page_unprotected(&state.db, note.id).await?,
@@ -897,6 +1082,7 @@ pub async fn note_info(
updated_at: db::normalize_timestamp(&note.updated_at),
can_delete_files,
can_upload_files,
upload_max_size_bytes,
global_color,
note_color,
authorship_mode: resource_editor_settings.authorship_mode,
@@ -905,11 +1091,17 @@ pub async fn note_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,
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(
workspace.password_hash.is_some(),
workspace_owner,
workspace_guest_owner,
),
files: markdown_file_references(&state, None, Some(note.id), None).await?,
}))
}
@@ -926,8 +1118,11 @@ pub async fn set_note_editor_settings(
let note = db::find_note(&state.db, workspace.id, &note_slug)
.await?
.ok_or_else(ApiError::not_found_note)?;
let creator_can_manage_authorship = note_creator_is_requester(&state, &headers, &note).await?
|| has_password_write_access(&state, &headers, "workspace", &workspace_slug).await?;
let creator_can_manage_authorship = can_manage_resource_settings(
false,
note_creator_is_requester(&state, &headers, &note).await?,
has_password_write_access(&state, &headers, "workspace", &workspace_slug).await?,
);
save_editor_settings(
&state,
&headers,
@@ -1001,8 +1196,9 @@ pub async fn restore(
{
AccessLevel::Write
} else {
combined_token_access_level(
request_access_level(
&state,
&headers,
"workspace",
&workspace_slug,
resource_request_token(
@@ -1022,26 +1218,62 @@ pub async fn restore(
.fetch_optional(state.db.pool())
.await?;
let content = content.ok_or_else(ApiError::not_found_revision)?;
let room_key = crate::state::AppState::note_room_key(&workspace_slug, &note_slug);
let channel = state.note_channel(&workspace_slug, &note_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 base_revision_id = document.revision_id;
let operation =
collab::replace_operation(document.content.encode_utf16().count(), content, Vec::new());
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_revision(
&state.db,
note.id,
workspace.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: "[]".into(),
client_id: "server_restore".into(),
update_id,
operation,
owner_replacements: Vec::new(),
};
let _ = state
.note_channel(&workspace_slug, &note_slug)
.await
.send(RoomEvent::Document(update));
let _ = channel.send(RoomEvent::Document(update));
drop(document);
Ok(Json(serde_json::json!({"ok": true})))
}
@@ -1060,28 +1292,6 @@ fn permission_level(permission: Option<&str>) -> AccessLevel {
}
}
async fn anonymous_access_token_valid(
state: &SharedState,
kind: &str,
slug: &str,
token: Option<&str>,
) -> Result<bool, ApiError> {
let Some(token) = token.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(false);
};
let count: i64 = sqlx::query_scalar(queries::get(
state.db.kind(),
queries::RESOURCE_ACCESS_TOKENS_VALID_COUNT,
))
.bind(access_tokens::hash_access_token(token))
.bind(kind)
.bind(slug)
.bind(Utc::now().to_rfc3339())
.fetch_one(state.db.pool())
.await?;
Ok(count > 0)
}
async fn has_password_write_access(
state: &SharedState,
headers: &HeaderMap,
@@ -1092,7 +1302,7 @@ async fn has_password_write_access(
crate::security::resource_token(headers, kind, slug),
authorization_token(headers),
] {
if anonymous_access_token_valid(state, kind, slug, token).await? {
if verify_password_access_token(state, kind, slug, token).await? {
return Ok(true);
}
}
@@ -1105,14 +1315,14 @@ async fn external_token_access_level(
slug: &str,
token: Option<&str>,
) -> Result<AccessLevel, ApiError> {
let permission = crate::auth::share_link_permission(state, kind, slug, token)
let permission = crate::auth::share_access_permission(state, kind, slug, token)
.await
.map_err(|error| ApiError::forbidden(&error.message))?;
let level = permission_level(permission.as_deref());
if level != AccessLevel::None {
return Ok(level);
}
if anonymous_access_token_valid(state, kind, slug, token).await? {
if verify_password_access_token(state, kind, slug, token).await? {
// A server-issued token created after a correct resource password
// retains the historical read/write semantics of password access.
return Ok(AccessLevel::Write);
@@ -1132,17 +1342,66 @@ async fn account_token_access_level(
Ok(permission_level(permission.as_deref()))
}
async fn combined_token_access_level(
async fn request_access_level(
state: &SharedState,
headers: &HeaderMap,
kind: &str,
slug: &str,
access_token: Option<&str>,
supplied_access_token: Option<&str>,
account_token: Option<&str>,
) -> Result<AccessLevel, ApiError> {
Ok(std::cmp::max(
external_token_access_level(state, kind, slug, access_token).await?,
account_token_access_level(state, kind, slug, account_token).await?,
))
let mut level = account_token_access_level(state, kind, slug, account_token).await?;
if level == AccessLevel::Write {
return Ok(level);
}
let mut checked_tokens = Vec::with_capacity(4);
for token in [
supplied_access_token,
crate::security::share_session_token(headers, kind, slug),
crate::security::resource_token(headers, kind, slug),
authorization_token(headers),
] {
let Some(token) = token.map(str::trim).filter(|value| !value.is_empty()) else {
continue;
};
if checked_tokens.contains(&token) {
continue;
}
checked_tokens.push(token);
level = std::cmp::max(
level,
external_token_access_level(state, kind, slug, Some(token)).await?,
);
if level == AccessLevel::Write {
break;
}
}
Ok(level)
}
fn access_level_name(level: AccessLevel) -> &'static str {
match level {
AccessLevel::None => "none",
AccessLevel::Read => "read",
AccessLevel::Write => "write",
}
}
async fn effective_header_access_level(
state: &SharedState,
headers: &HeaderMap,
kind: &str,
slug: &str,
is_private: i64,
password_protected: bool,
) -> Result<AccessLevel, ApiError> {
let mut level =
request_access_level(state, headers, kind, slug, None, bearer_token(headers)).await?;
if is_private == 0 && !password_protected {
level = std::cmp::max(level, AccessLevel::Write);
}
Ok(level)
}
fn require_write(level: AccessLevel) -> Result<(), ApiError> {
@@ -1159,18 +1418,12 @@ async fn has_header_resource_access(
kind: &str,
slug: &str,
) -> Result<bool, ApiError> {
for token in [
crate::security::resource_token(headers, kind, slug),
authorization_token(headers),
] {
if external_token_access_level(state, kind, slug, token).await? != AccessLevel::None {
return Ok(true);
}
}
Ok(account_token_access_level(
Ok(request_access_level(
state,
headers,
kind,
slug,
None,
crate::security::session_cookie_token(headers),
)
.await?
@@ -1241,7 +1494,7 @@ pub async fn authorized_workspace(
.await?
.ok_or_else(ApiError::not_found_workspace)?;
let token_level =
combined_token_access_level(state, "workspace", slug, access_token, bearer).await?;
request_access_level(state, headers, "workspace", slug, access_token, bearer).await?;
if workspace.is_private != 0 && token_level == AccessLevel::None {
return Err(ApiError::not_found_workspace());
}
@@ -1280,11 +1533,17 @@ async fn authorized_note(
Ok((workspace, note))
}
fn workspace_info_from(workspace: &db::Workspace) -> WorkspaceInfo {
fn workspace_info_from(
workspace: &db::Workspace,
access_level: AccessLevel,
can_set_password: bool,
) -> WorkspaceInfo {
WorkspaceInfo {
slug: workspace.slug.clone(),
title: workspace.title.clone(),
protected: workspace.password_hash.is_some(),
access_level: access_level_name(access_level).into(),
can_set_password,
created_at: db::normalize_timestamp(&workspace.created_at),
updated_at: db::normalize_timestamp(&workspace.updated_at),
}
@@ -1368,3 +1627,7 @@ async fn unique_note_slug(
}
Err(ApiError::internal("Failed to create a unique address"))
}
#[cfg(test)]
#[path = "../tests/api.rs"]
mod guest_resource_access_tests;
+294 -24
View File
@@ -18,6 +18,13 @@ pub struct CreatePadRequest {
content: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct SetPadPasswordRequest {
password: String,
#[serde(default)]
client_id: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct CreatePadResponse {
slug: String,
@@ -29,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,
@@ -37,6 +45,7 @@ pub struct PadInfo {
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,
@@ -45,11 +54,13 @@ pub struct PadInfo {
editor_line_numbers: bool,
preview_line_numbers: bool,
line_links: bool,
toolbar_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>,
}
@@ -106,6 +117,15 @@ pub async fn pad_info(
.await?
.ok_or_else(ApiError::not_found_note)?;
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))
@@ -118,12 +138,14 @@ pub async fn pad_info(
.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 = account_owner || guest_owner || password_write_access;
let can_upload_files = session_user(&state, &headers).await?.is_some()
&& has_write_permission(&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?
{
@@ -133,6 +155,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?,
@@ -141,6 +164,7 @@ pub async fn pad_info(
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,
@@ -149,15 +173,51 @@ 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,
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,
@@ -167,8 +227,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 = pad_creator_is_requester(&headers, &pad)
|| has_password_write_access(&state, &headers, "pad", &slug).await?;
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,
@@ -256,8 +319,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()),
@@ -267,6 +331,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?;
@@ -311,8 +380,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(
@@ -327,6 +397,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?;
@@ -360,9 +435,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 = ?",
@@ -377,7 +450,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);
@@ -393,9 +471,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"
@@ -414,7 +490,14 @@ 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);
@@ -452,6 +535,144 @@ pub async fn public_page(
}))
}
async fn commit_public_pad_task_update(
state: &SharedState,
page: &db::PublishedPage,
source_line: usize,
checked: bool,
) -> Result<(), ApiError> {
let pad_id = page.pad_id.ok_or_else(ApiError::not_found_note)?;
let room_key = crate::state::AppState::pad_room_key(&page.resource_slug);
let channel = state.pad_channel(&page.resource_slug).await;
let collaboration_snapshot = db::pad_collaboration_snapshot(&state.db, pad_id).await?;
let collaborative_document = state
.collaborative_document(
&room_key,
collaboration_snapshot.content,
collaboration_snapshot.owner_map,
collaboration_snapshot.revision_id,
)
.await;
let mut document = collaborative_document.lock().await;
let Some(next_content) =
db::updated_public_task_content(&document.content, source_line, checked)
else {
return Ok(());
};
let base_revision_id = document.revision_id;
let operation =
collab::operation_from_edit(&document.content, &next_content, &document.owner_map);
let (content, owner_map) = collab::apply_operation_to_document(
&document.content,
&document.owner_map,
&operation,
&[],
)
.map_err(|_| ApiError::bad_request("The task could not be updated"))?;
let (revision_id, updated_at) =
db::save_pad_revision(&state.db, pad_id, &content, Some("public"), &owner_map).await?;
let update_id = u64::try_from(revision_id).unwrap_or_default().max(1);
document.content.clone_from(&content);
document.owner_map.clone_from(&owner_map);
document.revision_id = revision_id;
document.record(AppliedOperation {
base_revision_id,
revision_id,
client_id: "public_task".into(),
update_id,
operation: operation.clone(),
owner_replacements: Vec::new(),
});
let _ = channel.send(RoomEvent::Document(NoteUpdate {
base_revision_id,
revision_id,
updated_at,
author: Some("public".into()),
client_id: "public_task".into(),
update_id,
operation,
owner_replacements: Vec::new(),
}));
drop(document);
Ok(())
}
async fn commit_public_note_task_update(
state: &SharedState,
page: &db::PublishedPage,
source_line: usize,
checked: bool,
) -> Result<(), ApiError> {
let note_id = page.note_id.ok_or_else(ApiError::not_found_note)?;
let workspace_id = page.workspace_id.ok_or_else(ApiError::not_found_note)?;
let workspace_slug = page
.workspace_slug
.as_deref()
.ok_or_else(ApiError::not_found_note)?;
let room_key = crate::state::AppState::note_room_key(workspace_slug, &page.resource_slug);
let channel = state
.note_channel(workspace_slug, &page.resource_slug)
.await;
let collaboration_snapshot = db::note_collaboration_snapshot(&state.db, note_id).await?;
let collaborative_document = state
.collaborative_document(
&room_key,
collaboration_snapshot.content,
collaboration_snapshot.owner_map,
collaboration_snapshot.revision_id,
)
.await;
let mut document = collaborative_document.lock().await;
let Some(next_content) =
db::updated_public_task_content(&document.content, source_line, checked)
else {
return Ok(());
};
let base_revision_id = document.revision_id;
let operation =
collab::operation_from_edit(&document.content, &next_content, &document.owner_map);
let (content, owner_map) = collab::apply_operation_to_document(
&document.content,
&document.owner_map,
&operation,
&[],
)
.map_err(|_| ApiError::bad_request("The task could not be updated"))?;
let (revision_id, updated_at) = db::save_revision(
&state.db,
note_id,
workspace_id,
&content,
Some("public"),
&owner_map,
)
.await?;
let update_id = u64::try_from(revision_id).unwrap_or_default().max(1);
document.content.clone_from(&content);
document.owner_map.clone_from(&owner_map);
document.revision_id = revision_id;
document.record(AppliedOperation {
base_revision_id,
revision_id,
client_id: "public_task".into(),
update_id,
operation: operation.clone(),
owner_replacements: Vec::new(),
});
let _ = channel.send(RoomEvent::Document(NoteUpdate {
base_revision_id,
revision_id,
updated_at,
author: Some("public".into()),
client_id: "public_task".into(),
update_id,
operation,
owner_replacements: Vec::new(),
}));
drop(document);
Ok(())
}
pub async fn update_public_task(
State(state): State<SharedState>,
headers: HeaderMap,
@@ -467,7 +688,14 @@ 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 =
@@ -527,8 +755,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()),
@@ -550,19 +779,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})))
}
@@ -577,7 +846,8 @@ 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());
}
+34 -120
View File
@@ -19,15 +19,34 @@ use axum::{
};
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();
@@ -139,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",
@@ -150,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),
@@ -191,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))
@@ -205,10 +233,8 @@ 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(TraceLayer::new_for_http())
.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)
@@ -311,117 +337,5 @@ fn is_icon_path(path: &str) -> bool {
}
#[cfg(test)]
mod tests {
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"));
}
}
#[path = "../tests/app.rs"]
mod tests;
+200 -34
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,
@@ -153,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",
@@ -214,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 {
@@ -254,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,
@@ -276,25 +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",
@@ -400,3 +562,7 @@ fn escape_html(value: &str) -> String {
.replace('"', "&quot;")
.replace('\'', "&#39;")
}
#[cfg(test)]
#[path = "../tests/app_pages.rs"]
mod tests;
+28 -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,11 +31,14 @@ const MODULES: &[&str] = &[
"note-api",
"note-editor",
"note-files",
"preview-edit",
"render-queue",
"session",
"socket",
"toast",
"theme",
"url-state",
"vendor-libs",
"security",
];
@@ -46,10 +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_THEME_BOOTSTRAP__", theme_bootstrap())
.replace("__APP_STYLESHEET__", &urls.stylesheet("styles"))
.replace("__APP_STYLESHEET__", &app_stylesheets)
.replace("__APP_IMPORT_MAP__", &urls.import_map())
.replace("__APP_ENTRYPOINT__", &urls.entrypoint(entrypoint))
.replace(
@@ -70,13 +86,13 @@ pub fn render_html(
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="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>"#
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 {
@@ -87,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),
)
}
@@ -110,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 {
+484 -68
View File
@@ -40,6 +40,7 @@ use crate::{
const MIN_PASSWORD: usize = 8;
const MAX_PASSWORD: usize = 128;
const MAX_NICKNAME: usize = 40;
const MAX_SHARE_LINK_LABEL: usize = 120;
#[derive(Debug, Clone)]
pub struct User {
@@ -149,7 +150,7 @@ impl<'r> sqlx::FromRow<'r, AnyRow> for SharingUserRow {
#[derive(Debug)]
struct SharingLinkRow {
token_hash: String,
token: Option<String>,
label: Option<String>,
permission: String,
expires_at: Option<String>,
created_at: String,
@@ -159,7 +160,7 @@ impl<'r> sqlx::FromRow<'r, AnyRow> for SharingLinkRow {
fn from_row(row: &'r AnyRow) -> Result<Self, sqlx::Error> {
Ok(Self {
token_hash: crate::row_decode::text(row, 0)?,
token: crate::row_decode::optional_text(row, 1)?,
label: crate::row_decode::optional_text(row, 1)?,
permission: crate::row_decode::text(row, 2)?,
expires_at: crate::row_decode::optional_text(row, 3)?,
created_at: crate::row_decode::text(row, 4)?,
@@ -167,6 +168,61 @@ impl<'r> sqlx::FromRow<'r, AnyRow> for SharingLinkRow {
}
}
#[derive(Debug)]
struct ShareLinkSessionSource {
token_hash: String,
permission: String,
expires_at: Option<String>,
}
impl<'r> sqlx::FromRow<'r, AnyRow> for ShareLinkSessionSource {
fn from_row(row: &'r AnyRow) -> Result<Self, sqlx::Error> {
Ok(Self {
token_hash: crate::row_decode::text(row, 0)?,
permission: crate::row_decode::text(row, 1)?,
expires_at: crate::row_decode::optional_text(row, 2)?,
})
}
}
#[derive(Debug)]
struct ShareLinkPermissionRow {
permission: String,
expires_at: Option<String>,
}
impl<'r> sqlx::FromRow<'r, AnyRow> for ShareLinkPermissionRow {
fn from_row(row: &'r AnyRow) -> Result<Self, sqlx::Error> {
Ok(Self {
permission: crate::row_decode::text(row, 0)?,
expires_at: crate::row_decode::optional_text(row, 1)?,
})
}
}
#[derive(Debug)]
struct ShareSessionPermissionRow {
permission: String,
session_expires_at: String,
link_expires_at: Option<String>,
}
impl<'r> sqlx::FromRow<'r, AnyRow> for ShareSessionPermissionRow {
fn from_row(row: &'r AnyRow) -> Result<Self, sqlx::Error> {
Ok(Self {
permission: crate::row_decode::text(row, 0)?,
session_expires_at: crate::row_decode::text(row, 1)?,
link_expires_at: crate::row_decode::optional_text(row, 2)?,
})
}
}
#[derive(Debug)]
pub struct ShareSession {
pub token: String,
pub max_age_seconds: i64,
}
#[derive(Debug)]
struct PendingShareRow {
email: String,
@@ -240,6 +296,8 @@ pub struct RemoveShareRequest {
pub struct CreateShareLinkRequest {
kind: String,
slug: String,
#[serde(default)]
label: Option<String>,
permission: String,
expires_at: Option<String>,
}
@@ -247,7 +305,9 @@ pub struct CreateShareLinkRequest {
pub struct UpdateShareLinkRequest {
kind: String,
slug: String,
token: String,
token_hash: String,
#[serde(default)]
label: Option<String>,
permission: String,
expires_at: Option<String>,
}
@@ -255,7 +315,7 @@ pub struct UpdateShareLinkRequest {
pub struct RevokeShareLinkRequest {
kind: String,
slug: String,
token: String,
token_hash: String,
}
#[derive(Serialize)]
@@ -282,10 +342,17 @@ pub struct PaginationMeta {
total_pages: usize,
}
fn default_page() -> usize { 1 }
fn default_per_page() -> usize { 25 }
fn default_page() -> usize {
1
}
fn default_per_page() -> usize {
25
}
fn normalized_per_page(value: usize) -> usize {
match value { 25 | 50 | 100 => value, _ => 25 }
match value {
25 | 50 | 100 => value,
_ => 25,
}
}
#[derive(Serialize)]
pub struct SessionResponse {
@@ -308,8 +375,6 @@ pub struct IdentityResponse {
}
#[derive(Serialize)]
pub struct RegisterResponse {
#[serde(skip_serializing)]
token: Option<String>,
nickname: String,
email: String,
expires_at: Option<String>,
@@ -461,7 +526,6 @@ pub async fn register(
return Ok((
StatusCode::CREATED,
Json(RegisterResponse {
token: None,
nickname: user.nickname,
email: user.email,
expires_at: None,
@@ -481,7 +545,6 @@ pub async fn register(
let mut response = (
StatusCode::CREATED,
Json(RegisterResponse {
token: Some(session.token),
nickname: session.nickname,
email: session.email,
expires_at: Some(session.expires_at),
@@ -1181,14 +1244,13 @@ pub async fn resources(
.fetch_all(state.db.pool())
.await
.map_err(AuthError::database)?;
let pads = sqlx::query_as::<_, ResourceItem>(
queries::get(state.db.kind(), queries::USER_LIST_PADS),
)
.bind(user.id)
.bind(user.id)
.fetch_all(state.db.pool())
.await
.map_err(AuthError::database)?;
let pads =
sqlx::query_as::<_, ResourceItem>(queries::get(state.db.kind(), queries::USER_LIST_PADS))
.bind(user.id)
.bind(user.id)
.fetch_all(state.db.pool())
.await
.map_err(AuthError::database)?;
let search = query.q.trim().to_lowercase();
let mut items = workspaces
@@ -1222,7 +1284,12 @@ pub async fn resources(
Ok(Json(ResourceList {
items,
pagination: PaginationMeta { page, per_page, total, total_pages },
pagination: PaginationMeta {
page,
per_page,
total,
total_pages,
},
}))
}
@@ -1232,6 +1299,8 @@ pub async fn update_resource(
Json(req): Json<ResourceActionRequest>,
) -> Result<Json<serde_json::Value>, AuthError> {
let user = require_user(&state, &headers).await?;
let kind = req.kind.trim();
let slug = req.slug.trim();
let hash = match req
.password
.as_deref()
@@ -1244,15 +1313,16 @@ pub async fn update_resource(
}
None => None,
};
ensure_owner(&state, user.id, &req.kind, &req.slug).await?;
let query = match req.kind.as_str() {
let password_enabled = hash.is_some();
ensure_owner(&state, user.id, kind, slug).await?;
let query = match kind {
"workspace" => queries::USER_SET_WORKSPACE_PASSWORD,
"pad" => queries::USER_SET_PAD_PASSWORD,
_ => return Err(AuthError::bad_request("Unknown resource type.")),
};
sqlx::query(queries::get(state.db.kind(), query))
.bind(hash)
.bind(req.slug.trim())
.bind(slug)
.execute(state.db.pool())
.await
.map_err(AuthError::database)?;
@@ -1260,12 +1330,21 @@ pub async fn update_resource(
state.db.kind(),
queries::RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE,
))
.bind(req.kind.as_str())
.bind(req.slug.trim())
.bind(kind)
.bind(slug)
.execute(state.db.pool())
.await
.map_err(AuthError::database)?;
Ok(Json(serde_json::json!({"ok":true})))
if password_enabled {
match kind {
"workspace" => state.notify_workspace_password_required(slug, None).await,
"pad" => state.notify_pad_password_required(slug, None).await,
_ => {}
}
}
Ok(Json(
serde_json::json!({"ok":true,"protected":password_enabled}),
))
}
pub async fn delete_resource(
@@ -1388,6 +1467,40 @@ async fn ensure_owner(
Ok(())
}
pub(crate) async fn resource_is_public_unprotected(
state: &SharedState,
kind: &str,
slug: &str,
) -> Result<bool, AuthError> {
let slug = slug.trim();
match kind {
"workspace" => Ok(crate::db::find_workspace(&state.db, slug)
.await
.map_err(AuthError::database)?
.is_some_and(|workspace| {
workspace.is_private == 0 && workspace.password_hash.is_none()
})),
"pad" => Ok(crate::db::find_pad(&state.db, slug)
.await
.map_err(AuthError::database)?
.is_some_and(|pad| pad.is_private == 0 && pad.password_hash.is_none())),
_ => Ok(false),
}
}
async fn ensure_share_links_enabled(
state: &SharedState,
kind: &str,
slug: &str,
) -> Result<(), AuthError> {
if resource_is_public_unprotected(state, kind, slug).await? {
return Err(AuthError::conflict(
"Direct share links are disabled for public resources without a password.",
));
}
Ok(())
}
pub async fn set_resource_privacy(
State(state): State<SharedState>,
headers: HeaderMap,
@@ -1653,6 +1766,7 @@ pub async fn resource_sharing(
.get("slug")
.ok_or_else(|| AuthError::bad_request("Missing slug."))?;
ensure_owner(&state, owner.id, kind, slug).await?;
let share_links_enabled = !resource_is_public_unprotected(&state, kind, slug).await?;
let users: Vec<SharingUserRow> = sqlx::query_as(queries::get(
state.db.kind(),
queries::RESOURCE_SHARING_USERS,
@@ -1662,15 +1776,19 @@ pub async fn resource_sharing(
.fetch_all(state.db.pool())
.await
.map_err(AuthError::database)?;
let links: Vec<SharingLinkRow> = sqlx::query_as(queries::get(
state.db.kind(),
queries::RESOURCE_SHARING_LINKS,
))
.bind(kind)
.bind(slug)
.fetch_all(state.db.pool())
.await
.map_err(AuthError::database)?;
let links: Vec<SharingLinkRow> = if share_links_enabled {
sqlx::query_as(queries::get(
state.db.kind(),
queries::RESOURCE_SHARING_LINKS,
))
.bind(kind)
.bind(slug)
.fetch_all(state.db.pool())
.await
.map_err(AuthError::database)?
} else {
Vec::new()
};
let pending: Vec<PendingShareRow> = sqlx::query_as(queries::get(
state.db.kind(),
queries::RESOURCE_SHARING_PENDING,
@@ -1681,7 +1799,7 @@ pub async fn resource_sharing(
.await
.map_err(AuthError::database)?;
Ok(Json(
serde_json::json!({"users":users.into_iter().map(|row|serde_json::json!({"email":row.email,"nickname":row.nickname,"permission":row.permission})).collect::<Vec<_>>(), "pending":pending.into_iter().map(|row|serde_json::json!({"email":row.email,"nickname":row.nickname,"permission":row.permission,"expires_at":row.expires_at})).collect::<Vec<_>>(), "links":links.into_iter().map(|row|serde_json::json!({"token_hash":row.token_hash,"token":row.token,"permission":row.permission,"expires_at":row.expires_at,"created_at":row.created_at})).collect::<Vec<_>>() }),
serde_json::json!({"users":users.into_iter().map(|row|serde_json::json!({"email":row.email,"nickname":row.nickname,"permission":row.permission})).collect::<Vec<_>>(), "pending":pending.into_iter().map(|row|serde_json::json!({"email":row.email,"nickname":row.nickname,"permission":row.permission,"expires_at":row.expires_at})).collect::<Vec<_>>(), "links":links.into_iter().map(|row|serde_json::json!({"token_hash":row.token_hash,"label":row.label,"permission":row.permission,"expires_at":row.expires_at,"created_at":row.created_at})).collect::<Vec<_>>(), "share_links_enabled":share_links_enabled }),
))
}
@@ -1689,20 +1807,22 @@ pub async fn create_share_link(
State(state): State<SharedState>,
headers: HeaderMap,
Json(req): Json<CreateShareLinkRequest>,
) -> Result<Json<serde_json::Value>, AuthError> {
) -> Result<Response, AuthError> {
let owner = require_user(&state, &headers).await?;
ensure_owner(&state, owner.id, &req.kind, &req.slug).await?;
ensure_share_links_enabled(&state, &req.kind, &req.slug).await?;
let permission = validate_permission(&req.permission)?;
validate_share_expiration(req.expires_at.as_deref())?;
let label = normalize_share_link_label(req.label.as_deref())?;
let expires_at = normalize_share_expiration(req.expires_at.as_deref())?;
let token = random_token();
let token_hash = hash_token(&token);
sqlx::query(queries::get(state.db.kind(), queries::SHARE_LINK_INSERT))
.bind(token_hash)
.bind(&token)
.bind(&token_hash)
.bind(&label)
.bind(&req.kind)
.bind(req.slug.trim())
.bind(permission)
.bind(&req.expires_at)
.bind(&expires_at)
.bind(owner.id)
.execute(state.db.pool())
.await
@@ -1712,9 +1832,20 @@ pub async fn create_share_link(
} else {
format!("/p/{}", req.slug.trim())
};
Ok(Json(
serde_json::json!({"token":token,"url":format!("{base}?share={token}"),"permission":permission,"expires_at":req.expires_at}),
))
let mut response = Json(
serde_json::json!({"token_hash":token_hash,"url":format!("{base}?share={token}"),"label":label,"permission":permission,"expires_at":expires_at}),
)
.into_response();
response.headers_mut().insert(
header::CACHE_CONTROL,
"no-cache, no-store, max-age=0"
.parse()
.expect("valid cache-control"),
);
response
.headers_mut()
.insert(header::PRAGMA, "no-cache".parse().expect("valid pragma"));
Ok(response)
}
pub async fn update_share_link(
@@ -1724,12 +1855,15 @@ pub async fn update_share_link(
) -> Result<Json<serde_json::Value>, AuthError> {
let owner = require_user(&state, &headers).await?;
ensure_owner(&state, owner.id, &req.kind, &req.slug).await?;
ensure_share_links_enabled(&state, &req.kind, &req.slug).await?;
let permission = validate_permission(&req.permission)?;
validate_share_expiration(req.expires_at.as_deref())?;
let label = normalize_share_link_label(req.label.as_deref())?;
let expires_at = normalize_share_expiration(req.expires_at.as_deref())?;
let result = sqlx::query(queries::get(state.db.kind(), queries::SHARE_LINK_UPDATE))
.bind(&label)
.bind(permission)
.bind(&req.expires_at)
.bind(req.token.trim())
.bind(&expires_at)
.bind(req.token_hash.trim())
.bind(&req.kind)
.bind(req.slug.trim())
.execute(state.db.pool())
@@ -1741,7 +1875,7 @@ pub async fn update_share_link(
));
}
Ok(Json(
serde_json::json!({"ok":true,"permission":permission,"expires_at":req.expires_at}),
serde_json::json!({"ok":true,"label":label,"permission":permission,"expires_at":expires_at}),
))
}
@@ -1752,14 +1886,25 @@ pub async fn revoke_share_link(
) -> Result<Json<serde_json::Value>, AuthError> {
let owner = require_user(&state, &headers).await?;
ensure_owner(&state, owner.id, &req.kind, &req.slug).await?;
ensure_share_links_enabled(&state, &req.kind, &req.slug).await?;
sqlx::query(queries::get(state.db.kind(), queries::SHARE_LINK_REVOKE))
.bind(Utc::now().to_rfc3339())
.bind(req.token.trim())
.bind(req.token_hash.trim())
.bind(&req.kind)
.bind(req.slug.trim())
.execute(state.db.pool())
.await
.map_err(AuthError::database)?;
sqlx::query(queries::get(
state.db.kind(),
queries::SHARE_SESSIONS_DELETE_BY_LINK,
))
.bind(req.token_hash.trim())
.bind(&req.kind)
.bind(req.slug.trim())
.execute(state.db.pool())
.await
.map_err(AuthError::database)?;
Ok(Json(serde_json::json!({"ok":true})))
}
@@ -1770,9 +1915,27 @@ fn validate_permission(value: &str) -> Result<&str, AuthError> {
}
}
fn validate_share_expiration(value: Option<&str>) -> Result<(), AuthError> {
fn normalize_share_link_label(value: Option<&str>) -> Result<Option<String>, AuthError> {
let Some(value) = value else {
return Ok(());
return Ok(None);
};
let value = value.trim();
if value.is_empty() {
return Ok(None);
}
if value.chars().count() > MAX_SHARE_LINK_LABEL
|| value.chars().any(|character| character.is_control())
{
return Err(AuthError::bad_request(
"Link label must contain at most 120 printable characters.",
));
}
Ok(Some(value.to_owned()))
}
fn normalize_share_expiration(value: Option<&str>) -> Result<Option<String>, AuthError> {
let Some(value) = value else {
return Ok(None);
};
let expires = chrono::DateTime::parse_from_rfc3339(value)
.map_err(|_| AuthError::bad_request("Invalid expiration date."))?
@@ -1780,7 +1943,7 @@ fn validate_share_expiration(value: Option<&str>) -> Result<(), AuthError> {
if expires <= Utc::now() {
return Err(AuthError::bad_request("Expiration must be in the future."));
}
Ok(())
Ok(Some(expires.to_rfc3339()))
}
pub async fn is_resource_owner(
@@ -1826,6 +1989,187 @@ pub async fn account_resource_permission(
Ok(permission)
}
pub async fn create_share_session(
state: &SharedState,
kind: &str,
slug: &str,
share_token: &str,
client_key: &str,
) -> Result<Option<ShareSession>, AuthError> {
if resource_is_public_unprotected(state, kind, slug).await? {
return Ok(None);
}
let share_token = share_token.trim();
if !valid_share_token(share_token) || !matches!(kind, "workspace" | "pad") {
return Ok(None);
}
let now = Utc::now();
let now_text = now.to_rfc3339();
let window = std::time::Duration::from_secs(15 * 60);
state
.check_rate_limit(format!("share-session-client:{client_key}"), 120, window)
.await
.map_err(|seconds| {
AuthError::rate_limited(&format!(
"Too many share-link attempts. Try again in {seconds} seconds."
))
})?;
let source = sqlx::query_as::<_, ShareLinkSessionSource>(queries::get(
state.db.kind(),
queries::SHARE_LINK_SESSION_SOURCE,
))
.bind(hash_token(share_token))
.bind(kind)
.bind(slug)
.fetch_optional(state.db.pool())
.await
.map_err(AuthError::database)?;
let Some(source) = source else {
return Ok(None);
};
if !matches!(source.permission.as_str(), "ro" | "rw") {
warn!(kind, slug, "invalid share link permission in database");
return Ok(None);
}
let session_limit = now + Duration::days(state.anonymous_access_token_ttl_days);
let expires_at = match source.expires_at.as_deref() {
Some(value) => match chrono::DateTime::parse_from_rfc3339(value) {
Ok(value) => std::cmp::min(value.with_timezone(&Utc), session_limit),
Err(error) => {
warn!(%error, kind, slug, "invalid share link expiration in database");
return Ok(None);
}
},
None => session_limit,
};
let max_age_seconds = (expires_at - now).num_seconds();
if max_age_seconds <= 0 {
return Ok(None);
}
state
.check_rate_limit(
format!("share-session-client:{client_key}:{}", source.token_hash),
60,
window,
)
.await
.map_err(|seconds| {
AuthError::rate_limited(&format!(
"Too many share-link sessions. Try again in {seconds} seconds."
))
})?;
state
.check_rate_limit(
format!("share-session-link:{}", source.token_hash),
2_000,
std::time::Duration::from_secs(60 * 60),
)
.await
.map_err(|seconds| {
AuthError::rate_limited(&format!(
"Too many share-link sessions. Try again in {seconds} seconds."
))
})?;
sqlx::query(queries::get(
state.db.kind(),
queries::SHARE_SESSIONS_DELETE_EXPIRED,
))
.bind(&now_text)
.execute(state.db.pool())
.await
.map_err(AuthError::database)?;
let token = random_token();
sqlx::query(queries::get(state.db.kind(), queries::SHARE_SESSION_INSERT))
.bind(hash_token(&token))
.bind(source.token_hash)
.bind(kind)
.bind(slug)
.bind(expires_at.to_rfc3339())
.execute(state.db.pool())
.await
.map_err(AuthError::database)?;
Ok(Some(ShareSession {
token,
max_age_seconds,
}))
}
async fn share_session_permission(
state: &SharedState,
kind: &str,
slug: &str,
token: Option<&str>,
) -> Result<Option<String>, AuthError> {
let Some(token) = token.filter(|value| !value.is_empty()) else {
return Ok(None);
};
if !valid_share_token(token) {
return Ok(None);
}
let row = sqlx::query_as::<_, ShareSessionPermissionRow>(queries::get(
state.db.kind(),
queries::SHARE_SESSION_PERMISSION,
))
.bind(hash_token(token))
.bind(kind)
.bind(slug)
.fetch_optional(state.db.pool())
.await
.map_err(AuthError::database)?;
let Some(row) = row else {
return Ok(None);
};
if !matches!(row.permission.as_str(), "ro" | "rw") {
warn!(kind, slug, "invalid share session permission in database");
return Ok(None);
}
let now = Utc::now();
let session_expires = match chrono::DateTime::parse_from_rfc3339(&row.session_expires_at) {
Ok(value) => value.with_timezone(&Utc),
Err(error) => {
warn!(%error, kind, slug, "invalid share session expiration in database");
return Ok(None);
}
};
if session_expires <= now {
return Ok(None);
}
if let Some(value) = row.link_expires_at.as_deref() {
let link_expires = match chrono::DateTime::parse_from_rfc3339(value) {
Ok(value) => value.with_timezone(&Utc),
Err(error) => {
warn!(%error, kind, slug, "invalid share link expiration in database");
return Ok(None);
}
};
if link_expires <= now {
return Ok(None);
}
}
Ok(Some(row.permission))
}
pub async fn share_access_permission(
state: &SharedState,
kind: &str,
slug: &str,
token: Option<&str>,
) -> Result<Option<String>, AuthError> {
if resource_is_public_unprotected(state, kind, slug).await? {
return Ok(None);
}
let permission = share_session_permission(state, kind, slug, token).await?;
if permission.is_some() {
return Ok(permission);
}
share_link_permission(state, kind, slug, token).await
}
pub async fn share_link_permission(
state: &SharedState,
kind: &str,
@@ -1835,42 +2179,106 @@ pub async fn share_link_permission(
let Some(token) = token.filter(|value| !value.is_empty()) else {
return Ok(None);
};
let now = Utc::now().to_rfc3339();
let permission: Option<String> = sqlx::query_scalar(queries::get(
if !valid_share_token(token) {
return Ok(None);
}
let row = sqlx::query_as::<_, ShareLinkPermissionRow>(queries::get(
state.db.kind(),
queries::SHARE_LINK_PERMISSION,
))
.bind(hash_token(token))
.bind(kind)
.bind(slug)
.bind(now)
.fetch_optional(state.db.pool())
.await
.map_err(AuthError::database)?;
Ok(permission)
let Some(row) = row else {
return Ok(None);
};
if !matches!(row.permission.as_str(), "ro" | "rw") {
warn!(kind, slug, "invalid share link permission in database");
return Ok(None);
}
if let Some(value) = row.expires_at.as_deref() {
let expires = match chrono::DateTime::parse_from_rfc3339(value) {
Ok(value) => value.with_timezone(&Utc),
Err(error) => {
warn!(%error, kind, slug, "invalid share link expiration in database");
return Ok(None);
}
};
if expires <= Utc::now() {
return Ok(None);
}
}
Ok(Some(row.permission))
}
pub async fn logout(
State(state): State<SharedState>,
headers: HeaderMap,
) -> Result<Response, AuthError> {
if let Some(token) = crate::security::session_token(&headers) {
let result = sqlx::query(queries::get(
state.db.kind(),
queries::AUTH_DELETE_SESSION_BY_TOKEN,
))
.bind(token)
.execute(state.db.pool())
.await
.map_err(AuthError::database)?;
info!(rows_affected = result.rows_affected(), "logout processed");
let session_token = crate::security::session_token(&headers).map(str::to_owned);
let access_cookies = crate::security::resource_access_cookies(&headers);
let has_access_tokens = access_cookies.iter().any(|(_, token)| token.is_some());
let mut revoked_access_tokens = 0_u64;
if session_token.is_some() || has_access_tokens {
let mut tx = state
.db
.pool()
.begin()
.await
.map_err(AuthError::database)?;
if let Some(token) = session_token.as_deref() {
let result = sqlx::query(queries::get(
state.db.kind(),
queries::AUTH_DELETE_SESSION_BY_TOKEN,
))
.bind(token)
.execute(&mut *tx)
.await
.map_err(AuthError::database)?;
info!(rows_affected = result.rows_affected(), "logout processed");
} else {
debug!("logout requested without an active session");
}
for (_, token) in &access_cookies {
let Some(token) = token.as_deref() else {
continue;
};
let result = sqlx::query(queries::get(
state.db.kind(),
queries::RESOURCE_ACCESS_TOKENS_DELETE_BY_TOKEN_HASH,
))
.bind(hash_token(token))
.execute(&mut *tx)
.await
.map_err(AuthError::database)?;
revoked_access_tokens += result.rows_affected();
}
tx.commit().await.map_err(AuthError::database)?;
} else {
debug!("logout requested without an active session");
debug!("logout requested without an active session or resource access cookies");
}
debug!(
revoked_access_tokens,
cleared_access_cookies = access_cookies.len(),
"password-derived resource access cleared during logout"
);
let mut response = Json(serde_json::json!({"ok": true})).into_response();
response
.headers_mut()
.insert(header::SET_COOKIE, crate::security::clear_session_cookie());
.append(header::SET_COOKIE, crate::security::clear_session_cookie());
for (name, _) in access_cookies {
if let Some(cookie) = crate::security::clear_resource_access_cookie(&name) {
response.headers_mut().append(header::SET_COOKIE, cookie);
}
}
Ok(response)
}
@@ -2286,6 +2694,10 @@ fn random_token() -> String {
random_hex_token::<32>()
}
fn valid_share_token(value: &str) -> bool {
value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
}
fn random_confirmation_token() -> String {
random_hex_token::<32>()
}
@@ -2641,3 +3053,7 @@ impl axum::response::IntoResponse for AuthError {
.into_response()
}
}
#[cfg(test)]
#[path = "../tests/auth.rs"]
mod logout_tests;
+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;
+46 -8
View File
@@ -55,6 +55,8 @@ 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,
@@ -77,7 +79,9 @@ 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)?;
@@ -107,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,
@@ -167,9 +167,12 @@ 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)?,
@@ -213,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;
+2
View File
@@ -19,6 +19,8 @@ const KNOWN_CONFIG_KEYS: &[&str] = &[
"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))
+22 -24
View File
@@ -15,6 +15,7 @@ pub struct EditorPreferences {
pub editor_line_numbers: bool,
pub preview_line_numbers: bool,
pub line_links: bool,
pub toolbar_collapsed: bool,
pub font_family: String,
pub font_size: i64,
}
@@ -26,6 +27,7 @@ impl Default for EditorPreferences {
editor_line_numbers: true,
preview_line_numbers: false,
line_links: false,
toolbar_collapsed: false,
font_family: "mono".into(),
font_size: 14,
}
@@ -78,14 +80,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,8 +94,9 @@ 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,
font_family: crate::row_decode::text(&row, 5)?,
font_size: row.try_get(6)?,
}))
}
@@ -113,20 +113,18 @@ pub async fn save_editor_configuration(
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.font_family)
.bind(preferences.font_size)
.execute(&mut *tx)
.await?;
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.font_family)
.bind(preferences.font_size)
.execute(&mut *tx)
.await?;
}
if let Some((resource_kind, resource_slug, settings)) = resource_settings {
+177 -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)]
@@ -72,6 +73,13 @@ pub struct Note {
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)]
struct SqliteNote {
id: i64,
@@ -135,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())
@@ -143,6 +152,7 @@ pub async fn create_workspace(
.bind(slug)
.bind(title)
.bind(password_hash)
.bind(created_by_guest_id)
.execute(pool.pool())
.await?;
@@ -152,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| {
@@ -316,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)
@@ -392,7 +481,6 @@ 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>,
}
@@ -428,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| {
@@ -476,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,
@@ -486,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 {
@@ -496,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")?,
})
}
}
@@ -538,9 +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?
+2 -59
View File
@@ -87,62 +87,5 @@ pub fn public_file_url(public_base: Option<&str>, stored_url: &str) -> String {
}
#[cfg(test)]
mod tests {
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"
);
}
}
#[path = "tests/file_urls.rs"]
mod tests;
+27 -12
View File
@@ -12,6 +12,7 @@ mod app;
mod assets;
mod auth;
mod cache;
mod collab;
mod config;
mod database;
mod db;
@@ -63,6 +64,8 @@ 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"),
@@ -105,6 +108,8 @@ 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(),
@@ -142,12 +147,30 @@ 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);
@@ -167,7 +190,7 @@ fn print_startup_credential() {
fn startup_credential() -> String {
format!(
"RustPad {}\nCopyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl",
"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")
)
}
@@ -278,16 +301,8 @@ async fn shutdown_signal() {
}
#[cfg(test)]
mod startup_tests {
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"));
}
}
#[path = "tests/main.rs"]
mod startup_tests;
async fn run_migrations(db: &Database) -> Result<(), sqlx::migrate::MigrateError> {
let path = match db.kind() {
+31 -24
View File
@@ -88,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,
@@ -105,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,
@@ -148,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 {
@@ -242,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;
@@ -259,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;
@@ -302,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;
+65 -24
View File
@@ -31,9 +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_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 = ?"#
@@ -61,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(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(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, font_family, font_size, updated_at) VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON DUPLICATE KEY UPDATE compact_view = VALUES(compact_view), editor_line_numbers = VALUES(editor_line_numbers), preview_line_numbers = VALUES(preview_line_numbers), line_links = VALUES(line_links), toolbar_collapsed = VALUES(toolbar_collapsed), font_family = VALUES(font_family), font_size = VALUES(font_size), updated_at = CURRENT_TIMESTAMP"#
}
Query::EDITOR_PREFERENCES_UPSERT_NOTE => {
r#"INSERT INTO user_editor_preferences (user_id, pad_id, note_id, compact_view, editor_line_numbers, preview_line_numbers, line_links, font_family, font_size, updated_at) VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON DUPLICATE KEY UPDATE compact_view = VALUES(compact_view), editor_line_numbers = VALUES(editor_line_numbers), preview_line_numbers = VALUES(preview_line_numbers), line_links = VALUES(line_links), font_family = VALUES(font_family), font_size = VALUES(font_size), updated_at = CURRENT_TIMESTAMP"#
r#"INSERT INTO user_editor_preferences (user_id, pad_id, note_id, compact_view, editor_line_numbers, preview_line_numbers, line_links, toolbar_collapsed, font_family, font_size, updated_at) VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON DUPLICATE KEY UPDATE compact_view = VALUES(compact_view), editor_line_numbers = VALUES(editor_line_numbers), preview_line_numbers = VALUES(preview_line_numbers), line_links = VALUES(line_links), toolbar_collapsed = VALUES(toolbar_collapsed), font_family = VALUES(font_family), font_size = VALUES(font_size), updated_at = CURRENT_TIMESTAMP"#
}
Query::EDITOR_PREFERENCES_DELETE_BY_USER => {
r#"DELETE FROM user_editor_preferences WHERE user_id = ?"#
@@ -174,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 = ?"#
@@ -202,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 (?, ?, ?, ?)"#
}
@@ -233,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 = ?"#
@@ -251,12 +252,34 @@ 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, 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"#
}
@@ -278,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, CAST(created_by_guest_id AS CHAR CHARACTER SET utf8mb4) AS created_by_guest_id 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, created_by_guest_id) VALUES (?, ?, ?, ?)"#,
Query::Q013 => {
r#"UPDATE pads SET content = ?, owner_map = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"#
}
@@ -296,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"#,
@@ -338,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 = ?"#
}
@@ -358,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;
+57 -22
View File
@@ -31,9 +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_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"#
@@ -61,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, 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, 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, font_family, font_size, updated_at) VALUES ($1, $2, NULL, $3, $4, $5, $6, $7, $8, $9, CURRENT_TIMESTAMP::text) ON CONFLICT (user_id, pad_id) DO UPDATE SET compact_view = EXCLUDED.compact_view, editor_line_numbers = EXCLUDED.editor_line_numbers, preview_line_numbers = EXCLUDED.preview_line_numbers, line_links = EXCLUDED.line_links, toolbar_collapsed = EXCLUDED.toolbar_collapsed, font_family = EXCLUDED.font_family, font_size = EXCLUDED.font_size, updated_at = CURRENT_TIMESTAMP::text"#
}
Query::EDITOR_PREFERENCES_UPSERT_NOTE => {
r#"INSERT INTO user_editor_preferences (user_id, pad_id, note_id, compact_view, editor_line_numbers, preview_line_numbers, line_links, font_family, font_size, updated_at) VALUES ($1, NULL, $2, $3, $4, $5, $6, $7, $8, CURRENT_TIMESTAMP::text) ON CONFLICT (user_id, note_id) DO UPDATE SET compact_view = EXCLUDED.compact_view, editor_line_numbers = EXCLUDED.editor_line_numbers, preview_line_numbers = EXCLUDED.preview_line_numbers, line_links = EXCLUDED.line_links, font_family = EXCLUDED.font_family, font_size = EXCLUDED.font_size, updated_at = CURRENT_TIMESTAMP::text"#
r#"INSERT INTO user_editor_preferences (user_id, pad_id, note_id, compact_view, editor_line_numbers, preview_line_numbers, line_links, toolbar_collapsed, font_family, font_size, updated_at) VALUES ($1, NULL, $2, $3, $4, $5, $6, $7, $8, $9, CURRENT_TIMESTAMP::text) ON CONFLICT (user_id, note_id) DO UPDATE SET compact_view = EXCLUDED.compact_view, editor_line_numbers = EXCLUDED.editor_line_numbers, preview_line_numbers = EXCLUDED.preview_line_numbers, line_links = EXCLUDED.line_links, toolbar_collapsed = EXCLUDED.toolbar_collapsed, font_family = EXCLUDED.font_family, font_size = EXCLUDED.font_size, updated_at = CURRENT_TIMESTAMP::text"#
}
Query::EDITOR_PREFERENCES_DELETE_BY_USER => {
r#"DELETE FROM user_editor_preferences WHERE user_id = $1"#
@@ -204,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)"#
}
@@ -235,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"#
@@ -253,12 +254,32 @@ 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, created_by_guest_id FROM notes WHERE workspace_id = $1 ORDER BY updated_at DESC, id DESC"#
}
@@ -282,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, created_by_guest_id 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, created_by_guest_id) VALUES ($1, $2, $3, $4)"#,
Query::Q013 => {
r#"UPDATE pads SET content = $1, owner_map = $2, updated_at = (CURRENT_TIMESTAMP::text) WHERE id = $3"#
}
@@ -300,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"#,
@@ -336,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"#,
}
}
+59 -22
View File
@@ -31,9 +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_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 = ?"#
@@ -61,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, 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, 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, font_family, font_size, updated_at) VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT (user_id, pad_id) DO UPDATE SET compact_view = excluded.compact_view, editor_line_numbers = excluded.editor_line_numbers, preview_line_numbers = excluded.preview_line_numbers, line_links = excluded.line_links, toolbar_collapsed = excluded.toolbar_collapsed, font_family = excluded.font_family, font_size = excluded.font_size, updated_at = CURRENT_TIMESTAMP"#
}
Query::EDITOR_PREFERENCES_UPSERT_NOTE => {
r#"INSERT INTO user_editor_preferences (user_id, pad_id, note_id, compact_view, editor_line_numbers, preview_line_numbers, line_links, font_family, font_size, updated_at) VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT (user_id, note_id) DO UPDATE SET compact_view = excluded.compact_view, editor_line_numbers = excluded.editor_line_numbers, preview_line_numbers = excluded.preview_line_numbers, line_links = excluded.line_links, font_family = excluded.font_family, font_size = excluded.font_size, updated_at = CURRENT_TIMESTAMP"#
r#"INSERT INTO user_editor_preferences (user_id, pad_id, note_id, compact_view, editor_line_numbers, preview_line_numbers, line_links, toolbar_collapsed, font_family, font_size, updated_at) VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT (user_id, note_id) DO UPDATE SET compact_view = excluded.compact_view, editor_line_numbers = excluded.editor_line_numbers, preview_line_numbers = excluded.preview_line_numbers, line_links = excluded.line_links, toolbar_collapsed = excluded.toolbar_collapsed, font_family = excluded.font_family, font_size = excluded.font_size, updated_at = CURRENT_TIMESTAMP"#
}
Query::EDITOR_PREFERENCES_DELETE_BY_USER => {
r#"DELETE FROM user_editor_preferences WHERE user_id = ?"#
@@ -202,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 (?, ?, ?, ?)"#
}
@@ -233,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 = ?"#
@@ -251,12 +252,34 @@ 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, created_by_guest_id FROM notes WHERE workspace_id = ? ORDER BY updated_at DESC, id DESC"#
}
@@ -278,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, created_by_guest_id 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, created_by_guest_id) VALUES (?, ?, ?, ?)"#,
Query::Q013 => {
r#"UPDATE pads SET content = ?, owner_map = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"#
}
@@ -296,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"#,
@@ -332,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 = ?"#
}
@@ -352,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 = ?"#,
}
}
+62 -119
View File
@@ -22,6 +22,9 @@ 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 {
@@ -50,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))
}
@@ -58,6 +86,10 @@ 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))
@@ -72,7 +104,7 @@ pub async fn csrf_token_endpoint(headers: HeaderMap) -> Response {
.insert(header::SET_COOKIE, csrf_cookie(&token));
response.headers_mut().insert(
header::CACHE_CONTROL,
HeaderValue::from_static("no-store, max-age=0"),
HeaderValue::from_static("no-cache, no-store, max-age=0"),
);
response
}
@@ -104,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"))
@@ -158,7 +203,20 @@ 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 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> {
@@ -233,120 +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;"));
}
#[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"));
}
}
#[path = "tests/security.rs"]
mod tests;
+101 -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,6 +108,8 @@ 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>,
@@ -108,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>>,
@@ -119,6 +134,8 @@ 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>,
@@ -136,6 +153,8 @@ 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,
@@ -148,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()),
@@ -197,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();
@@ -210,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,
@@ -224,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,
@@ -284,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() {
+53
View File
@@ -0,0 +1,53 @@
/*
* 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));
}
+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"),
}
}
+728 -80
View File
@@ -1,5 +1,7 @@
use crate::{
auth, db,
auth,
collab::{self, AppliedOperation, OwnerReplacement, TextOperation},
db,
state::{AppState, NoteUpdate, PresenceUser, RoomEvent, SharedState},
};
use axum::{
@@ -13,7 +15,10 @@ use axum::{
use futures_util::{SinkExt, StreamExt};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::time::{Duration, Instant};
use std::{
collections::HashSet,
time::{Duration, Instant},
};
use tracing::{debug, info, warn};
mod pad;
@@ -91,10 +96,17 @@ enum ClientMessage {
color: Option<String>,
#[serde(default)]
diagnostics: Option<ClientDiagnostics>,
#[serde(default)]
client_id: Option<String>,
#[serde(default)]
known_revision_id: Option<i64>,
},
Update {
content: String,
owner_map: Option<String>,
base_revision_id: i64,
update_id: u64,
operation: TextOperation,
#[serde(default)]
owner_replacements: Vec<OwnerReplacement>,
},
Ping {
nonce: u64,
@@ -115,14 +127,27 @@ enum ServerMessage {
note_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>,
@@ -137,11 +162,37 @@ enum ServerMessage {
Diagnostics {
diagnostics: ConnectionDiagnostics,
},
PasswordRequired,
PasswordChanged,
Error {
message: String,
},
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum WorkspaceWatchClientMessage {
Authenticate {
#[serde(default)]
access_token: Option<String>,
#[serde(default)]
client_id: Option<String>,
},
Ping {
nonce: u64,
},
}
#[derive(Debug, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum WorkspaceWatchServerMessage {
Watching,
Pong { nonce: u64 },
PasswordRequired,
PasswordChanged,
Error { message: String },
}
fn connection_diagnostics(
request: &RequestClientContext,
client: Option<ClientDiagnostics>,
@@ -196,37 +247,317 @@ async fn resource_permission_from_tokens(
state: &SharedState,
kind: &str,
slug: &str,
access_token: Option<&str>,
access_tokens: &[Option<&str>],
session_token: Option<&str>,
) -> Option<String> {
let access_permission = auth::share_link_permission(state, kind, slug, access_token)
let mut read_allowed = false;
for token in access_tokens {
match auth::share_access_permission(state, kind, slug, *token)
.await
.ok()
.flatten()
.as_deref()
{
Some("rw") => return Some("rw".into()),
Some("ro") => read_allowed = true,
_ => {}
}
}
match auth::account_resource_permission(state, kind, slug, session_token)
.await
.ok()
.flatten();
let session_permission = auth::account_resource_permission(state, kind, slug, session_token)
.await
.ok()
.flatten();
if access_permission.as_deref() == Some("rw") || session_permission.as_deref() == Some("rw") {
Some("rw".into())
} else if access_permission.as_deref() == Some("ro")
|| session_permission.as_deref() == Some("ro")
.flatten()
.as_deref()
{
Some("ro".into())
} else {
None
Some("rw") => Some("rw".into()),
Some("ro") if !read_allowed => Some("ro".into()),
_ if read_allowed => Some("ro".into()),
_ => None,
}
}
async fn anonymous_access_from_tokens(
async fn password_access_from_tokens(
state: &SharedState,
kind: &str,
slug: &str,
access_token: Option<&str>,
access_tokens: &[Option<&str>],
) -> bool {
crate::api::verify_resource_access_token(state, kind, slug, access_token)
for token in access_tokens {
if crate::api::verify_password_access_token(state, kind, slug, *token)
.await
.unwrap_or(false)
{
return true;
}
}
false
}
fn password_event_excludes_connection(
except_client_id: Option<&str>,
connection_client_id: &str,
) -> bool {
except_client_id.is_some_and(|except_client_id| except_client_id == connection_client_id)
}
async fn current_resource_access(
state: &SharedState,
kind: &str,
slug: &str,
access_tokens: &[Option<&str>],
session_token: Option<&str>,
password_ok: bool,
) -> (bool, bool) {
if auth::resource_is_public_unprotected(state, kind, slug)
.await
.unwrap_or(false)
{
return (true, true);
}
let permission =
resource_permission_from_tokens(state, kind, slug, access_tokens, session_token).await;
let password_token_ok = password_access_from_tokens(state, kind, slug, access_tokens).await;
let write_allowed = password_ok || password_token_ok || permission.as_deref() == Some("rw");
let read_allowed = write_allowed || permission.as_deref() == Some("ro");
(read_allowed, write_allowed)
}
pub async fn upgrade_workspace_watch(
ws: WebSocketUpgrade,
headers: HeaderMap,
Path(workspace_slug): Path<String>,
State(state): State<SharedState>,
) -> Response {
if !crate::security::websocket_origin_allowed(&headers) {
warn!(%workspace_slug, "workspace watch websocket rejected: invalid origin");
return (StatusCode::FORBIDDEN, "Invalid WebSocket origin").into_response();
}
let account_token = crate::security::session_token(&headers).map(str::to_owned);
let share_session_token =
crate::security::share_session_token(&headers, "workspace", &workspace_slug)
.map(str::to_owned);
let resource_token =
crate::security::resource_token(&headers, "workspace", &workspace_slug).map(str::to_owned);
ws.on_upgrade(move |socket| {
handle_workspace_watch(
socket,
state,
workspace_slug,
account_token,
share_session_token,
resource_token,
)
})
}
async fn workspace_watch_access_lost_message(
state: &SharedState,
workspace_slug: &str,
) -> WorkspaceWatchServerMessage {
if db::find_workspace(&state.db, workspace_slug)
.await
.ok()
.flatten()
.is_some_and(|workspace| workspace.password_hash.is_some())
{
WorkspaceWatchServerMessage::PasswordRequired
} else {
WorkspaceWatchServerMessage::Error {
message: "Access expired or revoked".into(),
}
}
}
async fn handle_workspace_watch(
mut socket: WebSocket,
state: SharedState,
workspace_slug: String,
cookie_session_token: Option<String>,
cookie_share_session_token: Option<String>,
cookie_password_token: Option<String>,
) {
let (explicit_access_token, collaboration_client_id) = match socket.recv().await {
Some(Ok(Message::Text(text))) => {
match serde_json::from_str::<WorkspaceWatchClientMessage>(&text) {
Ok(WorkspaceWatchClientMessage::Authenticate {
access_token,
client_id,
}) => (
access_token
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty() && *value != "cookie")
.map(str::to_owned),
clean_collaboration_client_id(client_id)
.unwrap_or_else(|| format!("watch_{}", db::random_suffix(24))),
),
_ => {
let _ = send_workspace_watch(
&mut socket,
&WorkspaceWatchServerMessage::Error {
message: "Authentication required".into(),
},
)
.await;
return;
}
}
}
_ => return,
};
let Some(workspace) = db::find_workspace(&state.db, &workspace_slug)
.await
.ok()
.flatten()
else {
let _ = send_workspace_watch(
&mut socket,
&WorkspaceWatchServerMessage::Error {
message: "Workspace not found".into(),
},
)
.await;
return;
};
let session_token = cookie_session_token;
let external_tokens = [
explicit_access_token.as_deref(),
cookie_share_session_token.as_deref(),
cookie_password_token.as_deref(),
];
let channel = state.workspace_channel(&workspace_slug).await;
let mut updates = channel.subscribe();
let (read_allowed, _) = current_resource_access(
&state,
"workspace",
&workspace_slug,
&external_tokens,
session_token.as_deref(),
false,
)
.await;
if !read_allowed {
let message = if workspace.password_hash.is_some() {
WorkspaceWatchServerMessage::PasswordRequired
} else {
WorkspaceWatchServerMessage::Error {
message: "Workspace not found".into(),
}
};
let _ = send_workspace_watch(&mut socket, &message).await;
return;
}
if send_workspace_watch(&mut socket, &WorkspaceWatchServerMessage::Watching)
.await
.is_err()
{
return;
}
let (mut sender, mut receiver) = socket.split();
let mut access_refresh = tokio::time::interval(Duration::from_secs(10));
access_refresh.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
tokio::select! {
incoming = receiver.next() => {
match incoming {
Some(Ok(Message::Text(text))) => {
match serde_json::from_str::<WorkspaceWatchClientMessage>(&text) {
Ok(WorkspaceWatchClientMessage::Ping { nonce }) => {
if send_workspace_watch_split(
&mut sender,
&WorkspaceWatchServerMessage::Pong { nonce },
).await.is_err() {
break;
}
}
Ok(WorkspaceWatchClientMessage::Authenticate { .. }) => {}
Err(error) => warn!(%error, %workspace_slug, "invalid workspace watch message"),
}
}
Some(Ok(Message::Close(_))) | None => break,
Some(Ok(_)) => {}
Some(Err(error)) => {
debug!(%error, %workspace_slug, "workspace watch receive error");
break;
}
}
}
_ = access_refresh.tick() => {
let (read_allowed, _) = current_resource_access(
&state,
"workspace",
&workspace_slug,
&external_tokens,
session_token.as_deref(),
false,
).await;
if !read_allowed {
let message = workspace_watch_access_lost_message(
&state,
&workspace_slug,
).await;
let _ = send_workspace_watch_split(
&mut sender,
&message,
).await;
break;
}
}
update = updates.recv() => {
match update {
Ok(RoomEvent::PasswordRequired { except_client_id }) => {
if password_event_excludes_connection(
except_client_id.as_deref(),
&collaboration_client_id,
) {
let _ = send_workspace_watch_split(
&mut sender,
&WorkspaceWatchServerMessage::PasswordChanged,
).await;
break;
}
let (read_allowed, _) = current_resource_access(
&state,
"workspace",
&workspace_slug,
&external_tokens,
session_token.as_deref(),
false,
).await;
if !read_allowed {
let _ = send_workspace_watch_split(
&mut sender,
&WorkspaceWatchServerMessage::PasswordRequired,
).await;
break;
}
}
Ok(_) => {}
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
let (read_allowed, _) = current_resource_access(
&state,
"workspace",
&workspace_slug,
&external_tokens,
session_token.as_deref(),
false,
).await;
if !read_allowed {
let _ = send_workspace_watch_split(
&mut sender,
&WorkspaceWatchServerMessage::PasswordRequired,
).await;
break;
}
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
}
}
}
// Merged from note.rs
@@ -241,6 +572,9 @@ pub async fn upgrade(
return (StatusCode::FORBIDDEN, "Invalid WebSocket origin").into_response();
}
let account_token = crate::security::session_token(&headers).map(str::to_owned);
let share_session_token =
crate::security::share_session_token(&headers, "workspace", &workspace_slug)
.map(str::to_owned);
let resource_token =
crate::security::resource_token(&headers, "workspace", &workspace_slug).map(str::to_owned);
let client_key = crate::security::client_key(&headers);
@@ -252,6 +586,7 @@ pub async fn upgrade(
workspace_slug,
note_slug,
account_token,
share_session_token,
resource_token,
client_key,
client_context,
@@ -265,7 +600,8 @@ async fn handle_socket(
workspace_slug: String,
note_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,
) {
@@ -288,38 +624,55 @@ async fn handle_socket(
let _ = send_error(&mut socket, "Note not found").await;
return;
};
let (password, access_token, nickname, guest_id, color, client_diagnostics) =
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,
}) => (
password,
access_token,
clean_nickname(nickname),
clean_guest_id(guest_id),
clean_color(color),
diagnostics,
),
_ => {
let _ = send_error(&mut socket, "Wymagane uwierzytelnienie").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_error(&mut socket, "Wymagane uwierzytelnienie").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) => {
@@ -343,18 +696,12 @@ async fn handle_socket(
&state,
"workspace",
&workspace_slug,
access_token.as_deref(),
&external_tokens,
session_token.as_deref(),
)
.await;
let anonymous_token_ok = permission.is_none()
&& anonymous_access_from_tokens(
&state,
"workspace",
&workspace_slug,
access_token.as_deref(),
)
.await;
let anonymous_token_ok =
password_access_from_tokens(&state, "workspace", &workspace_slug, &external_tokens).await;
let password_limit_key = format!("resource-password:{client_key}:workspace:{workspace_slug}");
let password_attempted = password
.as_deref()
@@ -410,18 +757,90 @@ async fn handle_socket(
let _ = send_error(&mut socket, "Invalid password").await;
return;
}
let write_allowed = permission.as_deref() == Some("rw")
|| anonymous_token_ok
|| password_ok
|| (workspace.is_private == 0 && workspace.password_hash.is_none() && permission.is_none());
let (_, write_allowed) = current_resource_access(
&state,
"workspace",
&workspace_slug,
&external_tokens,
session_token.as_deref(),
password_ok,
)
.await;
info!(workspace_id = workspace.id, note_id = note.id, nickname = ?nickname, "note websocket authenticated");
let room_key = AppState::note_room_key(&workspace_slug, &note_slug);
let collaboration_snapshot = match db::note_collaboration_snapshot(&state.db, note.id).await {
Ok(snapshot) => snapshot,
Err(error) => {
warn!(%error, workspace_id = workspace.id, note_id = note.id, "failed to load collaborative document");
let _ = send_error(&mut socket, "Failed to load the document").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.note_channel(&workspace_slug, &note_slug).await;
let mut updates = channel.subscribe();
let persisted_acknowledged_update_id = match db::latest_note_collaboration_update_id(
&state.db,
note.id,
&collaboration_client_id,
)
.await
{
Ok(update_id) => update_id,
Err(error) => {
warn!(%error, workspace_id = workspace.id, note_id = note.id, "failed to load collaborative acknowledgement");
let _ = send_error(&mut socket, "Failed to load the document").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(
&mut socket,
&ServerMessage::Authenticated {
workspace_title: workspace.title.clone(),
note_title: note.title.clone(),
content: note.content.clone(),
owner_map: note.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 {
@@ -434,15 +853,15 @@ async fn handle_socket(
{
return;
}
let room_key = AppState::note_room_key(&workspace_slug, &note_slug);
let channel = state.note_channel(&workspace_slug, &note_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_split(
&mut sender,
@@ -461,13 +880,130 @@ async fn handle_socket(
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_split(&mut sender,&ServerMessage::Error{message:"Read-only access".into()}).await; continue; }
if content.len()>2_000_000 { let _=send_split(&mut sender,&ServerMessage::Error{message:"The document is too large".into()}).await; continue; }
let owner_map=owner_map.unwrap_or_else(||"[]".into());
match db::save_revision(&state.db,note.id,workspace.id,&content,nickname.as_deref(),&owner_map).await {
Ok((revision_id,updated_at))=>{let _=channel.send(RoomEvent::Document(NoteUpdate{content,revision_id,updated_at,author:nickname.clone(),owner_map}));}
Err(error)=>warn!(%error, workspace_id = workspace.id, note_id = note.id, "failed to save revision"),
Ok(ClientMessage::Update{base_revision_id,update_id,operation,owner_replacements})=>{
let (read_allowed, current_write_allowed) = current_resource_access(
&state,
"workspace",
&workspace_slug,
&external_tokens,
session_token.as_deref(),
password_ok,
).await;
if !read_allowed { let _=send_split(&mut sender,&ServerMessage::Error{message:"Access expired or revoked".into()}).await; break; }
if !current_write_allowed { let _=send_split(&mut sender,&ServerMessage::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_split(&mut sender,&ServerMessage::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_split(&mut sender, &ServerMessage::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_split(&mut sender,&ServerMessage::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, workspace_id = workspace.id, note_id = note.id, "invalid collaborative operation");
let _=send_split(&mut sender,&ServerMessage::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, workspace_id = workspace.id, note_id = note.id, "failed to apply collaborative operation");
let _=send_split(&mut sender,&ServerMessage::Error{message:"Invalid collaborative update".into()}).await;
continue;
}
};
if content.len()>2_000_000 {
drop(document);
let _=send_split(&mut sender,&ServerMessage::Error{message:"The document is too large".into()}).await;
continue;
}
match db::save_collaborative_revision(
&state.db,
note.id,
workspace.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, workspace_id = workspace.id, note_id = note.id, "failed to save revision");
let _=send_split(&mut sender,&ServerMessage::Error{message:"Failed to save the document".into()}).await;
}
}
}
Ok(ClientMessage::Ping{nonce})=>{ let _=send_split(&mut sender,&ServerMessage::Pong{nonce}).await; },
@@ -480,12 +1016,68 @@ async fn handle_socket(
},
Some(Ok(Message::Close(_)))|None=>break, Some(Ok(_))=>{}, Some(Err(error))=>{debug!(%error,"websocket receive error");break;}
},
update=updates.recv()=>match update {
Ok(RoomEvent::Document(update))=>if send_split(&mut sender,&ServerMessage::Document{content:update.content,revision_id:update.revision_id,updated_at:update.updated_at,author:update.author,owner_map:update.owner_map}).await.is_err(){break;},
Ok(RoomEvent::Presence(users))=>if send_split(&mut sender,&ServerMessage::Presence{users}).await.is_err(){break;},
Ok(RoomEvent::Chat{sender:chat_sender,text})=>if send_split(&mut sender,&ServerMessage::Chat{sender:chat_sender,text}).await.is_err(){break;},
Err(tokio::sync::broadcast::error::RecvError::Lagged(_))=>if let Ok(Some(current))=db::find_note(&state.db,workspace.id,&note_slug).await { if send_split(&mut sender,&ServerMessage::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,
"workspace",
&workspace_slug,
&external_tokens,
session_token.as_deref(),
password_ok,
).await;
if !read_allowed {
let _=send_split(&mut sender,&ServerMessage::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_split(&mut sender,&ServerMessage::PasswordChanged).await;
break;
}
let (read_allowed, _) = current_resource_access(
&state,
"workspace",
&workspace_slug,
&external_tokens,
session_token.as_deref(),
false,
).await;
if !read_allowed {
let _=send_split(&mut sender,&ServerMessage::PasswordRequired).await;
break;
}
continue;
}
let (read_allowed, _) = current_resource_access(
&state,
"workspace",
&workspace_slug,
&external_tokens,
session_token.as_deref(),
password_ok,
).await;
if !read_allowed {
let _=send_split(&mut sender,&ServerMessage::Error{message:"Access expired or revoked".into()}).await;
break;
}
match update {
Ok(RoomEvent::Document(update))=>if send_split(&mut sender,&ServerMessage::Document{base_revision_id:update.base_revision_id,revision_id:update.revision_id,updated_at:update.updated_at,author:update.author,client_id:update.client_id,update_id:update.update_id,operation:update.operation,owner_replacements:update.owner_replacements}).await.is_err(){break;},
Ok(RoomEvent::Presence(users))=>if send_split(&mut sender,&ServerMessage::Presence{users}).await.is_err(){break;},
Ok(RoomEvent::Chat{sender:chat_sender,text})=>if send_split(&mut sender,&ServerMessage::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_split(&mut sender,&ServerMessage::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,
}
}
}
}
@@ -497,6 +1089,36 @@ async fn handle_socket(
"note websocket disconnected"
);
}
pub(crate) fn clean_collaboration_client_id(value: Option<String>) -> Option<String> {
value
.map(|value| value.trim().chars().take(64).collect::<String>())
.filter(|value| {
value.len() >= 8
&& value.chars().all(|character| {
character.is_ascii_alphanumeric() || matches!(character, '-' | '_')
})
})
}
fn valid_owner_replacements(replacements: &[OwnerReplacement]) -> bool {
if replacements.len() > 64 {
return false;
}
let mut owners = HashSet::with_capacity(replacements.len());
replacements.iter().all(|replacement| {
!replacement.owner.is_empty()
&& replacement.owner.chars().count() <= 80
&& !replacement.replacement.is_empty()
&& replacement.replacement.chars().count() <= 120
&& !replacement.owner.chars().any(char::is_control)
&& !replacement
.replacement
.chars()
.any(|character| character.is_control() && character != '\u{001f}')
&& owners.insert(replacement.owner.as_str())
})
}
fn clean_nickname(value: Option<String>) -> Option<String> {
value
.map(|v| v.trim().chars().take(40).collect::<String>())
@@ -559,4 +1181,30 @@ async fn send_split(
.await
}
async fn send_workspace_watch(
socket: &mut WebSocket,
message: &WorkspaceWatchServerMessage,
) -> Result<(), axum::Error> {
socket
.send(Message::Text(
serde_json::to_string(message).unwrap().into(),
))
.await
}
async fn send_workspace_watch_split(
sender: &mut futures_util::stream::SplitSink<WebSocket, Message>,
message: &WorkspaceWatchServerMessage,
) -> Result<(), axum::Error> {
sender
.send(Message::Text(
serde_json::to_string(message).unwrap().into(),
))
.await
}
// Merged from pad.rs
#[cfg(test)]
#[path = "../tests/websocket.rs"]
mod tests;
+348 -57
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>,
@@ -29,6 +42,8 @@ enum PadServerMessage {
Diagnostics {
diagnostics: ConnectionDiagnostics,
},
PasswordRequired,
PasswordChanged,
Error {
message: String,
},
@@ -44,6 +59,8 @@ 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 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);
@@ -53,6 +70,7 @@ pub async fn upgrade_pad(
state,
slug,
account_token,
share_session_token,
resource_token,
client_key,
client_context,
@@ -64,7 +82,8 @@ 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,
) {
@@ -80,44 +99,61 @@ async fn handle_pad_socket(
.await;
return;
};
let (password, access_token, nickname, guest_id, color, client_diagnostics) =
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,
}) => (
password,
access_token,
clean_nickname(nickname),
clean_guest_id(guest_id),
clean_color(color),
diagnostics,
),
_ => {
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) => {
@@ -141,12 +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()
@@ -210,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 {
@@ -233,15 +350,15 @@ 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,
@@ -260,11 +377,129 @@ async fn handle_pad_socket(
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; },
@@ -280,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,
}
}
}
}
+1065 -244
View File
File diff suppressed because it is too large Load Diff
+50 -19
View File
@@ -43,9 +43,22 @@
<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"><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-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"
@@ -103,6 +116,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>
@@ -116,16 +131,26 @@
<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="connection-notice" class="connection-notice" role="status" aria-live="polite" hidden>
<span class="connection-notice__signal"
@@ -134,7 +159,7 @@
interrupted</strong><span id="connection-notice-message">Trying to reconnect
automatically.</span></span>
</div>
<div id="editor-workspace" class="workspace view-split">
<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"
@@ -153,7 +178,7 @@
<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">
@@ -251,7 +276,8 @@
<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>
@@ -260,8 +286,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>
@@ -276,12 +307,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"><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>
<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
+10 -5
View File
@@ -30,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>
@@ -54,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">
@@ -126,8 +126,13 @@
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>
<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>
+4 -4
View File
@@ -85,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) {
@@ -123,7 +123,7 @@ 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 {
@@ -166,7 +166,7 @@ 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;
+7
View File
@@ -19,6 +19,12 @@ 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;
@@ -324,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");
+277
View File
@@ -0,0 +1,277 @@
/*
* Copyright (C) 2026 Mateusz Gruszczynski @linuxiarz.pl
* Source-Available Code / Dual-Licensed.
*
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
* Commercial or production use requires a valid paid license.
* See LICENSE file in repository root for details.
*/
import {
applyOperationToDocument,
compareOperationKeys,
composeOperations,
documentAfterPending,
identityOperation,
normalizeOperation,
operationBaseLength,
operationFromEdit,
operationTargetLength,
transformOperations,
} from "@rustpad/collaboration";
import { parseAuthorship } from "@rustpad/authorship";
function hasTextEffect(operation) {
return normalizeOperation(operation).components.some(component => component.kind === "insert" || component.kind === "delete");
}
function mergeOwnerReplacements(previous = [], next = []) {
const replacements = new Map();
for (const item of [...previous, ...next]) {
const owner = String(item?.owner || "");
const replacement = String(item?.replacement || "");
if (owner && replacement) replacements.set(owner, { owner, replacement });
}
return [...replacements.values()];
}
function pendingEnvelope(clientId, updateId, operation, ownerReplacements = []) {
return {
clientId,
updateId,
operation: normalizeOperation(operation),
ownerReplacements: mergeOwnerReplacements([], ownerReplacements),
};
}
function serverEnvelope(message) {
return {
baseRevisionId: Number(message?.base_revision_id ?? message?.baseRevisionId),
revisionId: Number(message?.revision_id ?? message?.revisionId),
clientId: String(message?.client_id ?? message?.clientId ?? ""),
updateId: Number(message?.update_id ?? message?.updateId ?? 0),
operation: normalizeOperation(message?.operation),
ownerReplacements: mergeOwnerReplacements([], message?.owner_replacements ?? message?.ownerReplacements ?? []),
};
}
export class CollaborationRevisionGapError extends Error {
constructor(expected, actual) {
super(`Collaborative revision gap: expected ${expected}, received ${actual}`);
this.name = "CollaborationRevisionGapError";
this.expected = expected;
this.actual = actual;
}
}
export class CollaborationSession {
constructor(clientId) {
this.clientId = String(clientId || "");
this.ready = false;
this.serverContent = "";
this.serverOwnerMap = "[]";
this.revisionId = 0;
this.outstanding = null;
this.buffer = null;
this.nextUpdateId = 1;
}
initialize(content, ownerMap, revisionId, { clearPending = true } = {}) {
this.serverContent = String(content || "");
this.serverOwnerMap = ownerMap == null ? "[]" : String(ownerMap);
this.revisionId = Number(revisionId) || 0;
if (clearPending) {
this.outstanding = null;
this.buffer = null;
}
this.ready = true;
}
localDocument() {
return documentAfterPending(
this.serverContent,
this.serverOwnerMap,
this.outstanding,
this.buffer,
);
}
hasPending() {
return Boolean(this.outstanding || this.buffer);
}
queue(operation, ownerReplacements = []) {
operation = normalizeOperation(operation);
const replacements = mergeOwnerReplacements([], ownerReplacements);
if (!hasTextEffect(operation) && !replacements.length) return null;
const localLength = this.localDocument().content.length;
if (operationBaseLength(operation) !== localLength) {
throw new Error("Local operation base length does not match the collaborative document");
}
if (!this.buffer) {
this.buffer = pendingEnvelope(this.clientId, this.nextUpdateId++, operation, replacements);
} else {
this.buffer.operation = composeOperations(this.buffer.operation, operation);
this.buffer.ownerReplacements = mergeOwnerReplacements(this.buffer.ownerReplacements, replacements);
}
if (operationTargetLength(this.buffer.operation) !== this.localDocument().content.length) {
throw new Error("Buffered operation target length does not match the collaborative document");
}
return this.buffer;
}
sendable() {
if (!this.ready || this.outstanding || !this.buffer) return null;
return {
baseRevisionId: this.revisionId,
updateId: this.buffer.updateId,
operation: this.buffer.operation,
ownerReplacements: this.buffer.ownerReplacements,
};
}
markSent(updateId) {
if (this.outstanding || !this.buffer || this.buffer.updateId !== Number(updateId)) return false;
this.outstanding = this.buffer;
this.buffer = null;
return true;
}
integrate(message) {
const remote = serverEnvelope(message);
if (!Number.isFinite(remote.baseRevisionId) || !Number.isFinite(remote.revisionId)) {
throw new Error("Collaborative update is missing revision metadata");
}
if (remote.revisionId <= this.revisionId) return { duplicate: true, ownAck: false, remote: false };
if (remote.baseRevisionId !== this.revisionId) {
throw new CollaborationRevisionGapError(this.revisionId, remote.baseRevisionId);
}
const nextServer = applyOperationToDocument(
this.serverContent,
this.serverOwnerMap,
remote.operation,
remote.ownerReplacements,
);
const ownAck = Boolean(
this.outstanding
&& remote.clientId === this.clientId
&& remote.updateId === this.outstanding.updateId
);
if (ownAck) {
this.outstanding = null;
} else {
let remoteForPending = remote.operation;
if (this.outstanding) {
const outstandingBeforeRemote = compareOperationKeys(this.outstanding, remote) < 0;
const [outstandingPrime, remotePrime] = transformOperations(
this.outstanding.operation,
remoteForPending,
outstandingBeforeRemote,
);
this.outstanding.operation = outstandingPrime;
remoteForPending = remotePrime;
}
if (this.buffer) {
const bufferBeforeRemote = compareOperationKeys(this.buffer, remote) < 0;
const [bufferPrime] = transformOperations(
this.buffer.operation,
remoteForPending,
bufferBeforeRemote,
);
this.buffer.operation = bufferPrime;
}
}
this.serverContent = nextServer.content;
this.serverOwnerMap = nextServer.ownerMap;
this.revisionId = remote.revisionId;
return { duplicate: false, ownAck, remote: !ownAck };
}
resynchronize(message) {
const canonicalContent = String(message?.content || "");
const canonicalOwnerMap = message?.owner_map == null ? "[]" : String(message.owner_map);
const canonicalRevisionId = Number(message?.revision_id) || 0;
const outstanding = this.outstanding;
const buffer = this.buffer;
const local = this.localDocument();
const acknowledgedIds = (message?.acknowledged_update_ids || [])
.map(Number)
.filter(Number.isFinite);
const acknowledgedThrough = acknowledgedIds.length ? Math.max(...acknowledgedIds) : 0;
const messageClientId = String(message?.client_id ?? message?.clientId ?? "");
const messageUpdateId = Number(message?.update_id ?? message?.updateId ?? 0);
const outstandingAcknowledged = Boolean(
outstanding
&& (
acknowledgedThrough >= outstanding.updateId
|| (messageClientId === this.clientId && messageUpdateId === outstanding.updateId)
)
);
const pendingOwnerReplacements = mergeOwnerReplacements(
outstanding?.ownerReplacements || [],
buffer?.ownerReplacements || [],
);
let replayOperation = null;
let replayOwnerReplacements = [];
if (this.ready && (outstanding || buffer)) {
const canonicalAuthorship = parseAuthorship(canonicalContent, canonicalOwnerMap);
if (outstandingAcknowledged) {
if (buffer) {
const afterOutstanding = applyOperationToDocument(
this.serverContent,
this.serverOwnerMap,
outstanding.operation,
outstanding.ownerReplacements,
);
const missedRemote = operationFromEdit(
afterOutstanding.content,
canonicalContent,
canonicalAuthorship,
);
[replayOperation] = transformOperations(buffer.operation, missedRemote, false);
replayOwnerReplacements = buffer.ownerReplacements;
}
} else {
const localAuthorship = parseAuthorship(local.content, local.ownerMap);
const localOperation = operationFromEdit(this.serverContent, local.content, localAuthorship);
const missedRemote = operationFromEdit(
this.serverContent,
canonicalContent,
canonicalAuthorship,
);
[replayOperation] = transformOperations(localOperation, missedRemote, false);
replayOwnerReplacements = pendingOwnerReplacements;
}
}
this.initialize(canonicalContent, canonicalOwnerMap, canonicalRevisionId);
if (replayOperation || replayOwnerReplacements.length) {
this.queue(
replayOperation || identityOperation(canonicalContent.length),
replayOwnerReplacements,
);
}
return {
replayed: Boolean(this.buffer),
outstandingAcknowledged,
};
}
adoptCanonicalSnapshot(content, ownerMap, revisionId) {
revisionId = Number(revisionId) || 0;
if (revisionId !== this.revisionId) {
throw new CollaborationRevisionGapError(this.revisionId, revisionId);
}
if (String(content || "") !== this.serverContent) {
throw new Error("Canonical collaborative content does not match applied operations");
}
this.serverOwnerMap = ownerMap == null ? "[]" : String(ownerMap);
}
}
+306
View File
@@ -0,0 +1,306 @@
/*
* Copyright (C) 2026 Mateusz Gruszczynski @linuxiarz.pl
* Source-Available Code / Dual-Licensed.
*
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
* Commercial or production use requires a valid paid license.
* See LICENSE file in repository root for details.
*/
import { parseAuthorship, serializeAuthorship } from "@rustpad/authorship";
function normalizeOwnerSpans(spans, length) {
const result = [];
const sorted = [...(Array.isArray(spans) ? spans : [])].sort((left, right) => (Number(left?.start) || 0) - (Number(right?.start) || 0) || (Number(left?.end) || 0) - (Number(right?.end) || 0));
for (const source of sorted) {
const start = Math.max(0, Math.min(length, Number(source?.start) || 0));
const end = Math.max(start, Math.min(length, Number(source?.end) || 0));
const owner = String(source?.owner || "");
if (!owner || end <= start) continue;
const previous = result.at(-1);
if (previous && previous.owner === owner && start <= previous.end) {
previous.end = Math.max(previous.end, end);
continue;
}
const clippedStart = previous && start < previous.end ? previous.end : start;
if (end > clippedStart) result.push({ start: clippedStart, end, owner });
}
return result;
}
function sliceOwnerSpans(spans, start, length) {
const end = start + length;
return normalizeOwnerSpans((spans || []).flatMap(span => {
const overlapStart = Math.max(start, span.start);
const overlapEnd = Math.min(end, span.end);
return overlapEnd > overlapStart
? [{ start: overlapStart - start, end: overlapEnd - start, owner: span.owner }]
: [];
}), length);
}
function shiftOwnerSpans(spans, offset) {
return (spans || []).map(span => ({ start: span.start + offset, end: span.end + offset, owner: span.owner }));
}
function appendComponent(components, component) {
if (!component) return;
if (component.kind === "retain" || component.kind === "delete") {
const count = Number(component.count) || 0;
if (count <= 0) return;
const previous = components.at(-1);
if (previous?.kind === component.kind) previous.count += count;
else components.push({ kind: component.kind, count });
return;
}
if (component.kind !== "insert") throw new Error("Unknown operation component");
const text = String(component.text || "");
if (!text) return;
const owners = normalizeOwnerSpans(component.owners, text.length);
const previous = components.at(-1);
if (previous?.kind === "insert") {
const offset = previous.text.length;
previous.text += text;
previous.owners = normalizeOwnerSpans([
...(previous.owners || []),
...shiftOwnerSpans(owners, offset),
], previous.text.length);
} else components.push({ kind: "insert", text, owners });
}
export function normalizeOperation(operation) {
const components = [];
for (const component of operation?.components || []) appendComponent(components, component);
return { components };
}
export function operationBaseLength(operation) {
return normalizeOperation(operation).components.reduce((length, component) =>
length + (component.kind === "retain" || component.kind === "delete" ? component.count : 0), 0);
}
export function operationTargetLength(operation) {
return normalizeOperation(operation).components.reduce((length, component) =>
length + (component.kind === "retain" ? component.count : component.kind === "insert" ? component.text.length : 0), 0);
}
export function identityOperation(length) {
return normalizeOperation({ components: length > 0 ? [{ kind: "retain", count: length }] : [] });
}
function isUtf16Boundary(value, offset) {
if (offset <= 0 || offset >= value.length) return true;
const previous = value.charCodeAt(offset - 1);
const next = value.charCodeAt(offset);
return !(previous >= 0xd800 && previous <= 0xdbff && next >= 0xdc00 && next <= 0xdfff);
}
export function operationFromEdit(previousText, nextText, nextAuthorship) {
previousText = String(previousText || "");
nextText = String(nextText || "");
let prefix = 0;
const shared = Math.min(previousText.length, nextText.length);
while (prefix < shared && previousText.charCodeAt(prefix) === nextText.charCodeAt(prefix)) prefix++;
while (prefix > 0 && (!isUtf16Boundary(previousText, prefix) || !isUtf16Boundary(nextText, prefix))) prefix--;
let oldSuffix = previousText.length;
let newSuffix = nextText.length;
while (oldSuffix > prefix && newSuffix > prefix && previousText.charCodeAt(oldSuffix - 1) === nextText.charCodeAt(newSuffix - 1)) {
oldSuffix--;
newSuffix--;
}
while (!isUtf16Boundary(previousText, oldSuffix) || !isUtf16Boundary(nextText, newSuffix)) {
oldSuffix++;
newSuffix++;
}
const components = [];
appendComponent(components, { kind: "retain", count: prefix });
appendComponent(components, { kind: "delete", count: oldSuffix - prefix });
const insertedText = nextText.slice(prefix, newSuffix);
appendComponent(components, {
kind: "insert",
text: insertedText,
owners: sliceOwnerSpans(nextAuthorship?.spans || [], prefix, insertedText.length),
});
appendComponent(components, { kind: "retain", count: previousText.length - oldSuffix });
return { components };
}
class OperationCursor {
constructor(operation) {
this.components = normalizeOperation(operation).components;
this.index = 0;
this.offset = 0;
}
get current() { return this.components[this.index] || null; }
get kind() { return this.current?.kind || null; }
get remaining() {
const component = this.current;
if (!component) return 0;
return (component.kind === "insert" ? component.text.length : component.count) - this.offset;
}
take(count = this.remaining) {
const component = this.current;
if (!component || count <= 0 || count > this.remaining) throw new Error("Invalid operation cursor read");
let part;
if (component.kind === "insert") {
part = {
kind: "insert",
text: component.text.slice(this.offset, this.offset + count),
owners: sliceOwnerSpans(component.owners, this.offset, count),
};
} else part = { kind: component.kind, count };
this.offset += count;
if (this.offset === (component.kind === "insert" ? component.text.length : component.count)) {
this.index++;
this.offset = 0;
}
return part;
}
}
export function composeOperations(first, second) {
first = normalizeOperation(first);
second = normalizeOperation(second);
if (operationTargetLength(first) !== operationBaseLength(second)) throw new Error("Cannot compose operations with different lengths");
const left = new OperationCursor(first);
const right = new OperationCursor(second);
const components = [];
while (left.current || right.current) {
if (right.kind === "insert") {
appendComponent(components, right.take());
continue;
}
if (left.kind === "delete") {
appendComponent(components, left.take());
continue;
}
if (!left.current || !right.current) throw new Error("Incomplete operation composition");
const count = Math.min(left.remaining, right.remaining);
const leftKind = left.kind;
const rightKind = right.kind;
if (leftKind === "retain" && rightKind === "retain") {
appendComponent(components, { kind: "retain", count });
left.take(count);
right.take(count);
} else if (leftKind === "retain" && rightKind === "delete") {
appendComponent(components, { kind: "delete", count });
left.take(count);
right.take(count);
} else if (leftKind === "insert" && rightKind === "retain") {
appendComponent(components, left.take(count));
right.take(count);
} else if (leftKind === "insert" && rightKind === "delete") {
left.take(count);
right.take(count);
} else throw new Error("Unsupported operation composition");
}
return { components };
}
export function transformOperations(leftOperation, rightOperation, leftBeforeRight = true) {
leftOperation = normalizeOperation(leftOperation);
rightOperation = normalizeOperation(rightOperation);
if (operationBaseLength(leftOperation) !== operationBaseLength(rightOperation)) throw new Error("Cannot transform operations with different base lengths");
const left = new OperationCursor(leftOperation);
const right = new OperationCursor(rightOperation);
const leftPrime = [];
const rightPrime = [];
while (left.current || right.current) {
if (left.kind === "insert" && (right.kind !== "insert" || leftBeforeRight)) {
const part = left.take();
appendComponent(leftPrime, part);
appendComponent(rightPrime, { kind: "retain", count: part.text.length });
continue;
}
if (right.kind === "insert") {
const part = right.take();
appendComponent(leftPrime, { kind: "retain", count: part.text.length });
appendComponent(rightPrime, part);
continue;
}
if (!left.current || !right.current) throw new Error("Incomplete operation transform");
const count = Math.min(left.remaining, right.remaining);
if (left.kind === "retain" && right.kind === "retain") {
appendComponent(leftPrime, { kind: "retain", count });
appendComponent(rightPrime, { kind: "retain", count });
} else if (left.kind === "delete" && right.kind === "retain") {
appendComponent(leftPrime, { kind: "delete", count });
} else if (left.kind === "retain" && right.kind === "delete") {
appendComponent(rightPrime, { kind: "delete", count });
} else if (left.kind !== "delete" || right.kind !== "delete") throw new Error("Unsupported operation transform");
left.take(count);
right.take(count);
}
return [{ components: leftPrime }, { components: rightPrime }];
}
function copyRetainedSpans(target, spans, sourceStart, length, outputStart) {
const sourceEnd = sourceStart + length;
for (const span of spans || []) {
const start = Math.max(sourceStart, span.start);
const end = Math.min(sourceEnd, span.end);
if (end > start) target.push({ start: outputStart + start - sourceStart, end: outputStart + end - sourceStart, owner: span.owner });
}
}
export function applyOperationToDocument(content, ownerMap, operation, ownerReplacements = []) {
content = String(content || "");
operation = normalizeOperation(operation);
if (operationBaseLength(operation) !== content.length) throw new Error("Operation base length does not match document");
const sourceModel = parseAuthorship(content, ownerMap);
const outputSpans = [];
let sourceOffset = 0;
let outputOffset = 0;
let nextContent = "";
for (const component of operation.components) {
if (component.kind === "retain") {
nextContent += content.slice(sourceOffset, sourceOffset + component.count);
copyRetainedSpans(outputSpans, sourceModel.spans, sourceOffset, component.count, outputOffset);
sourceOffset += component.count;
outputOffset += component.count;
} else if (component.kind === "delete") sourceOffset += component.count;
else {
nextContent += component.text;
outputSpans.push(...shiftOwnerSpans(component.owners, outputOffset));
outputOffset += component.text.length;
}
}
if (sourceOffset !== content.length) throw new Error("Operation did not consume the whole document");
const replacementMap = new Map((ownerReplacements || []).map(item => [String(item?.owner || ""), String(item?.replacement || "")]));
const replacedSpans = outputSpans.map(span => {
const identity = String(span.owner || "").split("\u001f", 1)[0];
const replacement = replacementMap.get(identity);
return replacement ? { ...span, owner: replacement } : span;
});
const model = { version: 2, spans: normalizeOwnerSpans(replacedSpans, nextContent.length) };
return { content: nextContent, ownerMap: serializeAuthorship(model, nextContent.length), authorship: model };
}
export function compareOperationKeys(left, right) {
const leftClient = String(left?.clientId || left?.client_id || "");
const rightClient = String(right?.clientId || right?.client_id || "");
if (leftClient !== rightClient) return leftClient < rightClient ? -1 : 1;
const leftUpdate = Number(left?.updateId ?? left?.update_id ?? 0);
const rightUpdate = Number(right?.updateId ?? right?.update_id ?? 0);
return leftUpdate === rightUpdate ? 0 : leftUpdate < rightUpdate ? -1 : 1;
}
export function documentAfterPending(serverContent, serverOwnerMap, outstanding, buffer) {
let documentState = { content: serverContent, ownerMap: serverOwnerMap };
for (const pending of [outstanding, buffer]) {
if (!pending) continue;
documentState = applyOperationToDocument(
documentState.content,
documentState.ownerMap,
pending.operation,
pending.ownerReplacements,
);
}
return documentState;
}
+73
View File
@@ -109,3 +109,76 @@ export function bindFormatShortcuts(editor) {
applyFormat(editor, format);
});
}
function selectedLineRange(value, start, end) {
const blockStart = value.lastIndexOf("\n", Math.max(0, start - 1)) + 1;
let blockEnd = value.indexOf("\n", end);
if (blockEnd < 0) blockEnd = value.length;
if (end > start && value[end - 1] === "\n") blockEnd = end - 1;
return { blockStart, blockEnd };
}
function mapOffsetThroughEdits(offset, edits) {
let delta = 0;
for (const edit of edits) {
if (offset < edit.start) break;
const editEnd = edit.start + edit.remove;
if (offset <= editEnd) return edit.start + delta + edit.insert.length;
delta += edit.insert.length - edit.remove;
}
return offset + delta;
}
export function applyIndentation(editor, { outdent = false, size = 2 } = {}) {
if (!editor || editor.readOnly) return false;
const indent = " ".repeat(Math.max(1, Number(size) || 2));
const start = editor.selectionStart;
const end = editor.selectionEnd;
const direction = editor.selectionDirection || "none";
if (!outdent && start === end) {
editor.setRangeText(indent, start, end, "end");
editor.dispatchEvent(new Event("input", { bubbles: true }));
return true;
}
const value = editor.value;
const { blockStart, blockEnd } = selectedLineRange(value, start, end);
const block = value.slice(blockStart, blockEnd);
const lineStarts = [0];
for (let index = 0; index < block.length; index++) {
if (block[index] === "\n") lineStarts.push(index + 1);
}
const edits = lineStarts.map(relativeStart => {
const absoluteStart = blockStart + relativeStart;
if (!outdent) return { start: absoluteStart, remove: 0, insert: indent };
const line = value.slice(absoluteStart, value.indexOf("\n", absoluteStart) < 0 ? value.length : value.indexOf("\n", absoluteStart));
const removable = line.startsWith("\t") ? 1 : Math.min(indent.length, (line.match(/^ +/) || [""])[0].length);
return { start: absoluteStart, remove: removable, insert: "" };
}).filter(edit => edit.remove || edit.insert);
if (!edits.length) return false;
let replacement = block;
for (let index = edits.length - 1; index >= 0; index--) {
const edit = edits[index];
const relativeStart = edit.start - blockStart;
replacement = `${replacement.slice(0, relativeStart)}${edit.insert}${replacement.slice(relativeStart + edit.remove)}`;
}
const nextStart = mapOffsetThroughEdits(start, edits);
const nextEnd = mapOffsetThroughEdits(end, edits);
editor.setRangeText(replacement, blockStart, blockEnd, "start");
editor.setSelectionRange(nextStart, nextEnd, direction);
editor.dispatchEvent(new Event("input", { bubbles: true }));
return true;
}
export function bindIndentationShortcuts(editor, { size = 2 } = {}) {
editor.addEventListener("keydown", event => {
if (event.key !== "Tab" || event.ctrlKey || event.metaKey || event.altKey) return;
if (editor.readOnly) return;
event.preventDefault();
applyIndentation(editor, { outdent: event.shiftKey, size });
});
}
+112 -17
View File
@@ -65,7 +65,7 @@ document.querySelector("#pad-form").addEventListener("submit", async (event) =>
if (password.value) payload.password = password.value;
const result = await api("/api/pads", { method: "POST", headers: authHeaders(), body: JSON.stringify(payload) });
if (password.value) { const grant = await api("/api/access-token", { method: "POST", body: JSON.stringify({ kind: "pad", slug: result.slug, password: password.value }) }); setAccessToken("pad", result.slug, grant.granted); }
window.location.assign(safeAppUrl(`${result.url}?view=split&mode=markdown`));
window.location.assign(safeAppUrl(result.url));
} catch (requestError) {
error.textContent = requestError.message;
} finally {
@@ -119,11 +119,41 @@ let currentSession = null;
function authHeaders() { return {}; }
function escapeHtml(value) { const node = document.createElement("div"); node.textContent = String(value ?? ""); return node.innerHTML; }
function resourceActionLabel(full, short = full) {
return `<span class="resource-action-label resource-action-label--full">${escapeHtml(full)}</span><span class="resource-action-label resource-action-label--short" aria-hidden="true">${escapeHtml(short)}</span>`;
}
function setResourcePrivacyLabel(button, isPrivate) {
if (!button) return;
const full = isPrivate ? "Make public" : "Make private";
const short = isPrivate ? "Public" : "Private";
button.setAttribute("aria-label", full);
button.title = full;
const fullLabel = button.querySelector(".resource-action-label--full");
const shortLabel = button.querySelector(".resource-action-label--short");
if (fullLabel) fullLabel.textContent = full;
if (shortLabel) shortLabel.textContent = short;
}
function shareExpiry(hours, forever) { if (forever) return null; const value = Number(hours); if (!Number.isFinite(value) || value <= 0 || value > 87600) throw new Error("Enter a validity between 1 and 87600 hours."); return new Date(Date.now() + value * 3600000).toISOString(); }
function formatShareExpiry(value) { if (!value) return "Never expires"; const date = new Date(value); return Number.isNaN(date.getTime()) ? value : `Expires ${date.toLocaleString()}`; }
function formatShareCreated(value) { const date = new Date(value); return Number.isNaN(date.getTime()) ? value : `Created ${date.toLocaleString()}`; }
function shareLinkId(tokenHash) { return String(tokenHash || "").slice(0, 12); }
function renderResourcesPagination(meta) {
resourcesPagination.innerHTML = meta.total ? `<button type="button" data-page="${meta.page - 1}" ${meta.page <= 1 ? "disabled" : ""}>Previous</button><span>Page ${meta.page} of ${meta.total_pages} · ${meta.total} items</span><button type="button" data-page="${meta.page + 1}" ${meta.page >= meta.total_pages ? "disabled" : ""}>Next</button>` : "";
}
function closeResourcePasswordMenus(except = null) {
document.querySelectorAll(".resource-password-menu[open]").forEach(menu => {
if (menu !== except) menu.removeAttribute("open");
});
}
document.addEventListener("click", event => {
const menu = event.target.closest?.(".resource-password-menu");
closeResourcePasswordMenus(menu);
});
document.addEventListener("keydown", event => {
if (event.key === "Escape") closeResourcePasswordMenus();
});
async function loadResources() {
resourcesError.textContent = ""; resourcesList.innerHTML = "<p>Loading…</p>";
try {
@@ -138,7 +168,12 @@ async function loadResources() {
const sharedLabel = !item.owned ? `<span class="resource-shared-badge">Shared by ${escapeHtml(item.shared_by || "another user")}</span>` : "";
const permissionLabel = item.permission === "rw" ? "Read and write" : "Read only";
row.classList.toggle("resource-row--shared", !Boolean(item.owned));
row.innerHTML = `<div class="resource-main"><div class="resource-copy"><div class="resource-title-line"><a href="${escapeHtml(safeAppUrl(item.url))}">${escapeHtml(item.title)}</a>${sharedLabel}</div><small>${item.kind === "workspace" ? "Workspace" : "Note"}${item.private ? " · private" : ""}${!item.owned ? ` · ${permissionLabel}` : item.protected ? " · password protected" : ""}</small></div><div class="resource-actions">${item.owned ? `<button class="action-button action-button--secondary compact-button" type="button" data-privacy>${item.private ? "Make public" : "Make private"}</button><button class="action-button action-button--primary compact-button" type="button" data-share>Share</button><button class="action-button action-button--secondary compact-button" type="button" data-password>Change password</button><button class="action-button action-button--danger compact-button" type="button" data-delete>Delete</button>` : ""}</div></div><div class="resource-inline" data-inline hidden></div>`;
const passwordActions = item.protected
? `<button class="resource-password-menu__item" type="button" data-password>Change password</button><button class="resource-password-menu__item resource-password-menu__item--danger" type="button" data-remove-password>Remove password</button>`
: `<button class="resource-password-menu__item" type="button" data-password>Set password</button>`;
const privacyAction = item.private ? "Make public" : "Make private";
const privacyShort = item.private ? "Public" : "Private";
row.innerHTML = `<div class="resource-main"><div class="resource-copy"><div class="resource-title-line"><a href="${escapeHtml(safeAppUrl(item.url))}" title="${escapeHtml(item.title)}">${escapeHtml(item.title)}</a>${sharedLabel}</div><small>${item.kind === "workspace" ? "Workspace" : "Note"}${item.private ? " · private" : ""}${!item.owned ? ` · ${permissionLabel}` : item.protected ? " · password protected" : ""}</small></div><div class="resource-actions">${item.owned ? `<button class="action-button action-button--secondary compact-button" type="button" data-privacy aria-label="${privacyAction}" title="${privacyAction}">${resourceActionLabel(privacyAction, privacyShort)}</button><button class="action-button action-button--primary compact-button" type="button" data-share aria-label="Share" title="Share">${resourceActionLabel("Share")}</button><details class="resource-password-menu"><summary class="action-button action-button--secondary compact-button" aria-label="Password settings" title="Password settings">${resourceActionLabel("Password…", "Pass…")}<span class="resource-password-menu__chevron" aria-hidden="true">▾</span></summary><div class="resource-password-menu__panel">${passwordActions}</div></details><button class="action-button action-button--danger compact-button" type="button" data-delete aria-label="Delete" title="Delete">${resourceActionLabel("Delete")}</button>` : ""}</div></div><div class="resource-inline" data-inline hidden></div>`;
const inline = row.querySelector("[data-inline]");
const closeInline = () => { inline.hidden = true; inline.innerHTML = ""; };
@@ -156,7 +191,7 @@ async function loadResources() {
try {
await api("/api/auth/resources/privacy", { method: "POST", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, private: nextPrivate }) });
item.private = nextPrivate;
button.textContent = nextPrivate ? "Make public" : "Make private";
setResourcePrivacyLabel(button, nextPrivate);
const meta = row.querySelector(".resource-copy small");
meta.textContent = `${item.kind === "workspace" ? "Workspace" : "Note"}${item.private ? " · private" : ""}${!item.owned ? ` · ${permissionLabel}` : item.protected ? " · password protected" : ""}`;
} catch (e) {
@@ -166,6 +201,7 @@ async function loadResources() {
}
});
row.querySelector("[data-share]")?.addEventListener("click", async () => {
const initialShareLinksEnabled = Boolean(item.private || item.protected);
const dialog = document.createElement("dialog");
dialog.className = "app-dialog share-dialog";
dialog.innerHTML = `<div class="share-panel">
@@ -176,12 +212,13 @@ async function loadResources() {
<label><span>Permission</span><select name="permission"><option value="ro">Read only</option><option value="rw">Read and write</option></select></label>
<button class="primary-button" type="submit">Grant access</button>
</form><div data-user-list class="share-list"></div></section>
<section class="share-section"><div class="share-section-head"><div><h4>Direct links</h4><p>Create links for people without an account or manage existing links.</p></div></div><form data-link-form class="share-form-grid share-link-form">
<section class="share-section" data-direct-links-section><div class="share-section-head"><div><h4>Direct links</h4><p>Create links for people without an account or manage existing links.</p></div></div><div data-direct-links-enabled ${initialShareLinksEnabled ? "" : "hidden"}><form data-link-form class="share-form-grid share-link-form">
<label class="share-link-label"><span>Label</span><input name="label" type="text" maxlength="120" placeholder="Client ABC" autocomplete="off"></label>
<label><span>Permission</span><select name="permission"><option value="ro">Read only</option><option value="rw">Read and write</option></select></label>
<label><span>Valid for</span><div class="share-hours-field"><input name="hours" type="number" min="1" max="87600" value="24" inputmode="numeric"><span>hours</span></div></label>
<label class="share-forever"><input name="forever" type="checkbox"><span>Never expires</span></label>
<button class="primary-button" type="submit">Create link</button>
</form><div class="share-link-list-wrap"><h5>Individual links</h5><div data-link-list class="share-list share-link-list"></div></div></section>
</form><div class="share-link-list-wrap"><h5>Individual links</h5><div data-link-list class="share-list share-link-list"></div></div></div><div class="share-empty" data-direct-links-disabled ${initialShareLinksEnabled ? "hidden" : ""}>Direct links are disabled while this item is public and has no password. Existing links are preserved and become active again after you make it private or add a password.</div></section>
</div>
<div class="share-dialog-footer"><p class="form-message resource-inline-message" data-inline-message role="status"></p><button class="secondary-button" type="button" data-done>Done</button></div>
</div>`;
@@ -200,40 +237,80 @@ async function loadResources() {
const linkForm = dialog.querySelector("[data-link-form]");
const userList = dialog.querySelector("[data-user-list]");
const linkList = dialog.querySelector("[data-link-list]");
const directLinksEnabled = dialog.querySelector("[data-direct-links-enabled]");
const directLinksDisabled = dialog.querySelector("[data-direct-links-disabled]");
const createdLinkValues = new Map();
const syncForever = () => { linkForm.hours.disabled = linkForm.forever.checked; };
linkForm.forever.addEventListener("change", syncForever); syncForever();
const refresh = async () => {
const d = await api(`/api/auth/resources/sharing?kind=${encodeURIComponent(item.kind)}&slug=${encodeURIComponent(item.slug)}`, { headers: authHeaders() });
const shareLinksEnabled = d.share_links_enabled !== false;
directLinksEnabled.hidden = !shareLinksEnabled;
directLinksDisabled.hidden = shareLinksEnabled;
userList.innerHTML = d.users.length ? d.users.map(u => `<div class="share-list-row"><div class="share-list-identity"><span class="share-avatar">${escapeHtml((u.nickname || u.email || "?").slice(0, 1).toUpperCase())}</span><div><strong>${escapeHtml(u.nickname)}</strong><small>${escapeHtml(u.email)}</small></div></div><span class="share-role">${u.permission === "rw" ? "Read and write" : "Read only"}</span><button class="secondary-button compact-button" type="button" data-remove-user="${escapeHtml(u.email)}">Remove</button></div>`).join("") : '<p class="share-empty">No users have access.</p>';
linkList.innerHTML = d.links.length ? d.links.map(link => {
const directUrl = link.token ? new URL(`${item.url}?share=${encodeURIComponent(link.token)}`, location.origin).href : "";
const linkPreview = link.token ? `<div class="share-link-inline"><input type="text" readonly value="${escapeHtml(directUrl)}" aria-label="Direct access link"><button class="secondary-button compact-button" type="button" data-copy-link>Copy</button></div>` : '<small class="share-link-legacy">Link value unavailable. Recreate this legacy link to display it.</small>';
return `<form class="share-list-row share-link-row" data-link-token="${escapeHtml(link.token_hash)}"><div class="share-link-info"><strong>Individual link</strong><small>${escapeHtml(formatShareExpiry(link.expires_at))}</small>${linkPreview}</div><label><span class="sr-only">Permission</span><select name="permission" aria-label="Link permission"><option value="ro" ${link.permission === "ro" ? "selected" : ""}>Read only</option><option value="rw" ${link.permission === "rw" ? "selected" : ""}>Read and write</option></select></label><label><span class="sr-only">Validity in hours</span><div class="share-hours-field"><input name="hours" type="number" min="1" max="87600" value="24" aria-label="New validity in hours"><span>h</span></div></label><label class="share-forever"><input name="forever" type="checkbox" ${link.expires_at ? "" : "checked"}><span>Never</span></label><div class="share-row-actions"><button class="secondary-button compact-button" type="submit">Update</button><button class="danger-button compact-button" type="button" data-revoke-link>Revoke</button></div></form>`;
linkList.innerHTML = shareLinksEnabled && d.links.length ? d.links.map(link => {
const identifier = shareLinkId(link.token_hash);
const visibleLink = createdLinkValues.get(link.token_hash);
const linkPreview = visibleLink
? `<div class="share-link-inline"><input type="text" readonly value="${escapeHtml(visibleLink)}" aria-label="New share link"><button class="secondary-button compact-button" type="button" data-copy-link>Copy</button></div><small class="share-link-once">The full address remains visible until this dialog is closed.</small>`
: `<small class="share-link-legacy">The full address is not stored. Use the label or link ID #${escapeHtml(identifier)} to identify it.</small>`;
return `<form class="share-list-row share-link-row" data-link-token-hash="${escapeHtml(link.token_hash)}"><div class="share-link-info"><label class="share-link-label-edit"><span>Label</span><input name="label" type="text" maxlength="120" value="${escapeHtml(link.label || "")}" placeholder="Unlabeled link" autocomplete="off"></label><small>Link ID #${escapeHtml(identifier)} · ${escapeHtml(formatShareCreated(link.created_at))} · ${escapeHtml(formatShareExpiry(link.expires_at))}</small>${linkPreview}</div><label><span class="sr-only">Permission</span><select name="permission" aria-label="Link permission"><option value="ro" ${link.permission === "ro" ? "selected" : ""}>Read only</option><option value="rw" ${link.permission === "rw" ? "selected" : ""}>Read and write</option></select></label><label><span class="sr-only">Validity in hours</span><div class="share-hours-field"><input name="hours" type="number" min="1" max="87600" value="24" aria-label="New validity in hours"><span>h</span></div></label><label class="share-forever"><input name="forever" type="checkbox" ${link.expires_at ? "" : "checked"}><span>Never</span></label><div class="share-row-actions"><button class="secondary-button compact-button" type="submit">Update</button><button class="danger-button compact-button" type="button" data-revoke-link>Revoke</button></div></form>`;
}).join("") : '<p class="share-empty">No active links.</p>';
userList.querySelectorAll("[data-remove-user]").forEach(button => button.addEventListener("click", async () => { try { button.disabled = true; await api("/api/auth/resources/sharing", { method: "DELETE", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, email: button.dataset.removeUser }) }); setDialogMessage("Access removed.", "success"); await refresh(); } catch (err) { setDialogMessage(err.message, "error"); button.disabled = false; } }));
linkList.querySelectorAll("[data-link-token]").forEach(linkRow => {
linkList.querySelectorAll("[data-link-token-hash]").forEach(linkRow => {
const forever = linkRow.elements.forever, hours = linkRow.elements.hours; const sync = () => { hours.disabled = forever.checked; }; forever.addEventListener("change", sync); sync();
linkRow.querySelector("[data-copy-link]")?.addEventListener("click", async () => { try { await copyText(linkRow.querySelector(".share-link-inline input").value); setDialogMessage("Link copied.", "success"); } catch (err) { setDialogMessage(err.message, "error"); } });
linkRow.addEventListener("submit", async event => { event.preventDefault(); try { const expires_at = shareExpiry(hours.value, forever.checked); await api("/api/auth/resources/share-links", { method: "PUT", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, token: linkRow.dataset.linkToken, permission: linkRow.elements.permission.value, expires_at }) }); setDialogMessage("Link updated.", "success"); await refresh(); } catch (err) { setDialogMessage(err.message, "error"); } });
linkRow.querySelector("[data-revoke-link]").addEventListener("click", async () => { try { await api("/api/auth/resources/share-links", { method: "DELETE", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, token: linkRow.dataset.linkToken }) }); setDialogMessage("Link revoked.", "success"); await refresh(); } catch (err) { setDialogMessage(err.message, "error"); } });
linkRow.querySelector("[data-copy-link]")?.addEventListener("click", async event => {
const button = event.currentTarget;
const input = linkRow.querySelector(".share-link-inline input");
try {
button.disabled = true;
await copyText(input.value);
setDialogMessage("Link copied.", "success");
} catch (err) {
setDialogMessage(err.message, "error");
} finally {
button.disabled = false;
}
});
linkRow.addEventListener("submit", async event => { event.preventDefault(); try { const expires_at = shareExpiry(hours.value, forever.checked); await api("/api/auth/resources/share-links", { method: "PUT", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, token_hash: linkRow.dataset.linkTokenHash, label: linkRow.elements.label.value, permission: linkRow.elements.permission.value, expires_at }) }); setDialogMessage("Link updated.", "success"); await refresh(); } catch (err) { setDialogMessage(err.message, "error"); } });
linkRow.querySelector("[data-revoke-link]").addEventListener("click", async () => { try { await api("/api/auth/resources/share-links", { method: "DELETE", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, token_hash: linkRow.dataset.linkTokenHash }) }); createdLinkValues.delete(linkRow.dataset.linkTokenHash); setDialogMessage("Link revoked.", "success"); await refresh(); } catch (err) { setDialogMessage(err.message, "error"); } });
});
};
userForm.addEventListener("submit", async event => { event.preventDefault(); try { const result = await api("/api/auth/resources/sharing", { method: "POST", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, recipients: userForm.recipients.value, permission: userForm.permission.value }) }); userForm.recipients.value = ""; setDialogMessage(result.confirmation_required ? "Invitation sent. Access will appear after the recipient accepts it." : "Access granted.", "success"); await refresh(); } catch (err) { setDialogMessage(err.message, "error"); } });
linkForm.addEventListener("submit", async event => { event.preventDefault(); try { const expires_at = shareExpiry(linkForm.hours.value, linkForm.forever.checked); const result = await api("/api/auth/resources/share-links", { method: "POST", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, permission: linkForm.permission.value, expires_at }) }); const absolute = new URL(result.url, location.origin).href; await copyText(absolute); setDialogMessage("Link created and copied. It remains visible below.", "success"); await refresh(); } catch (err) { setDialogMessage(err.message, "error"); } });
linkForm.addEventListener("submit", async event => {
event.preventDefault();
try {
const expires_at = shareExpiry(linkForm.hours.value, linkForm.forever.checked);
const result = await api("/api/auth/resources/share-links", { method: "POST", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, label: linkForm.label.value, permission: linkForm.permission.value, expires_at }) });
const absolute = new URL(result.url, location.origin).href;
createdLinkValues.set(result.token_hash, absolute);
linkForm.label.value = "";
await refresh();
try {
await copyText(absolute);
setDialogMessage("Link created, displayed below and copied.", "success");
} catch {
setDialogMessage("Link created and displayed below. Automatic copying failed; use the Copy button.", "warning");
}
} catch (err) {
setDialogMessage(err.message, "error");
}
});
dialog.showModal();
try { await refresh(); } catch (err) { setDialogMessage(err.message, "error"); }
});
row.querySelector("[data-password]")?.addEventListener("click", () => {
row.querySelector("[data-password]")?.addEventListener("click", event => {
event.currentTarget.closest(".resource-password-menu")?.removeAttribute("open");
inline.hidden = false;
inline.innerHTML = `<form class="resource-password-form" autocomplete="off"><label>New password<input name="resource-password" type="password" minlength="8" maxlength="128" autocomplete="new-password" data-bwignore="true" placeholder="Minimum 8 characters"></label><p class="resource-inline-help">Leave empty to remove password protection.</p><p class="form-message resource-inline-message" data-inline-message role="status"></p><div class="resource-inline-actions"><button class="primary-button" type="submit">Save</button><button class="secondary-button" type="button" data-cancel>Cancel</button></div></form>`;
inline.innerHTML = `<form class="resource-password-form" autocomplete="off"><label>${item.protected ? "New password" : "Password"}<input name="resource-password" type="password" minlength="8" maxlength="128" autocomplete="new-password" data-bwignore="true" placeholder="Minimum 8 characters" required></label><p class="form-message resource-inline-message" data-inline-message role="status"></p><div class="resource-inline-actions"><button class="primary-button" type="submit">Save</button><button class="secondary-button" type="button" data-cancel>Cancel</button></div></form>`;
const form = inline.querySelector("form");
const input = form.querySelector("input");
form.querySelector("[data-cancel]").addEventListener("click", closeInline);
form.addEventListener("submit", async event => {
event.preventDefault();
const password = input.value;
if (password && password.length < 8) { setInlineMessage("Password must contain at least 8 characters.", "error"); return; }
if (password.length < 8) { setInlineMessage("Password must contain at least 8 characters.", "error"); return; }
const submit = form.querySelector('[type="submit"]');
submit.disabled = true;
setInlineMessage("");
@@ -248,6 +325,24 @@ async function loadResources() {
input.focus();
});
row.querySelector("[data-remove-password]")?.addEventListener("click", event => {
event.currentTarget.closest(".resource-password-menu")?.removeAttribute("open");
inline.hidden = false;
inline.innerHTML = `<div class="resource-delete-confirm"><p>Remove password protection from “${escapeHtml(item.title)}”?</p><p class="resource-inline-help">Anyone with the public link will be able to open it without a password.</p><p class="form-message resource-inline-message" data-inline-message role="status"></p><div class="resource-inline-actions"><button class="danger-button" type="button" data-confirm-remove-password>Remove password</button><button class="secondary-button" type="button" data-cancel>Cancel</button></div></div>`;
inline.querySelector("[data-cancel]").addEventListener("click", closeInline);
inline.querySelector("[data-confirm-remove-password]").addEventListener("click", async event => {
event.currentTarget.disabled = true;
setInlineMessage("");
try {
await api("/api/auth/resources", { method: "PUT", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, password: "" }) });
await loadResources();
} catch (e) {
setInlineMessage(e.message, "error");
event.currentTarget.disabled = false;
}
});
});
row.querySelector("[data-delete]")?.addEventListener("click", () => {
inline.hidden = false;
inline.innerHTML = `<div class="resource-delete-confirm"><p>Delete “${escapeHtml(item.title)}” permanently?</p><p class="form-message resource-inline-message" data-inline-message role="status"></p><div class="resource-inline-actions"><button class="danger-button" type="button" data-confirm-delete>Delete</button><button class="secondary-button" type="button" data-cancel>Cancel</button></div></div>`;
+122
View File
@@ -0,0 +1,122 @@
/*
* 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.
*/
const IMAGE_ALIAS_SOURCE = String.raw`\[(image|img)=([^,\]\s]+)(?:,([^\]]*))?\]`;
const ALIGNMENTS = new Set(["left", "center", "right"]);
const MAX_IMAGE_DIMENSION = 10000;
function cleanText(value, fallback = "") {
return String(value ?? fallback)
.replace(/\]/g, ")")
.replace(/[\r\n]+/g, " ")
.trim();
}
function validDimension(value) {
const number = Math.round(Number(value));
return Number.isFinite(number) && number >= 1 && number <= MAX_IMAGE_DIMENSION ? number : null;
}
function inlineCodeRanges(value) {
const ranges = [];
const pattern = /`[^`]+`/g;
for (const match of String(value || "").matchAll(pattern)) {
ranges.push([match.index, match.index + match[0].length]);
}
return ranges;
}
function isInsideRange(index, ranges) {
return ranges.some(([start, end]) => index >= start && index < end);
}
export function imageAliasPattern(flags = "gi") {
return new RegExp(IMAGE_ALIAS_SOURCE, flags);
}
export function parseImageAlias(value) {
const match = String(value || "").match(new RegExp(`^${IMAGE_ALIAS_SOURCE}$`, "i"));
if (!match) return null;
const [, kind, filename, tail = ""] = match;
const parts = tail.split(",");
let align = null;
let width = null;
let height = null;
while (parts.length) {
const part = parts.at(-1).trim();
const alignment = part.match(/^a=(left|center|right)$/i);
if (alignment) {
align = alignment[1].toLowerCase();
parts.pop();
continue;
}
const size = part.match(/^size=(\d{1,5})x(\d{1,5})$/i);
if (size) {
const nextWidth = validDimension(size[1]);
const nextHeight = validDimension(size[2]);
if (nextWidth && nextHeight) {
width = nextWidth;
height = nextHeight;
}
parts.pop();
continue;
}
break;
}
return {
kind: kind.toLowerCase(),
filename,
label: cleanText(parts.join(","), filename) || filename,
align,
width,
height,
raw: match[0],
};
}
export function buildImageAlias(options = {}) {
const filename = cleanText(options.filename, "image").replace(/[,\s]/g, "_") || "image";
const label = cleanText(options.label, filename) || filename;
const kind = String(options.kind || "image").toLowerCase() === "img" ? "img" : "image";
const align = ALIGNMENTS.has(String(options.align || "").toLowerCase())
? String(options.align).toLowerCase()
: null;
const width = validDimension(options.width);
const height = validDimension(options.height);
const parts = [label];
if (align) parts.push(`a=${align}`);
if (width && height) parts.push(`size=${width}x${height}`);
return `[${kind}=${filename},${parts.join(",")}]`;
}
export function updateImageAliasInLineBySource(line, aliasSource, occurrence = 0, patch = {}) {
const source = String(line || "");
const target = String(aliasSource || "");
const targetOccurrence = Number(occurrence);
if (!target || !Number.isInteger(targetOccurrence) || targetOccurrence < 0) return null;
const codeRanges = inlineCodeRanges(source);
let matchedOccurrence = 0;
let changed = false;
const value = source.replace(imageAliasPattern(), (match, ...args) => {
const offset = args.at(-2);
if (isInsideRange(offset, codeRanges) || match !== target || matchedOccurrence++ !== targetOccurrence) return match;
const parsed = parseImageAlias(match);
if (!parsed) return match;
changed = true;
return buildImageAlias({ ...parsed, ...patch });
});
return changed ? value : null;
}
+95 -7
View File
@@ -8,6 +8,7 @@
*/
import { EMOJI_SHORTCODES } from "@rustpad/emoji-data";
import { parseImageAlias } from "@rustpad/image-alias";
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#039;" }[c]));
@@ -42,6 +43,51 @@ function safeUrl(value) {
}
}
function safeAttachmentUrl(file, { download = false } = {}) {
let raw = String(file?.url || "").trim();
const route = attachmentRoute(raw);
if (route && markdownFileRoutes.has(route)) raw = markdownFileRoutes.get(route);
try {
const url = new URL(raw, location.origin);
if (url.protocol !== "http:" && url.protocol !== "https:") return "#";
if (download && route) url.searchParams.set("download", "1");
return escapeHtml(url.href);
} catch {
return "#";
}
}
function safeAttachmentPlaybackUrl(file) {
return safeAttachmentUrl(file);
}
function safeAttachmentDownloadUrl(file) {
return safeAttachmentUrl(file, { download: true });
}
function youtubeVideo(value) {
const raw = String(value || "").trim();
if (!raw || raw.startsWith("//")) return null;
try {
const url = new URL(raw, location.origin);
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
const host = url.hostname.toLowerCase();
let id = "";
if (host === "youtu.be") {
id = url.pathname.split("/").filter(Boolean)[0] || "";
} else if (host === "youtube.com" || host.endsWith(".youtube.com") || host === "youtube-nocookie.com" || host.endsWith(".youtube-nocookie.com")) {
if (url.pathname === "/watch") id = url.searchParams.get("v") || "";
else {
const match = url.pathname.match(/^\/(?:shorts|embed|live)\/([A-Za-z0-9_-]+)/);
id = match?.[1] || "";
}
}
return /^[A-Za-z0-9_-]{6,20}$/.test(id) ? { id, url: url.href } : null;
} catch {
return null;
}
}
export function setMarkdownFiles(files) {
const normalized = (Array.isArray(files) ? files : [])
.filter(file => file && file.filename && file.url)
@@ -59,7 +105,7 @@ export function setMarkdownFiles(files) {
export function unresolvedMarkdownFileAliases(value) {
const missing = new Set();
const source = String(value || "").replace(/`[^`]*`/g, "");
for (const match of source.matchAll(/\[(?:file|image|img)=([^,\]\s]+)(?:,[^\]]*)?\]/gi)) {
for (const match of source.matchAll(/\[(?:file|image|img|video)=([^,\]\s]+)(?:,[^\]]*)?\]/gi)) {
if (!markdownFiles.has(match[1])) missing.add(match[1]);
}
return [...missing];
@@ -75,15 +121,30 @@ function inline(value) {
let html = escapeHtml(value);
html = html.replace(/`([^`]+)`/g, (_, code) => stash(`<code>${code}</code>`));
html = html.replace(/\[(file|image|img)=([^,\]\s]+)(?:,([^\]]*))?\]/gi, (match, kind, filename, label) => {
html = html.replace(/\[(file|image|img|video)=([^,\]\s]+)(?:,([^\]]*))?\]/gi, (match, kind, filename, label) => {
const normalizedKind = kind.toLowerCase();
const file = markdownFiles.get(filename);
if (!file) return match;
const text = String(label || filename).trim() || filename;
if (kind.toLowerCase() === "file") {
return stash(`<a href="${safeUrl(file.url)}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-file-alias="file" data-file-name="${filename}">${text}</a>`);
if (normalizedKind === "file") {
const text = String(label || filename).trim() || filename;
return stash(`<a href="${safeAttachmentDownloadUrl(file)}" download="${escapeHtml(filename)}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-file-alias="file" data-file-name="${escapeHtml(filename)}">${text}</a>`);
}
if (normalizedKind === "video") {
if (!file.mimeType.startsWith("video/")) return match;
const text = String(label || filename).trim() || filename;
return stash(`<a href="${safeAttachmentPlaybackUrl(file)}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-file-alias="video" data-file-name="${escapeHtml(filename)}">${text}</a>`);
}
if (!file.mimeType.startsWith("image/")) return match;
return stash(`<img src="${safeUrl(file.url)}" alt="${text}" loading="lazy" decoding="async" referrerpolicy="no-referrer" draggable="false" contenteditable="false" data-file-alias="image" data-file-name="${filename}">`);
const alias = parseImageAlias(match);
if (!alias) return match;
const alignment = alias.align || "left";
const sized = alias.width && alias.height;
const sizeClass = sized ? " markdown-alias-image--sized" : "";
const style = sized
? ` style="--image-width:${alias.width}px;--image-height:${alias.height}px;--image-aspect:${alias.width} / ${alias.height}"`
: "";
return stash(`<span class="markdown-alias-image markdown-alias-image--${alignment}${sizeClass}" contenteditable="false" data-file-alias="image-container" data-file-name="${filename}" data-image-alias-source="${match}" data-image-align="${alias.align || ""}"${sized ? ` data-image-width="${alias.width}" data-image-height="${alias.height}"` : ""}${style}><img src="${safeUrl(file.url)}" alt="${alias.label}" loading="lazy" decoding="async" referrerpolicy="no-referrer" draggable="false" contenteditable="false" data-file-alias="image" data-file-name="${filename}"></span>`);
});
html = html.replace(/!\[([^\]]*)\]\(([^\s)]+)(?:\s+["']([^"']*)["'])?\)/g, (_, alt, url, title) => {
const titleAttr = title ? ` title="${escapeHtml(title)}"` : "";
@@ -151,6 +212,26 @@ function normalizeLanguage(value) {
const attrs = (line, editable = false, prefix = "", suffix = "", lineOffset = 0) => ` class="preview-source-line${editable ? " preview-editable" : ""}" data-source-line="${line + lineOffset + 1}"${editable ? ` data-source-prefix="${escapeHtml(prefix)}" data-source-suffix="${escapeHtml(suffix)}"` : ""}`;
const isPlainText = line => !/[`*_~^=\[\]<>|:#]/.test(line) && !/^\s*(?:[-+*>]|\d+\.)\s/.test(line);
function renderStandaloneMedia(line, sourceLine) {
const videoAlias = String(line).trim().match(/^\[video=([^,\]\s]+)(?:,([^\]]*))?\]$/i);
if (videoAlias) {
const filename = videoAlias[1];
const file = markdownFiles.get(filename);
if (!file || !file.mimeType.startsWith("video/")) return null;
const label = String(videoAlias[2] || filename).trim() || filename;
const playbackUrl = safeAttachmentPlaybackUrl(file);
const downloadUrl = safeAttachmentDownloadUrl(file);
return `<div class="rustpad-media preview-source-line" data-source-line="${sourceLine}" contenteditable="false"><video class="rustpad-media__player" data-rustpad-player data-player-kind="video" controls playsinline preload="metadata" aria-label="${escapeHtml(label)}"><source src="${playbackUrl}" type="${escapeHtml(file.mimeType)}"></video><p class="rustpad-media__fallback" hidden>Playback is unavailable. <a href="${downloadUrl}" download="${escapeHtml(filename)}">Download ${escapeHtml(label)}</a>.</p></div>`;
}
const trimmed = String(line).trim();
const markdownLink = trimmed.match(/^\[([^\]]+)\]\(([^\s)]+)(?:\s+["'][^"']*["'])?\)$/);
const candidate = markdownLink ? markdownLink[2] : trimmed;
const youtube = youtubeVideo(candidate);
if (!youtube) return null;
const title = markdownLink?.[1]?.trim() || "YouTube video";
return `<div class="rustpad-media preview-source-line" data-source-line="${sourceLine}" contenteditable="false"><div class="rustpad-media__player" data-rustpad-player data-player-kind="youtube" data-video-id="${escapeHtml(youtube.id)}" data-player-title="${escapeHtml(title)}"><p class="rustpad-media__fallback"><a href="${safeUrl(youtube.url)}" target="_blank" rel="noopener noreferrer">Open ${escapeHtml(title)}</a></p></div></div>`;
}
function listLine(line) {
const match = line.match(/^(\s*)([-*+]|(\d+)\.)\s+(?:\[([ xX])\]\s+)?(.+)$/);
@@ -261,7 +342,7 @@ export function alignPreviewLineNumbers(root) {
const targetLeft = parseFloat(styles.paddingLeft || "0") - 50;
const rootLeft = root.getBoundingClientRect().left;
root.querySelectorAll(".preview-source-line").forEach(line => {
const lineLeft = line.getBoundingClientRect().left - rootLeft;
const lineLeft = line.getBoundingClientRect().left - rootLeft + root.scrollLeft;
line.style.setProperty("--preview-line-left", `${targetLeft - lineLeft}px`);
});
}
@@ -325,6 +406,13 @@ export function renderMarkdown(source, lineOffset = 0) {
}
if (inCode) { code.push(line); continue; }
const standaloneMedia = renderStandaloneMedia(line, index + lineOffset + 1);
if (standaloneMedia) {
closeList();
html += standaloneMedia;
continue;
}
const delimiter = index + 1 < lines.length ? tableDelimiter(lines[index + 1]) : null;
if (line.includes("|") && delimiter) {
closeList();
+10
View File
@@ -21,6 +21,7 @@ export function createPadAdapter() {
return {
access: { kind: "pad", key: slug },
passwordScope: "note",
addressSelector: "#document-url",
title: info => `${info.title} · RustPad`,
loadInfo: headers => api(base, { headers }),
@@ -37,6 +38,10 @@ export function createPadAdapter() {
method: "POST",
body: JSON.stringify({ kind: "pad", slug, password }),
}),
setPassword: (password, clientId) => api(`${base}/password`, {
method: "POST",
body: JSON.stringify({ password, client_id: clientId || null }),
}),
publish: (accessToken, allowTaskUpdates, unprotectPage, enabled = true) => api(`${base}/publish`, {
method: "POST",
body: JSON.stringify({ access_token: accessToken || null, allow_task_updates: allowTaskUpdates, unprotect_page: unprotectPage, enabled }),
@@ -62,6 +67,7 @@ export function createWorkspaceNoteAdapter() {
return {
access: { kind: "workspace", key: workspaceSlug },
passwordScope: "workspace",
addressSelector: "#document-url",
title: info => `${info.title} · ${info.workspace_title}`,
loadInfo: headers => api(base, { headers }),
@@ -78,6 +84,10 @@ export function createWorkspaceNoteAdapter() {
method: "POST",
body: JSON.stringify({ kind: "workspace", slug: workspaceSlug, password }),
}),
setPassword: (password, clientId) => api(`/api/workspaces/${encode(workspaceSlug)}/password`, {
method: "POST",
body: JSON.stringify({ password, client_id: clientId || null }),
}),
publish: (accessToken, allowTaskUpdates, unprotectPage, enabled = true) => api(`${base}/publish`, {
method: "POST",
body: JSON.stringify({ access_token: accessToken || null, allow_task_updates: allowTaskUpdates, unprotect_page: unprotectPage, enabled }),
+816 -53
View File
File diff suppressed because it is too large Load Diff
+226 -44
View File
@@ -1,7 +1,7 @@
/*
* 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.
@@ -30,12 +30,44 @@ function formatDate(value) {
return Number.isNaN(date.getTime()) ? "" : date.toLocaleString("pl-PL");
}
function aliasCode(filename, label, mimeType) {
const kind = String(mimeType || "").startsWith("image/") ? "image" : "file";
function isVideo(mimeType) {
return String(mimeType || "").startsWith("video/");
}
function isLikelyVideoFile(file) {
return isVideo(file?.type) || /\.(?:mp4|m4v|mov|webm|ogv)$/i.test(String(file?.name || ""));
}
function aliasCode(filename, label, mimeType, mode = "auto") {
const safeLabel = String(label || filename).replace(/\]/g, ")").replace(/[\r\n]+/g, " ").trim() || filename;
let kind = String(mimeType || "").startsWith("image/") ? "image" : "file";
if (isVideo(mimeType) && mode === "player") kind = "video";
return `[${kind}=${filename},${safeLabel}]`;
}
function clipboardFiles(event) {
const itemFiles = [...(event.clipboardData?.items || [])]
.filter(item => item.kind === "file")
.map(item => item.getAsFile())
.filter(Boolean);
if (itemFiles.length) return itemFiles;
return [...(event.clipboardData?.files || [])];
}
function pasteInsertionRange(editor, target) {
if (target === editor) return { start: editor.selectionStart, end: editor.selectionEnd };
const sourceLine = target instanceof Element ? target.closest("[data-source-line]") : null;
const lineIndex = Number(sourceLine?.dataset.sourceLine) - 1;
if (!Number.isInteger(lineIndex) || lineIndex < 0) {
return { start: editor.selectionStart, end: editor.selectionEnd };
}
const lines = editor.value.split("\n");
if (lineIndex >= lines.length) return { start: editor.selectionStart, end: editor.selectionEnd };
let end = 0;
for (let index = 0; index <= lineIndex; index++) end += lines[index].length + (index < lineIndex ? 1 : 0);
return { start: end, end };
}
function markdownCode(url, label, mimeType) {
return String(mimeType || "").startsWith("image/") ? `![${label}](${url})` : `[${label}](${url})`;
}
@@ -47,11 +79,84 @@ function safeAttachmentUrl(value) {
: safePublicUrl(raw, { allowMailto: false });
}
export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, canUpload, toast, onFilesChanged = () => {} }) {
function createVideoInsertDialog() {
const dialog = document.createElement("dialog");
dialog.className = "app-dialog video-insert-dialog";
dialog.innerHTML = `
<div class="video-insert-dialog__panel">
<div class="dialog-heading-row">
<div><p class="eyebrow">Video file</p><h2>How should it be added?</h2></div>
<button type="button" class="icon-button" data-video-choice="cancel" aria-label="Cancel">×</button>
</div>
<p class="dialog-copy" data-video-file-name></p>
<div class="video-choice-actions">
<button type="button" class="action-button action-button--primary" data-video-choice="player">
<strong>Embedded player</strong><span>Play the video directly in the note.</span>
</button>
<button type="button" class="action-button action-button--secondary" data-video-choice="download">
<strong>Download link</strong><span>Insert a link that downloads the original file.</span>
</button>
</div>
</div>`;
document.body.append(dialog);
return dialog;
}
export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxSize = () => 0, canDelete, canUpload, canEdit = () => true, toast, onFilesChanged = () => { } }) {
const dialog = document.querySelector("#files-dialog");
const list = document.querySelector("#files-list");
const input = document.querySelector("#file-input");
const footer = document.querySelector("#footer-files");
const retryableStatuses = new Set([408, 425, 429, 500, 502, 503, 504]);
let videoInsertDialog;
function chooseVideoInsertMode(filename) {
videoInsertDialog ||= createVideoInsertDialog();
videoInsertDialog.querySelector("[data-video-file-name]").textContent = filename;
if (!videoInsertDialog.open) videoInsertDialog.showModal();
return new Promise(resolve => {
let settled = false;
const finish = value => {
if (settled) return;
settled = true;
videoInsertDialog.removeEventListener("click", handleClick);
videoInsertDialog.removeEventListener("cancel", handleCancel);
if (videoInsertDialog.open) videoInsertDialog.close();
resolve(value);
};
const handleClick = event => {
const choice = event.target.closest("[data-video-choice]")?.dataset.videoChoice;
if (!choice) return;
finish(choice === "player" || choice === "download" ? choice : null);
};
const handleCancel = event => {
event.preventDefault();
finish(null);
};
videoInsertDialog.addEventListener("click", handleClick);
videoInsertDialog.addEventListener("cancel", handleCancel);
});
}
function fileActionButtons(file) {
const attributes = `data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}"`;
const insertButtons = isVideo(file.mime_type)
? `<button class="action-button action-button--primary compact-button" data-add-file-to-note data-insert-mode="player" ${attributes}>Add player</button>
<button class="action-button action-button--secondary compact-button" data-add-file-to-note data-insert-mode="download" ${attributes}>Add download</button>`
: `<button class="action-button action-button--primary compact-button" data-add-file-to-note data-insert-mode="auto" ${attributes}>Add to note</button>`;
const codeButtons = isVideo(file.mime_type)
? `<button class="action-button action-button--secondary compact-button" data-show-file-code="link" ${attributes}>Link</button>
<button class="action-button action-button--secondary compact-button" data-show-file-code="player" ${attributes}>Player code</button>
<button class="action-button action-button--secondary compact-button" data-show-file-code="download" ${attributes}>Download code</button>`
: `<button class="action-button action-button--secondary compact-button" data-show-file-code="link" ${attributes}>Link</button>
<button class="action-button action-button--primary compact-button" data-show-file-code="alias" ${attributes}>Alias</button>
<button class="action-button action-button--primary compact-button" data-show-file-code="markdown" ${attributes}>Markdown</button>`;
const deleteButton = canDelete()
? `<button class="action-button action-button--danger compact-button" data-delete-file="${file.id}" data-file-name="${escapeHtml(file.filename)}">Delete</button>`
: "";
return `${codeButtons}${insertButtons}${deleteButton}`;
}
async function loadFiles({ open = false } = {}) {
try {
@@ -64,13 +169,7 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, ca
<div class="file-name">${escapeHtml(file.filename)}</div>
<div class="file-meta">${formatBytes(file.size_bytes)} · ${escapeHtml(file.mime_type)}${file.created_at ? ` · ${formatDate(file.created_at)}` : ""} · <span class="file-flag${file.is_attached ? "" : " detached"}">${file.is_attached ? "in note" : "removed from content"}</span></div>
</div>
<div class="file-actions">
<button class="action-button action-button--secondary compact-button" data-show-file-code="link" data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}">Link</button>
<button class="action-button action-button--primary compact-button" data-show-file-code="alias" data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}">Alias</button>
<button class="action-button action-button--primary compact-button" data-show-file-code="markdown" data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}">Markdown</button>
<button class="action-button action-button--primary compact-button" data-add-file-to-note data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}">Add to note</button>
${canDelete() ? `<button class="action-button action-button--danger compact-button" data-delete-file="${file.id}" data-file-name="${escapeHtml(file.filename)}">Delete</button>` : ""}
</div>
<div class="file-actions">${fileActionButtons(file)}</div>
<div class="file-code" hidden><textarea readonly aria-label="Generated file code"></textarea><button class="action-button action-button--primary compact-button" data-copy-generated>Copy</button></div>
</div>`).join("") : '<p class="dialog-copy">No files uploaded.</p>';
onFilesChanged(files);
@@ -80,32 +179,27 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, ca
}
}
document.querySelector("#upload-button").addEventListener("click", () => {
if (!canUpload()) {
toast("Log in with read-write access to upload files.");
return;
}
input.click();
});
input.addEventListener("change", async event => {
let file = event.target.files[0];
if (!file) return;
if (file.type.startsWith("image/")) {
try {
file = await prepareImageFile(file);
} catch (error) {
toast(error.message);
input.value = "";
return;
}
if (!file) { input.value = ""; return; }
}
function insertText(text, range = null, inputType = "insertText") {
const start = Math.max(0, Math.min(range?.start ?? editor.selectionStart, editor.value.length));
const end = Math.max(start, Math.min(range?.end ?? editor.selectionEnd, editor.value.length));
editor.setRangeText(text, start, end, "end");
editor.dispatchEvent(new InputEvent("input", { bubbles: true, inputType, data: text }));
return { start: start + text.length, end: start + text.length };
}
function insertVideoPlayer(text, range = null, inputType = "insertText") {
const start = Math.max(0, Math.min(range?.start ?? editor.selectionStart, editor.value.length));
const end = Math.max(start, Math.min(range?.end ?? editor.selectionEnd, editor.value.length));
const prefix = start > 0 && editor.value[start - 1] !== "\n" ? "\n" : "";
const suffix = end < editor.value.length && editor.value[end] !== "\n" ? "\n" : "";
return insertText(`${prefix}${text}${suffix}`, { start, end }, inputType);
}
async function uploadFile(file, onUploaded, insertMode = "auto") {
const uploadToast = createUploadToast(file.name);
let completed = false;
const retryableStatuses = new Set([408, 425, 429, 500, 502, 503, 504]);
const uploadFile = async () => {
const run = async () => {
uploadToast.start();
const form = new FormData();
form.append("access_token", getAccessToken() || "");
@@ -115,23 +209,89 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, ca
method: "POST",
body: form,
headers: {},
uploadMaxSizeBytes: getUploadMaxSize(),
onProgress: progress => uploadToast.update(progress),
});
if (completed) return;
completed = true;
const text = aliasCode(result.name, file.name, result.mime_type || file.type);
editor.setRangeText(text, editor.selectionStart, editor.selectionEnd, "end");
editor.dispatchEvent(new Event("input", { bubbles: true }));
const text = aliasCode(result.name, file.name, result.mime_type || file.type, insertMode);
await onUploaded(text);
uploadToast.success();
await loadFiles();
} catch (error) {
const retryable = !error.status || retryableStatuses.has(error.status);
uploadToast.fail(error.message, { retryable, onRetry: uploadFile });
uploadToast.fail(error.message, { retryable, onRetry: run });
}
};
await run();
}
function requestUpload() {
if (!canUpload() || !canEdit()) {
toast("You need read-write access and upload permission to upload files.");
return;
}
input.click();
}
document.querySelector("#upload-button")?.addEventListener("click", requestUpload);
document.querySelector("#mobile-upload-button")?.addEventListener("click", requestUpload);
document.querySelector("#files-upload-button")?.addEventListener("click", requestUpload);
input.addEventListener("change", async event => {
let file = event.target.files[0];
if (!file) return;
input.value = "";
await uploadFile();
if (file.type.startsWith("image/")) {
try {
file = await prepareImageFile(file);
} catch (error) {
toast(error.message);
return;
}
if (!file) return;
}
const insertMode = isLikelyVideoFile(file) ? await chooseVideoInsertMode(file.name) : "auto";
if (!insertMode) return;
const range = { start: editor.selectionStart, end: editor.selectionEnd };
await uploadFile(file, text => insertMode === "player" ? insertVideoPlayer(text, range) : insertText(text, range), insertMode);
});
document.querySelector("#editor-workspace")?.addEventListener("paste", async event => {
const files = clipboardFiles(event);
if (!files.length) return;
event.preventDefault();
if (!canUpload() || !canEdit()) {
toast("You need read-write access and upload permission to paste files.");
return;
}
const initialRange = pasteInsertionRange(editor, event.target);
const state = { cursor: initialRange.start, replaceEnd: initialRange.end, inserted: false };
const insertPastedAlias = text => {
const value = editor.value;
if (!state.inserted) {
const prefix = state.cursor > 0 && value[state.cursor - 1] !== "\n" ? "\n" : "";
const suffix = state.replaceEnd < value.length && value[state.replaceEnd] !== "\n" ? "\n" : "";
insertText(`${prefix}${text}${suffix}`, { start: state.cursor, end: state.replaceEnd }, "insertFromPaste");
state.cursor += prefix.length + text.length;
state.replaceEnd = state.cursor;
state.inserted = true;
return;
}
const inserted = `\n${text}`;
insertText(inserted, { start: state.cursor, end: state.cursor }, "insertFromPaste");
state.cursor += inserted.length;
state.replaceEnd = state.cursor;
};
for (const file of files) {
const insertMode = isLikelyVideoFile(file) ? await chooseVideoInsertMode(file.name) : "auto";
if (insertMode) await uploadFile(file, insertPastedAlias, insertMode);
}
editor.setSelectionRange(state.cursor, state.cursor);
});
document.querySelector("#files-button").addEventListener("click", () => loadFiles({ open: true }));
@@ -140,12 +300,14 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, ca
list.addEventListener("click", async event => {
const addButton = event.target.closest("[data-add-file-to-note]");
if (addButton) {
const text = aliasCode(addButton.dataset.name, addButton.dataset.name, addButton.dataset.mime);
editor.setRangeText(text, editor.selectionStart, editor.selectionEnd, "end");
editor.dispatchEvent(new Event("input", { bubbles: true }));
const mode = addButton.dataset.insertMode;
const text = aliasCode(addButton.dataset.name, addButton.dataset.name, addButton.dataset.mime, mode);
if (mode === "player") insertVideoPlayer(text);
else insertText(text);
toast("Added to note");
return;
}
const showButton = event.target.closest("[data-show-file-code]");
if (showButton) {
const panel = showButton.closest(".file-row").querySelector(".file-code");
@@ -157,11 +319,29 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, ca
text = aliasCode(showButton.dataset.name, showButton.dataset.name, showButton.dataset.mime);
} else if (showButton.dataset.showFileCode === "markdown") {
text = markdownCode(safeUrl, showButton.dataset.name, showButton.dataset.mime);
} else if (showButton.dataset.showFileCode === "player") {
text = aliasCode(showButton.dataset.name, showButton.dataset.name, showButton.dataset.mime, "player");
} else if (showButton.dataset.showFileCode === "download") {
text = aliasCode(showButton.dataset.name, showButton.dataset.name, showButton.dataset.mime, "download");
}
output.value = text; panel.hidden = false; output.focus(); output.select(); return;
output.value = text;
panel.hidden = false;
output.focus();
output.select();
return;
}
const copyButton = event.target.closest("[data-copy-generated]");
if (copyButton) { try { await copyText(copyButton.closest(".file-code").querySelector("textarea").value); toast("Copied"); } catch (error) { toast(error.message); } return; }
if (copyButton) {
try {
await copyText(copyButton.closest(".file-code").querySelector("textarea").value);
toast("Copied");
} catch (error) {
toast(error.message);
}
return;
}
const deleteButton = event.target.closest("[data-delete-file]");
if (!deleteButton) return;
if (!await askConfirm(`Delete file "${deleteButton.dataset.fileName}" permanently?`, { title: "Delete file", confirmText: "Delete", danger: true })) return;
@@ -169,7 +349,9 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, ca
await api(endpoints.remove(deleteButton.dataset.deleteFile), { method: "DELETE", headers: {}, body: JSON.stringify({ access_token: getAccessToken() || null }) });
toast("File deleted");
await loadFiles();
} catch (error) { toast(error.message); }
} catch (error) {
toast(error.message);
}
});
return { loadFiles };
+14
View File
@@ -0,0 +1,14 @@
/*
* 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.
*/
const ACTIVE_PREVIEW_EDIT_SELECTOR = '.preview-editable[contenteditable="true"]';
export function previewEditingHost(target) {
return target?.matches?.(ACTIVE_PREVIEW_EDIT_SELECTOR) ? target : null;
}
+24 -3
View File
@@ -15,15 +15,36 @@ import { copyText } from "@rustpad/clipboard";
import { alignPreviewLineNumbers, renderMarkdown, setMarkdownFiles } from "@rustpad/markdown";
import { toast } from "@rustpad/toast";
import { getTheme } from "@rustpad/theme";
import { loadHighlight, loadMediaPlayer, loadMermaid } from "@rustpad/vendor-libs";
const token = location.pathname.split("/").filter(Boolean)[1];
const content = document.querySelector("#public-content");
const lineNumbersToggle = document.querySelector("#public-line-numbers-toggle");
const passwordDialog = document.querySelector("#public-password-dialog"), passwordForm = document.querySelector("#public-password-form"), passwordInput = document.querySelector("#public-password"), passwordError = document.querySelector("#public-password-error");
let pagePassword = "";
let mermaidRenderVersion = 0;
function pageHeaders() { return pagePassword ? { "X-RustPad-Page-Password": pagePassword } : {}; }
async function renderMermaid() { const nodes = content.querySelectorAll(".mermaid"); if (!nodes.length) return; try { const { default: mermaid } = await import("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs"); mermaid.initialize({ startOnLoad: false, theme: getTheme() === "dark" ? "dark" : "default", securityLevel: "strict" }); await mermaid.run({ nodes: [...nodes] }); } catch { nodes.forEach(n => n.insertAdjacentHTML("beforebegin", '<p class="error">Failed to load Mermaid.</p>')); } }
async function renderCodeHighlight() { const blocks = content.querySelectorAll('pre code[class^="language-"]'); if (!blocks.length) return; try { const hljs = await import("https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/+esm"); blocks.forEach(block => { const lines = block.querySelectorAll(".code-line"); if (!lines.length) { hljs.default.highlightElement(block); return; } const language = [...block.classList].find(name => name.startsWith("language-"))?.slice(9); lines.forEach(line => { try { line.innerHTML = hljs.default.highlight(line.textContent, { language, ignoreIllegals: true }).value; } catch { line.innerHTML = hljs.default.highlightAuto(line.textContent).value; } }); block.classList.add("hljs"); }); } catch { } }
async function renderMermaid() {
const renderVersion = ++mermaidRenderVersion;
const nodes = [...content.querySelectorAll(".mermaid")];
if (!nodes.length) return;
try {
const mermaid = await loadMermaid();
mermaid.initialize({ startOnLoad: false, theme: getTheme() === "dark" ? "dark" : "default", securityLevel: "strict" });
await mermaid.run({ nodes });
} catch {
if (renderVersion !== mermaidRenderVersion) return;
nodes.forEach(node => {
if (!node.isConnected || !content.contains(node) || !node.parentNode) return;
const message = document.createElement("p");
message.className = "error mermaid-error";
message.textContent = "Failed to load Mermaid.";
node.parentNode.insertBefore(message, node);
});
}
}
async function renderCodeHighlight() { const blocks = content.querySelectorAll('pre code[class^="language-"]'); if (!blocks.length) return; try { const hljs = await loadHighlight(); blocks.forEach(block => { const lines = block.querySelectorAll(".code-line"); if (!lines.length) { hljs.highlightElement(block); return; } const language = [...block.classList].find(name => name.startsWith("language-"))?.slice(9); lines.forEach(line => { try { line.innerHTML = hljs.highlight(line.textContent, { language, ignoreIllegals: true }).value; } catch { line.innerHTML = hljs.highlightAuto(line.textContent).value; } }); block.classList.add("hljs"); }); } catch { } }
async function renderMediaPlayers() { const nodes = content.querySelectorAll("[data-rustpad-player]"); if (!nodes.length) return; try { const { hydrateMediaPlayers } = await loadMediaPlayer(); hydrateMediaPlayers(content); } catch { } }
function lockPublicContent(allowTaskUpdates) {
content.querySelectorAll('[contenteditable]').forEach(node => node.removeAttribute('contenteditable'));
content.querySelectorAll('.preview-editable').forEach(node => node.classList.remove('preview-editable'));
@@ -39,7 +60,7 @@ function scrollToPublicAnchor(hash, behavior = "auto") {
target.scrollIntoView({ behavior, block: "start" });
return true;
}
async function initialize() { try { const page = await api(`/api/public/${encodeURIComponent(token)}`, { headers: pageHeaders() }); if (passwordDialog.open) passwordDialog.close(); passwordError.textContent = ""; document.querySelector("#public-title").textContent = page.title; document.querySelector("#public-meta").textContent = `Updated: ${new Date(page.updated_at).toLocaleString("en-US")}${page.allow_task_updates ? " · tasks can be updated" : ""}`; document.title = `${page.title} · RustPad`; setMarkdownFiles(page.files || []); content.innerHTML = renderMarkdown(page.content); alignPreviewLineNumbers(content); lockPublicContent(page.allow_task_updates); await Promise.all([renderMermaid(), renderCodeHighlight()]); requestAnimationFrame(() => scrollToPublicAnchor(location.hash)); } catch (error) { if (error.status === 401 || error.status === 403) { passwordError.textContent = error.status === 403 ? "Sign in with an authorized account or enter the resource password." : "Enter the correct password."; if (!passwordDialog.open) passwordDialog.showModal(); passwordInput.focus(); return; } content.replaceChildren(); const message = document.createElement("p"); message.className = "error"; message.textContent = String(error.message); content.append(message); } }
async function initialize() { try { const page = await api(`/api/public/${encodeURIComponent(token)}`, { headers: pageHeaders() }); if (passwordDialog.open) passwordDialog.close(); passwordError.textContent = ""; document.querySelector("#public-title").textContent = page.title; document.querySelector("#public-meta").textContent = `Updated: ${new Date(page.updated_at).toLocaleString("en-US")}${page.allow_task_updates ? " · tasks can be updated" : ""}`; document.title = `${page.title} · RustPad`; setMarkdownFiles(page.files || []); content.innerHTML = renderMarkdown(page.content); alignPreviewLineNumbers(content); lockPublicContent(page.allow_task_updates); await Promise.all([renderMermaid(), renderCodeHighlight(), renderMediaPlayers()]); requestAnimationFrame(() => scrollToPublicAnchor(location.hash)); } catch (error) { if (error.status === 401 || error.status === 403) { passwordError.textContent = error.status === 403 ? "Sign in with an authorized account or enter the resource password." : "Enter the correct password."; if (!passwordDialog.open) passwordDialog.showModal(); passwordInput.focus(); return; } content.replaceChildren(); const message = document.createElement("p"); message.className = "error"; message.textContent = String(error.message); content.append(message); } }
content.addEventListener("click", event => {
const link = event.target.closest('.markdown-toc a[href^="#"]');
if (!link) return;
+47
View File
@@ -0,0 +1,47 @@
/*
* 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.
*/
export function createRenderQueue(renderNow, schedule = queueMicrotask) {
if (typeof renderNow !== "function") throw new TypeError("renderNow must be a function");
if (typeof schedule !== "function") throw new TypeError("schedule must be a function");
let rendering = false;
let pending = false;
let scheduled = false;
const schedulePendingRender = () => {
if (scheduled) return;
scheduled = true;
schedule(() => {
scheduled = false;
if (!pending) return;
pending = false;
render();
});
};
function render() {
if (rendering) {
pending = true;
schedulePendingRender();
return;
}
pending = false;
rendering = true;
try {
renderNow();
} finally {
rendering = false;
if (pending) schedulePendingRender();
}
}
return render;
}
+8 -3
View File
@@ -7,6 +7,14 @@
* See LICENSE file in repository root for details.
*/
const RESOURCE_ACCESS_ERROR_PATTERN = /(?:invalid password|password required|authentication required|access expired or revoked)/i;
export function isResourceAccessError(error) {
const status = Number(error?.status);
const message = typeof error === "string" ? error : error?.message;
return status === 401 || RESOURCE_ACCESS_ERROR_PATTERN.test(String(message || ""));
}
export function safeAppUrl(value, fallback = "/") {
try {
const url = new URL(String(value || ""), location.origin);
@@ -30,6 +38,3 @@ export function safePublicUrl(value, { allowMailto = true } = {}) {
}
}
export function safeHexColor(value, fallback = "#64748b") {
return /^#[0-9a-f]{6}$/i.test(String(value || "")) ? String(value) : fallback;
}
+6 -4
View File
@@ -28,6 +28,12 @@ export function setAccessToken(resourceKind, resourceSlug, granted) {
if (granted) localStorage.setItem(key, "1");
else localStorage.removeItem(key);
}
export function clearResourceAccessState() {
for (let i = localStorage.length - 1; i >= 0; i--) {
const key = localStorage.key(i);
if (key?.startsWith("rustpad:access:")) localStorage.removeItem(key);
}
}
// Remove plaintext passwords saved by the previous frontend version.
for (let i = localStorage.length - 1; i >= 0; i--) {
const key = localStorage.key(i);
@@ -89,7 +95,3 @@ export function clearAuthSession() {
sessionStorage.removeItem(NICKNAME_KEY);
setNicknameCookie("");
}
export async function resolveIdentity(api, nickname) {
const result = await api("/api/auth/identity", { method: "POST", body: JSON.stringify({ nickname }) });
setNickname(result.nickname); return result;
}
+23 -1
View File
@@ -97,6 +97,8 @@ class RoomSocket {
guest_id: this.guestId || null,
color: this.color || null,
diagnostics: this.clientDiagnostics(),
client_id: this.clientId || null,
known_revision_id: this.getKnownRevision?.() ?? null,
});
this.emitDiagnostics();
});
@@ -107,6 +109,17 @@ class RoomSocket {
try { message = JSON.parse(event.data); } catch { return; }
this.lastMessageAt = Date.now();
this.bytesReceived += typeof event.data === "string" ? new Blob([event.data]).size : Number(event.data?.byteLength || 0);
if (message.type === "password_required") {
this.intentionalClose = true;
this.onPasswordRequired?.();
socket.close();
return;
}
if (message.type === "password_changed") {
this.intentionalClose = true;
socket.close();
return;
}
if (message.type === "error") {
this.intentionalClose = true;
this.onError?.(message.message);
@@ -130,6 +143,7 @@ class RoomSocket {
return;
}
if (message.type === "document") this.onDocument?.(message);
if (message.type === "resync") this.onResync?.(message);
if (message.type === "presence") this.onPresence?.(message.users || []);
if (message.type === "chat") this.onChat?.(message);
if (message.type === "pong") {
@@ -302,7 +316,15 @@ class RoomSocket {
return true;
}
update(content, ownerMap = "[]") { this.send({ type: "update", content, owner_map: ownerMap }); }
update(baseRevisionId, updateId, operation, ownerReplacements = []) {
return this.send({
type: "update",
base_revision_id: baseRevisionId,
update_id: updateId,
operation,
owner_replacements: ownerReplacements,
});
}
chat(text) { this.send({ type: "chat", text }); }
setColor(color) { this.color = color || null; this.send({ type: "set_color", color: this.color }); }
+15 -3
View File
@@ -7,11 +7,15 @@
* See LICENSE file in repository root for details.
*/
const STORAGE_KEY = "rustpad:theme";
const DEFAULT_THEME = "dark";
const THEMES = new Set(["dark", "light"]);
const systemThemeQuery = window.matchMedia("(prefers-color-scheme: light)");
function systemTheme() {
return systemThemeQuery.matches ? "light" : "dark";
}
function normalizeTheme(value) {
return THEMES.has(value) ? value : DEFAULT_THEME;
return THEMES.has(value) ? value : systemTheme();
}
function updateBrowserChrome(theme) {
@@ -42,5 +46,13 @@ export function applySessionTheme(session) {
}
window.addEventListener("storage", event => {
if (event.key === STORAGE_KEY && event.newValue) applyTheme(event.newValue, { persist: false });
if (event.key !== STORAGE_KEY) return;
applyTheme(THEMES.has(event.newValue) ? event.newValue : systemTheme(), { persist: false });
});
systemThemeQuery.addEventListener("change", () => {
try {
if (THEMES.has(localStorage.getItem(STORAGE_KEY))) return;
} catch { }
applyTheme(systemTheme(), { persist: false });
});
+6 -1
View File
@@ -10,10 +10,15 @@
const VIEWS = new Set(["edit", "split", "preview"]);
const MODES = new Set(["markdown", "text"]);
function defaultEditorView() {
return window.matchMedia("(max-width: 760px)").matches ? "edit" : "split";
}
export function readEditorState() {
const params = new URLSearchParams(window.location.search);
const requestedView = params.get("view");
return {
view: VIEWS.has(params.get("view")) ? params.get("view") : "split",
view: VIEWS.has(requestedView) ? requestedView : defaultEditorView(),
mode: MODES.has(params.get("mode")) ? params.get("mode") : "markdown",
};
}
+53
View File
@@ -0,0 +1,53 @@
/*
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
* Source-Available Code / Dual-Licensed.
*/
const config = window.__RUSTPAD_CONFIG__ || {};
const assetVersion = encodeURIComponent(String(config.assetVersion || "dev"));
const promiseCache = new Map();
function localAsset(path) {
const separator = path.includes("?") ? "&" : "?";
return `${path}${separator}v=${assetVersion}`;
}
function once(key, factory) {
if (!promiseCache.has(key)) promiseCache.set(key, Promise.resolve().then(factory));
return promiseCache.get(key);
}
function loadClassicScript(path, globalName) {
return once(path, () => new Promise((resolve, reject) => {
const existing = document.querySelector(`script[data-rustpad-lib="${globalName}"]`);
if (existing && window[globalName]) {
resolve(window[globalName]);
return;
}
const script = existing || document.createElement("script");
script.src = localAsset(path);
script.async = true;
script.dataset.rustpadLib = globalName;
script.addEventListener("load", () => {
if (window[globalName]) resolve(window[globalName]);
else reject(new Error(`${globalName} did not register a browser global.`));
}, { once: true });
script.addEventListener("error", () => reject(new Error(`Failed to load ${path}.`)), { once: true });
if (!existing) document.head.append(script);
}));
}
export function loadHighlight() {
return loadClassicScript("/assets/libs/highlight/highlight.min.js", "hljs");
}
export function loadMermaid() {
return once("mermaid", async () => {
const module = await import(localAsset("/assets/libs/mermaid/mermaid.esm.min.mjs"));
return module.default;
});
}
export function loadMediaPlayer() {
return once("rustpad-player", () => import(localAsset("/assets/libs/rustpad-player/player.js")));
}

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