Compare commits
22
Commits
13c1aba161
...
master
+5
-1
@@ -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/
|
||||
|
||||
@@ -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
@@ -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
@@ -2581,7 +2581,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustpad"
|
||||
version = "0.2.21"
|
||||
version = "0.2.45"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"aws-config",
|
||||
|
||||
+2
-2
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"libraries": {
|
||||
"highlight": {
|
||||
"integrity": "sha512-VEPdHzwelZ12hEX18BHduqxMZGolcUsrbeokHYxOUIm8X2+M7nx5QPtPeQgRxR9XjhdLv4/7DD5BWOlSrJ3k7Q==",
|
||||
"package": "@highlightjs/cdn-assets",
|
||||
"repository": "git://github.com/highlightjs/highlight.js.git",
|
||||
"shasum": "136984ae467865e22080b3a4b65398a086e1ae7b",
|
||||
"tarball": "https://registry.npmjs.org/@highlightjs/cdn-assets/-/cdn-assets-11.11.1.tgz",
|
||||
"version": "11.11.1"
|
||||
},
|
||||
"mermaid": {
|
||||
"integrity": "sha512-TQsq6u22fAn3rek5VOubrhKPo1g5hwC3FXUN9hiyupTckcYiGuuKGkNQrKYwGJkXUxZdojwRG46gsSCFZMDp4g==",
|
||||
"package": "mermaid",
|
||||
"repository": "git+https://github.com/mermaid-js/mermaid.git",
|
||||
"shasum": "57ae2342f6c45b967113b04c9258430bdd057ee8",
|
||||
"tarball": "https://registry.npmjs.org/mermaid/-/mermaid-11.16.1.tgz",
|
||||
"version": "11.16.1"
|
||||
}
|
||||
},
|
||||
"schema": 1
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
PROJECT_DIR=$(dirname -- "$SCRIPT_DIR")
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
fatal() {
|
||||
printf 'enterdb: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
command -v docker >/dev/null 2>&1 || fatal "docker is not available in PATH"
|
||||
docker compose version >/dev/null 2>&1 || fatal "docker compose is not available"
|
||||
|
||||
service_running() {
|
||||
docker compose ps --status running --services 2>/dev/null | grep -Fxq "$1"
|
||||
}
|
||||
|
||||
# DATABASE_URL from the running application is the source of truth for the selected database engine.
|
||||
DATABASE_URL_VALUE=""
|
||||
if service_running app; then
|
||||
DATABASE_URL_VALUE=$(docker compose exec -T app sh -c 'printf "%s" "${DATABASE_URL:-}"' 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
case "$DATABASE_URL_VALUE" in
|
||||
postgres://*|postgresql://*)
|
||||
DATABASE_ENGINE=postgres
|
||||
;;
|
||||
mysql://*)
|
||||
DATABASE_ENGINE=mysql
|
||||
;;
|
||||
sqlite://*)
|
||||
fatal "the application uses SQLite; this script supports PostgreSQL and MySQL"
|
||||
;;
|
||||
"")
|
||||
POSTGRES_RUNNING=false
|
||||
MYSQL_RUNNING=false
|
||||
service_running postgres && POSTGRES_RUNNING=true
|
||||
service_running mysql && MYSQL_RUNNING=true
|
||||
|
||||
if [ "$POSTGRES_RUNNING" = true ] && [ "$MYSQL_RUNNING" = false ]; then
|
||||
DATABASE_ENGINE=postgres
|
||||
elif [ "$MYSQL_RUNNING" = true ] && [ "$POSTGRES_RUNNING" = false ]; then
|
||||
DATABASE_ENGINE=mysql
|
||||
elif [ "$POSTGRES_RUNNING" = true ] && [ "$MYSQL_RUNNING" = true ]; then
|
||||
fatal "PostgreSQL and MySQL are both running, and DATABASE_URL could not be read from the app service"
|
||||
else
|
||||
fatal "no running postgres or mysql service was found"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
fatal "unsupported DATABASE_URL: $DATABASE_URL_VALUE"
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$DATABASE_ENGINE" in
|
||||
postgres)
|
||||
service_running postgres || fatal "the postgres service is not running"
|
||||
printf 'Connecting to PostgreSQL...\n' >&2
|
||||
exec docker compose exec postgres sh -lc '
|
||||
export PGPASSWORD="${POSTGRES_PASSWORD:-rustpad}"
|
||||
exec psql --host=127.0.0.1 --username="${POSTGRES_USER:-rustpad}" --dbname="${POSTGRES_DB:-rustpad}"
|
||||
'
|
||||
;;
|
||||
mysql)
|
||||
service_running mysql || fatal "the mysql service is not running"
|
||||
printf 'Connecting to MySQL...\n' >&2
|
||||
exec docker compose exec mysql sh -lc '
|
||||
if command -v mysql >/dev/null 2>&1; then
|
||||
client=mysql
|
||||
elif command -v mariadb >/dev/null 2>&1; then
|
||||
client=mariadb
|
||||
else
|
||||
echo "enterdb: no mysql/mariadb client is available in the container" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export MYSQL_PWD="${MYSQL_PASSWORD:-rustpad}"
|
||||
exec "$client" --user="${MYSQL_USER:-rustpad}" "${MYSQL_DATABASE:-rustpad}"
|
||||
'
|
||||
;;
|
||||
esac
|
||||
Executable
+651
@@ -0,0 +1,651 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Download and refresh RustPad's third-party browser libraries.
|
||||
|
||||
The script uses only the Python standard library. By default it resolves the
|
||||
current stable package versions from npm. Reproducible builds can use a
|
||||
committed lock file generated with ``--update-lock`` and consumed with
|
||||
``--locked``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import binascii
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Callable, Iterable
|
||||
|
||||
USER_AGENT = "RustPad browser-library updater/0.1"
|
||||
DEFAULT_TIMEOUT = 45
|
||||
LOCK_SCHEMA = 1
|
||||
DEFAULT_LOCK_FILE = Path("scripts/browser-libs.lock.json")
|
||||
|
||||
|
||||
class UpdateError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Library:
|
||||
key: str
|
||||
package: str
|
||||
destination: str
|
||||
entrypoint: str
|
||||
repository_fragment: str
|
||||
installer: Callable[[tarfile.TarFile, Path], None]
|
||||
|
||||
|
||||
def registry_url(package: str, version: str = "latest") -> str:
|
||||
encoded_package = urllib.parse.quote(package, safe="")
|
||||
encoded_version = urllib.parse.quote(version, safe="")
|
||||
return f"https://registry.npmjs.org/{encoded_package}/{encoded_version}"
|
||||
|
||||
|
||||
def request_bytes(url: str, timeout: int) -> bytes:
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
headers={
|
||||
"Accept": "application/json, application/octet-stream;q=0.9, */*;q=0.8",
|
||||
"User-Agent": USER_AGENT,
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
return response.read()
|
||||
except (urllib.error.URLError, TimeoutError, OSError) as error:
|
||||
raise UpdateError(f"Cannot download {url}: {error}") from error
|
||||
|
||||
|
||||
def package_metadata(package: str, timeout: int, version: str = "latest") -> dict:
|
||||
raw = request_bytes(registry_url(package, version), timeout)
|
||||
try:
|
||||
metadata = json.loads(raw.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
||||
raise UpdateError(f"Invalid npm metadata for {package}@{version}") from error
|
||||
if not isinstance(metadata, dict):
|
||||
raise UpdateError(f"Unexpected npm metadata for {package}@{version}")
|
||||
return metadata
|
||||
|
||||
|
||||
def repository_url(metadata: dict) -> str:
|
||||
repository = metadata.get("repository", "")
|
||||
if isinstance(repository, dict):
|
||||
repository = repository.get("url", "")
|
||||
return str(repository or "")
|
||||
|
||||
|
||||
def package_dist(metadata: dict) -> dict:
|
||||
dist = metadata.get("dist")
|
||||
if not isinstance(dist, dict):
|
||||
return {}
|
||||
return dist
|
||||
|
||||
|
||||
def validate_tarball_url(url: str, package: str) -> None:
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
if parsed.scheme != "https" or not parsed.netloc:
|
||||
raise UpdateError(f"Invalid HTTPS tarball URL for {package}: {url or 'not provided'}")
|
||||
|
||||
|
||||
def validate_metadata(
|
||||
library: Library,
|
||||
metadata: dict,
|
||||
*,
|
||||
require_integrity: bool = True,
|
||||
) -> tuple[str, dict, str]:
|
||||
version = str(metadata.get("version") or "").strip()
|
||||
dist = package_dist(metadata)
|
||||
tarball = str(dist.get("tarball") or "").strip()
|
||||
integrity = str(dist.get("integrity") or "").strip()
|
||||
shasum = str(dist.get("shasum") or "").strip()
|
||||
repository = repository_url(metadata).strip()
|
||||
|
||||
if not version or not tarball:
|
||||
raise UpdateError(
|
||||
f"npm metadata for {library.package} is missing version or tarball data"
|
||||
)
|
||||
validate_tarball_url(tarball, library.package)
|
||||
if require_integrity and not integrity and not shasum:
|
||||
raise UpdateError(f"npm metadata for {library.package}@{version} has no integrity hash")
|
||||
if library.repository_fragment.lower() not in repository.lower():
|
||||
raise UpdateError(
|
||||
f"Unexpected repository for {library.package}: {repository or 'not provided'}"
|
||||
)
|
||||
return version, dist, repository
|
||||
|
||||
|
||||
def verify_tarball(data: bytes, dist: dict) -> None:
|
||||
integrity = str(dist.get("integrity") or "").strip()
|
||||
algorithms = {
|
||||
"sha512": hashlib.sha512,
|
||||
"sha384": hashlib.sha384,
|
||||
"sha256": hashlib.sha256,
|
||||
}
|
||||
|
||||
if integrity:
|
||||
recognized = False
|
||||
for token in integrity.split():
|
||||
algorithm, separator, encoded = token.partition("-")
|
||||
if not separator or algorithm not in algorithms:
|
||||
continue
|
||||
recognized = True
|
||||
try:
|
||||
expected = base64.b64decode(encoded, validate=True)
|
||||
except (binascii.Error, ValueError) as error:
|
||||
raise UpdateError(f"Invalid {algorithm} integrity value") from error
|
||||
actual = algorithms[algorithm](data).digest()
|
||||
if actual != expected:
|
||||
raise UpdateError(f"Tarball integrity verification failed ({algorithm})")
|
||||
return
|
||||
if not recognized:
|
||||
raise UpdateError("Tarball integrity uses an unsupported hash algorithm")
|
||||
|
||||
shasum = str(dist.get("shasum") or "").strip()
|
||||
if shasum:
|
||||
if hashlib.sha1(data).hexdigest().lower() != shasum.lower():
|
||||
raise UpdateError("Tarball SHA-1 verification failed")
|
||||
return
|
||||
|
||||
raise UpdateError("Tarball metadata does not contain a supported integrity hash")
|
||||
|
||||
|
||||
def safe_relative(member_name: str, prefix: tuple[str, ...]) -> PurePosixPath | None:
|
||||
path = PurePosixPath(member_name)
|
||||
parts = path.parts
|
||||
if len(parts) <= len(prefix) or tuple(parts[: len(prefix)]) != prefix:
|
||||
return None
|
||||
relative = PurePosixPath(*parts[len(prefix) :])
|
||||
if relative.is_absolute() or any(part in {"", ".", ".."} for part in relative.parts):
|
||||
raise UpdateError(f"Unsafe path in package archive: {member_name}")
|
||||
return relative
|
||||
|
||||
|
||||
def write_member(archive: tarfile.TarFile, member: tarfile.TarInfo, destination: Path) -> None:
|
||||
if not member.isfile():
|
||||
return
|
||||
source = archive.extractfile(member)
|
||||
if source is None:
|
||||
raise UpdateError(f"Cannot read {member.name} from package archive")
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
with source, destination.open("wb") as output:
|
||||
shutil.copyfileobj(source, output)
|
||||
|
||||
|
||||
def copy_license(archive: tarfile.TarFile, destination: Path) -> None:
|
||||
names = {
|
||||
"package/LICENSE",
|
||||
"package/LICENSE.txt",
|
||||
"package/LICENSE.md",
|
||||
"package/LICENCE",
|
||||
"package/LICENCE.txt",
|
||||
"package/LICENCE.md",
|
||||
}
|
||||
member = next((item for item in archive.getmembers() if item.isfile() and item.name in names), None)
|
||||
if member is None:
|
||||
raise UpdateError("The package archive does not contain a license file")
|
||||
write_member(archive, member, destination / "LICENSE.txt")
|
||||
|
||||
|
||||
def install_mermaid(archive: tarfile.TarFile, destination: Path) -> None:
|
||||
copied = 0
|
||||
for member in archive.getmembers():
|
||||
relative = safe_relative(member.name, ("package", "dist"))
|
||||
if relative is None or not member.isfile():
|
||||
continue
|
||||
suffix = relative.suffix.lower()
|
||||
is_entrypoint = relative == PurePosixPath("mermaid.esm.min.mjs")
|
||||
is_minified_chunk = relative.parts[:2] == ("chunks", "mermaid.esm.min")
|
||||
if not (is_entrypoint or is_minified_chunk):
|
||||
continue
|
||||
if suffix not in {".mjs", ".wasm", ".css"}:
|
||||
continue
|
||||
write_member(archive, member, destination / Path(*relative.parts))
|
||||
copied += 1
|
||||
if copied == 0 or not (destination / "mermaid.esm.min.mjs").is_file():
|
||||
raise UpdateError("Mermaid browser entrypoint was not found in the npm package")
|
||||
copy_license(archive, destination)
|
||||
|
||||
|
||||
def install_highlight(archive: tarfile.TarFile, destination: Path) -> None:
|
||||
candidates = {
|
||||
"package/highlight.min.js",
|
||||
"package/build/highlight.min.js",
|
||||
}
|
||||
member = next((item for item in archive.getmembers() if item.isfile() and item.name in candidates), None)
|
||||
if member is None:
|
||||
member = next(
|
||||
(
|
||||
item
|
||||
for item in archive.getmembers()
|
||||
if item.isfile() and PurePosixPath(item.name).name == "highlight.min.js"
|
||||
),
|
||||
None,
|
||||
)
|
||||
if member is None:
|
||||
raise UpdateError("Highlight.js browser build was not found in the npm package")
|
||||
write_member(archive, member, destination / "highlight.min.js")
|
||||
copy_license(archive, destination)
|
||||
|
||||
|
||||
LIBRARIES = (
|
||||
Library(
|
||||
key="mermaid",
|
||||
package="mermaid",
|
||||
destination="mermaid",
|
||||
entrypoint="mermaid.esm.min.mjs",
|
||||
repository_fragment="mermaid-js/mermaid",
|
||||
installer=install_mermaid,
|
||||
),
|
||||
Library(
|
||||
key="highlight",
|
||||
package="@highlightjs/cdn-assets",
|
||||
destination="highlight",
|
||||
entrypoint="highlight.min.js",
|
||||
repository_fragment="highlightjs/highlight.js",
|
||||
installer=install_highlight,
|
||||
),
|
||||
)
|
||||
|
||||
LIBRARIES_BY_KEY = {library.key: library for library in LIBRARIES}
|
||||
|
||||
|
||||
def installed_version(destination: Path) -> str:
|
||||
version_file = destination / "VERSION"
|
||||
try:
|
||||
return version_file.read_text(encoding="utf-8").strip()
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
def is_complete(library: Library, destination: Path) -> bool:
|
||||
return (destination / library.entrypoint).is_file() and (destination / "LICENSE.txt").is_file()
|
||||
|
||||
|
||||
def source_note(library: Library, metadata: dict, version: str) -> str:
|
||||
dist = package_dist(metadata)
|
||||
repository = repository_url(metadata)
|
||||
return (
|
||||
f"Package: {library.package}\n"
|
||||
f"Version: {version}\n"
|
||||
f"Registry: {registry_url(library.package, version)}\n"
|
||||
f"Tarball: {dist.get('tarball', '')}\n"
|
||||
f"Integrity: {dist.get('integrity') or dist.get('shasum') or ''}\n"
|
||||
f"Repository: {repository}\n"
|
||||
"Generated by scripts/update_browser_libs.py; do not edit or commit this directory.\n"
|
||||
)
|
||||
|
||||
|
||||
def install_library(
|
||||
library: Library,
|
||||
metadata: dict,
|
||||
destination: Path,
|
||||
timeout: int,
|
||||
) -> None:
|
||||
version, dist, _ = validate_metadata(library, metadata)
|
||||
tarball = request_bytes(str(dist["tarball"]), timeout)
|
||||
verify_tarball(tarball, dist)
|
||||
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(prefix=f".{library.key}-", dir=destination.parent) as temporary:
|
||||
staging = Path(temporary) / library.destination
|
||||
staging.mkdir(parents=True)
|
||||
archive_path = Path(temporary) / "package.tgz"
|
||||
archive_path.write_bytes(tarball)
|
||||
try:
|
||||
with tarfile.open(archive_path, mode="r:gz") as archive:
|
||||
library.installer(archive, staging)
|
||||
except (tarfile.TarError, OSError) as error:
|
||||
raise UpdateError(f"Cannot unpack {library.package}: {error}") from error
|
||||
|
||||
(staging / "VERSION").write_text(f"{version}\n", encoding="utf-8")
|
||||
(staging / "SOURCE.txt").write_text(
|
||||
source_note(library, metadata, version), encoding="utf-8"
|
||||
)
|
||||
if not is_complete(library, staging):
|
||||
raise UpdateError(f"Generated {library.key} directory is incomplete")
|
||||
|
||||
old_destination = destination.with_name(f".{destination.name}.old")
|
||||
if old_destination.exists():
|
||||
shutil.rmtree(old_destination)
|
||||
if destination.exists():
|
||||
destination.replace(old_destination)
|
||||
try:
|
||||
shutil.move(str(staging), str(destination))
|
||||
except Exception:
|
||||
if old_destination.exists() and not destination.exists():
|
||||
old_destination.replace(destination)
|
||||
raise
|
||||
finally:
|
||||
if old_destination.exists():
|
||||
shutil.rmtree(old_destination)
|
||||
|
||||
|
||||
def selected_libraries(keys: Iterable[str]) -> list[Library]:
|
||||
requested = set(keys)
|
||||
if not requested:
|
||||
return list(LIBRARIES)
|
||||
return [library for library in LIBRARIES if library.key in requested]
|
||||
|
||||
|
||||
def resolve_path(root: Path, value: Path) -> Path:
|
||||
return value.resolve() if value.is_absolute() else (root / value).resolve()
|
||||
|
||||
|
||||
def load_lock_file(path: Path, *, required: bool) -> dict:
|
||||
if not path.is_file():
|
||||
if required:
|
||||
raise UpdateError(
|
||||
f"Lock file does not exist: {path}. Generate it with --update-lock first."
|
||||
)
|
||||
return {"schema": LOCK_SCHEMA, "libraries": {}}
|
||||
try:
|
||||
document = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error:
|
||||
raise UpdateError(f"Cannot read lock file {path}: {error}") from error
|
||||
if not isinstance(document, dict) or document.get("schema") != LOCK_SCHEMA:
|
||||
raise UpdateError(f"Unsupported or missing lock-file schema in {path}")
|
||||
entries = document.get("libraries")
|
||||
if not isinstance(entries, dict):
|
||||
raise UpdateError(f"Lock file {path} has no libraries object")
|
||||
return document
|
||||
|
||||
|
||||
def metadata_from_lock(library: Library, document: dict) -> dict:
|
||||
entries = document["libraries"]
|
||||
entry = entries.get(library.key)
|
||||
if not isinstance(entry, dict):
|
||||
raise UpdateError(f"Lock file has no entry for {library.key}")
|
||||
|
||||
package = str(entry.get("package") or "").strip()
|
||||
version = str(entry.get("version") or "").strip()
|
||||
tarball = str(entry.get("tarball") or "").strip()
|
||||
integrity = str(entry.get("integrity") or "").strip()
|
||||
shasum = str(entry.get("shasum") or "").strip()
|
||||
repository = str(entry.get("repository") or "").strip()
|
||||
|
||||
if package != library.package:
|
||||
raise UpdateError(
|
||||
f"Lock entry {library.key} points to {package or 'no package'}, expected {library.package}"
|
||||
)
|
||||
metadata = {
|
||||
"name": package,
|
||||
"version": version,
|
||||
"repository": repository,
|
||||
"dist": {
|
||||
"tarball": tarball,
|
||||
"integrity": integrity,
|
||||
"shasum": shasum,
|
||||
},
|
||||
}
|
||||
validate_metadata(library, metadata)
|
||||
return metadata
|
||||
|
||||
|
||||
def lock_entry_from_metadata(library: Library, metadata: dict) -> dict:
|
||||
version, dist, repository = validate_metadata(library, metadata)
|
||||
return {
|
||||
"package": library.package,
|
||||
"version": version,
|
||||
"tarball": str(dist.get("tarball") or ""),
|
||||
"integrity": str(dist.get("integrity") or ""),
|
||||
"shasum": str(dist.get("shasum") or ""),
|
||||
"repository": repository,
|
||||
}
|
||||
|
||||
|
||||
def write_lock_file(path: Path, document: dict) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = json.dumps(document, indent=2, sort_keys=True, ensure_ascii=True) + "\n"
|
||||
descriptor, temporary_name = tempfile.mkstemp(
|
||||
prefix=f".{path.name}.", suffix=".tmp", dir=path.parent
|
||||
)
|
||||
temporary_path = Path(temporary_name)
|
||||
try:
|
||||
with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as output:
|
||||
output.write(payload)
|
||||
output.flush()
|
||||
os.fsync(output.fileno())
|
||||
os.replace(temporary_path, path)
|
||||
except Exception:
|
||||
temporary_path.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
|
||||
def parse_version_overrides(values: Iterable[str]) -> dict[str, str]:
|
||||
overrides: dict[str, str] = {}
|
||||
for value in values:
|
||||
key, separator, version = value.partition("=")
|
||||
key = key.strip()
|
||||
version = version.strip()
|
||||
if not separator or key not in LIBRARIES_BY_KEY or not version:
|
||||
choices = ", ".join(sorted(LIBRARIES_BY_KEY))
|
||||
raise UpdateError(
|
||||
f"Invalid --version value {value!r}; expected LIBRARY=VERSION ({choices})"
|
||||
)
|
||||
if key in overrides:
|
||||
raise UpdateError(f"Duplicate --version value for {key}")
|
||||
overrides[key] = version
|
||||
return overrides
|
||||
|
||||
|
||||
def update_one(
|
||||
library: Library,
|
||||
metadata: dict,
|
||||
libs_root: Path,
|
||||
*,
|
||||
timeout: int,
|
||||
force: bool,
|
||||
check: bool,
|
||||
) -> bool:
|
||||
target, _, _ = validate_metadata(library, metadata)
|
||||
destination = libs_root / library.destination
|
||||
current = installed_version(destination)
|
||||
complete = is_complete(library, destination)
|
||||
|
||||
if complete and current == target and not force:
|
||||
print(f"{library.key}: up to date ({target})")
|
||||
return True
|
||||
|
||||
state = "missing" if not complete else f"{current or 'unknown'} -> {target}"
|
||||
if check:
|
||||
print(f"{library.key}: update required ({state})")
|
||||
return False
|
||||
|
||||
print(f"{library.key}: downloading {target} ({state})")
|
||||
install_library(library, metadata, destination, timeout)
|
||||
print(f"{library.key}: installed {target}")
|
||||
return True
|
||||
|
||||
|
||||
def check_updates(
|
||||
libraries: list[Library],
|
||||
libs_root: Path,
|
||||
lock_path: Path,
|
||||
timeout: int,
|
||||
) -> bool:
|
||||
document = load_lock_file(lock_path, required=False)
|
||||
entries = document["libraries"]
|
||||
success = True
|
||||
|
||||
for library in libraries:
|
||||
baseline = ""
|
||||
source = "installed copy"
|
||||
if library.key in entries:
|
||||
baseline = str(metadata_from_lock(library, document).get("version") or "")
|
||||
source = "lock file"
|
||||
if not baseline:
|
||||
baseline = installed_version(libs_root / library.destination)
|
||||
|
||||
latest_metadata = package_metadata(library.package, timeout)
|
||||
latest, _, _ = validate_metadata(library, latest_metadata)
|
||||
if not baseline:
|
||||
print(f"{library.key}: no locked or installed version; latest is {latest}")
|
||||
success = False
|
||||
elif baseline == latest:
|
||||
print(f"{library.key}: {source} is current ({latest})")
|
||||
else:
|
||||
print(f"{library.key}: update available ({baseline} -> {latest}, based on {source})")
|
||||
success = False
|
||||
return success
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
script_root = Path(__file__).resolve().parent.parent
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--root", type=Path, default=script_root, help="RustPad repository root")
|
||||
parser.add_argument(
|
||||
"--library",
|
||||
action="append",
|
||||
choices=[item.key for item in LIBRARIES],
|
||||
default=[],
|
||||
help="limit the operation to one library; may be repeated",
|
||||
)
|
||||
parser.add_argument("--force", action="store_true", help="download again even when the version is current")
|
||||
parser.add_argument("--check", action="store_true", help="only verify that installed assets match the target versions")
|
||||
parser.add_argument("--strict", action="store_true", help="fail when the registry cannot be reached")
|
||||
parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT, help="network timeout in seconds")
|
||||
parser.add_argument(
|
||||
"--lock-file",
|
||||
type=Path,
|
||||
default=DEFAULT_LOCK_FILE,
|
||||
help=f"lock-file path relative to --root (default: {DEFAULT_LOCK_FILE})",
|
||||
)
|
||||
modes = parser.add_mutually_exclusive_group()
|
||||
modes.add_argument(
|
||||
"--locked",
|
||||
action="store_true",
|
||||
help="install exact versions and tarballs from the lock file without querying npm metadata",
|
||||
)
|
||||
modes.add_argument(
|
||||
"--update-lock",
|
||||
action="store_true",
|
||||
help="resolve target versions, install them, and atomically update the lock file",
|
||||
)
|
||||
modes.add_argument(
|
||||
"--check-updates",
|
||||
action="store_true",
|
||||
help="compare locked or installed versions with the current npm latest versions",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--version",
|
||||
action="append",
|
||||
default=[],
|
||||
metavar="LIBRARY=VERSION",
|
||||
help="resolve an exact npm version instead of latest; may be repeated",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def validate_arguments(args: argparse.Namespace, overrides: dict[str, str]) -> None:
|
||||
if args.timeout < 1:
|
||||
raise UpdateError("--timeout must be at least 1 second")
|
||||
if args.locked and overrides:
|
||||
raise UpdateError("--version cannot be combined with --locked")
|
||||
if args.check_updates and (args.check or args.force or overrides):
|
||||
raise UpdateError("--check-updates cannot be combined with --check, --force, or --version")
|
||||
if args.update_lock and args.check:
|
||||
raise UpdateError("--update-lock cannot be combined with --check")
|
||||
selected = set(args.library)
|
||||
if selected:
|
||||
outside_selection = sorted(set(overrides) - selected)
|
||||
if outside_selection:
|
||||
raise UpdateError(
|
||||
"--version was provided for an unselected library: "
|
||||
+ ", ".join(outside_selection)
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
try:
|
||||
overrides = parse_version_overrides(args.version)
|
||||
validate_arguments(args, overrides)
|
||||
|
||||
root = args.root.resolve()
|
||||
libs_root = root / "static" / "libs"
|
||||
lock_path = resolve_path(root, args.lock_file)
|
||||
libraries = selected_libraries(args.library)
|
||||
|
||||
if args.check_updates:
|
||||
return 0 if check_updates(libraries, libs_root, lock_path, args.timeout) else 1
|
||||
|
||||
if args.locked or args.update_lock:
|
||||
lock_document = load_lock_file(lock_path, required=args.locked)
|
||||
else:
|
||||
lock_document = {"schema": LOCK_SCHEMA, "libraries": {}}
|
||||
targets: dict[str, dict] = {}
|
||||
skipped: set[str] = set()
|
||||
effective_strict = args.strict or args.check or args.update_lock
|
||||
|
||||
for library in libraries:
|
||||
if args.locked:
|
||||
targets[library.key] = metadata_from_lock(library, lock_document)
|
||||
continue
|
||||
|
||||
requested_version = overrides.get(library.key, "latest")
|
||||
try:
|
||||
targets[library.key] = package_metadata(
|
||||
library.package,
|
||||
args.timeout,
|
||||
requested_version,
|
||||
)
|
||||
validate_metadata(library, targets[library.key])
|
||||
except UpdateError as error:
|
||||
destination = libs_root / library.destination
|
||||
complete = is_complete(library, destination)
|
||||
current = installed_version(destination)
|
||||
if complete and not effective_strict:
|
||||
print(
|
||||
f"warning: {error}; keeping {library.key} {current or 'local copy'}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
skipped.add(library.key)
|
||||
continue
|
||||
raise
|
||||
|
||||
success = True
|
||||
for library in libraries:
|
||||
if library.key in skipped:
|
||||
continue
|
||||
success = update_one(
|
||||
library,
|
||||
targets[library.key],
|
||||
libs_root,
|
||||
timeout=args.timeout,
|
||||
force=args.force,
|
||||
check=args.check,
|
||||
) and success
|
||||
|
||||
if args.update_lock and success:
|
||||
entries = dict(lock_document["libraries"])
|
||||
for library in libraries:
|
||||
entries[library.key] = lock_entry_from_metadata(library, targets[library.key])
|
||||
updated_document = {
|
||||
"schema": LOCK_SCHEMA,
|
||||
"libraries": entries,
|
||||
}
|
||||
write_lock_file(lock_path, updated_document)
|
||||
print(f"lock: updated {lock_path}")
|
||||
|
||||
return 0 if success else 1
|
||||
except UpdateError as error:
|
||||
print(f"error: {error}", file=sys.stderr)
|
||||
return 1
|
||||
except OSError as error:
|
||||
print(f"error: {error}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -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
@@ -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(
|
||||
"",
|
||||
"image.png",
|
||||
stored,
|
||||
None,
|
||||
));
|
||||
assert!(content_references_stored_file(
|
||||
"",
|
||||
"image.png",
|
||||
"https://old-files.example.com/f/token/image.png",
|
||||
Some("https://new-files.example.com"),
|
||||
));
|
||||
}
|
||||
}
|
||||
#[path = "../tests/api_files.rs"]
|
||||
mod tests;
|
||||
|
||||
+350
-87
@@ -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(¬e.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, ¬e_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, ¬e).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(¬e.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, ¬e_slug)
|
||||
.await?
|
||||
.ok_or_else(ApiError::not_found_note)?;
|
||||
let creator_can_manage_authorship = note_creator_is_requester(&state, &headers, ¬e).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, ¬e).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, ¬e_slug);
|
||||
let channel = state.note_channel(&workspace_slug, ¬e_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, ¬e_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
@@ -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, ¤t, payload.source_line, payload.checked)
|
||||
.await?;
|
||||
} else {
|
||||
commit_public_note_task_update(&state, ¤t, payload.source_line, payload.checked)
|
||||
.await?;
|
||||
}
|
||||
let page = db::find_published_page(&state.db, &token)
|
||||
.await?
|
||||
.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
@@ -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
@@ -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, ¬e_slug).await {
|
||||
Ok(Some(note)) => render_editor_page(
|
||||
&state,
|
||||
"note",
|
||||
"note",
|
||||
if workspace.is_private != 0 {
|
||||
"Note"
|
||||
} else {
|
||||
¬e.title
|
||||
},
|
||||
if workspace.is_private != 0 {
|
||||
"Workspace"
|
||||
} else {
|
||||
&workspace.title
|
||||
},
|
||||
&format!("/w/{workspace_slug}"),
|
||||
"",
|
||||
"workspace",
|
||||
"",
|
||||
),
|
||||
Ok(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 {
|
||||
¬e.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('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../tests/app_pages.rs"]
|
||||
mod tests;
|
||||
|
||||
+28
-9
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
||||
* Source-Available Code / Dual-Licensed.
|
||||
*
|
||||
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||
* Commercial or production use requires a valid paid license.
|
||||
* See LICENSE file in repository root for details.
|
||||
*/
|
||||
|
||||
use super::{
|
||||
content_references_file, content_references_stored_file, is_safe_inline_image_mime,
|
||||
is_safe_inline_video_mime, parse_byte_range,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn only_raster_images_are_inline() {
|
||||
assert!(is_safe_inline_image_mime("image/png"));
|
||||
assert!(is_safe_inline_image_mime("image/jpeg"));
|
||||
assert!(!is_safe_inline_image_mime("image/svg+xml"));
|
||||
assert!(!is_safe_inline_image_mime("text/html"));
|
||||
assert!(!is_safe_inline_image_mime("application/xml"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extended_image_alias_is_still_attached() {
|
||||
assert!(content_references_file(
|
||||
"[image=photo.jpg,Photo,a=left,size=640x400]",
|
||||
"photo.jpg",
|
||||
"/f/token/photo.jpg",
|
||||
));
|
||||
assert!(content_references_file(
|
||||
"[file=report.pdf,Quarterly report]",
|
||||
"report.pdf",
|
||||
"/f/token/report.pdf",
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_references_survive_origin_changes() {
|
||||
let stored = "/f/token/image.png";
|
||||
assert!(content_references_stored_file(
|
||||
"",
|
||||
"image.png",
|
||||
stored,
|
||||
None,
|
||||
));
|
||||
assert!(content_references_stored_file(
|
||||
"",
|
||||
"image.png",
|
||||
"https://old-files.example.com/f/token/image.png",
|
||||
Some("https://new-files.example.com"),
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn common_video_formats_are_inline() {
|
||||
assert!(is_safe_inline_video_mime("video/mp4"));
|
||||
assert!(is_safe_inline_video_mime("video/webm"));
|
||||
assert!(!is_safe_inline_video_mime("text/html"));
|
||||
assert!(!is_safe_inline_video_mime("application/javascript"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_alias_is_still_attached() {
|
||||
assert!(content_references_file(
|
||||
"[video=clip.mp4,Product demo]",
|
||||
"clip.mp4",
|
||||
"/f/token/clip.mp4",
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn byte_ranges_support_video_seeking() {
|
||||
assert_eq!(parse_byte_range("bytes=0-99", 1_000), Ok(Some((0, 100))));
|
||||
assert_eq!(parse_byte_range("bytes=500-", 1_000), Ok(Some((500, 1_000))));
|
||||
assert_eq!(parse_byte_range("bytes=-100", 1_000), Ok(Some((900, 1_000))));
|
||||
assert_eq!(parse_byte_range("bytes=900-2000", 1_000), Ok(Some((900, 1_000))));
|
||||
assert_eq!(parse_byte_range("bytes=1000-", 1_000), Err(()));
|
||||
assert_eq!(parse_byte_range("bytes=0-1,4-5", 1_000), Err(()));
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
||||
* Source-Available Code / Dual-Licensed.
|
||||
*
|
||||
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||
* Commercial or production use requires a valid paid license.
|
||||
* See LICENSE file in repository root for details.
|
||||
*/
|
||||
|
||||
use super::{ResponseHeaderPolicy, apply_response_headers, response_header_policy};
|
||||
use axum::http::{HeaderMap, HeaderValue, header};
|
||||
|
||||
#[test]
|
||||
fn classifies_assets_and_icons_as_static_assets() {
|
||||
for path in [
|
||||
"/assets/app.js",
|
||||
"/assets",
|
||||
"/favicon.ico",
|
||||
"/icons/favicon.svg",
|
||||
"/icons/missing.svg",
|
||||
] {
|
||||
assert_eq!(
|
||||
response_header_policy(path),
|
||||
ResponseHeaderPolicy::StaticAsset
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_file_routes_as_files() {
|
||||
for path in ["/f", "/f/token/image.png"] {
|
||||
assert_eq!(response_header_policy(path), ResponseHeaderPolicy::File);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_other_routes_as_application() {
|
||||
for path in [
|
||||
"/",
|
||||
"/api/auth/me",
|
||||
"/static/missing.css",
|
||||
"/files/legacy/image.png",
|
||||
"/unknown",
|
||||
] {
|
||||
assert_eq!(
|
||||
response_header_policy(path),
|
||||
ResponseHeaderPolicy::Application
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_asset_policy_only_adds_nosniff() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
header::CACHE_CONTROL,
|
||||
HeaderValue::from_static("public, max-age=3600"),
|
||||
);
|
||||
|
||||
apply_response_headers(ResponseHeaderPolicy::StaticAsset, &mut headers);
|
||||
|
||||
assert_eq!(headers.len(), 2);
|
||||
assert_eq!(headers[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
|
||||
assert!(!headers.contains_key("x-frame-options"));
|
||||
assert!(!headers.contains_key("cross-origin-opener-policy"));
|
||||
assert!(!headers.contains_key("cross-origin-resource-policy"));
|
||||
assert!(!headers.contains_key("referrer-policy"));
|
||||
assert!(!headers.contains_key("permissions-policy"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_policy_keeps_file_headers_without_document_policies() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"content-security-policy",
|
||||
HeaderValue::from_static("default-src 'none'; sandbox"),
|
||||
);
|
||||
headers.insert(
|
||||
header::CONTENT_DISPOSITION,
|
||||
HeaderValue::from_static("attachment; filename=\"manual.pdf\""),
|
||||
);
|
||||
|
||||
apply_response_headers(ResponseHeaderPolicy::File, &mut headers);
|
||||
|
||||
assert_eq!(headers[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
|
||||
assert_eq!(
|
||||
headers["content-security-policy"],
|
||||
"default-src 'none'; sandbox"
|
||||
);
|
||||
assert!(headers.contains_key(header::CONTENT_DISPOSITION));
|
||||
assert!(!headers.contains_key("x-frame-options"));
|
||||
assert!(!headers.contains_key("cross-origin-opener-policy"));
|
||||
assert!(!headers.contains_key("cross-origin-resource-policy"));
|
||||
assert!(!headers.contains_key("referrer-policy"));
|
||||
assert!(!headers.contains_key("permissions-policy"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn application_policy_preserves_handler_headers() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"content-security-policy",
|
||||
HeaderValue::from_static("default-src 'none'; sandbox"),
|
||||
);
|
||||
|
||||
apply_response_headers(ResponseHeaderPolicy::Application, &mut headers);
|
||||
|
||||
assert_eq!(
|
||||
headers["content-security-policy"],
|
||||
"default-src 'none'; sandbox"
|
||||
);
|
||||
assert_eq!(headers["x-frame-options"], "DENY");
|
||||
assert_eq!(headers["cross-origin-opener-policy"], "same-origin");
|
||||
assert_eq!(headers["cross-origin-resource-policy"], "same-origin");
|
||||
assert_eq!(headers[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
|
||||
assert_eq!(
|
||||
headers["referrer-policy"],
|
||||
"strict-origin-when-cross-origin"
|
||||
);
|
||||
assert!(headers.contains_key("permissions-policy"));
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
||||
* Source-Available Code / Dual-Licensed.
|
||||
*
|
||||
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||
* Commercial or production use requires a valid paid license.
|
||||
* See LICENSE file in repository root for details.
|
||||
*/
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn share_exchange_redirects_to_canonical_resource_path() {
|
||||
let uri: Uri = "/w/private?view=all&share=secret&page=2".parse().unwrap();
|
||||
assert_eq!(canonical_resource_url(&uri), "/w/private?view=all&page=2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoded_or_repeated_parameters_are_parsed_without_rejection() {
|
||||
let uri: Uri = "/w/private?%73hare=one&share=two".parse().unwrap();
|
||||
assert_eq!(
|
||||
share_token_from_query(uri.query()),
|
||||
(true, Some("one".into()))
|
||||
);
|
||||
assert_eq!(canonical_resource_url(&uri), "/w/private");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_share_parameter_is_still_removed_from_the_url() {
|
||||
let uri: Uri = "/w/private?share=%ZZ&keep=no".parse().unwrap();
|
||||
assert_eq!(share_token_from_query(uri.query()), (true, None));
|
||||
assert_eq!(canonical_resource_url(&uri), "/w/private?keep=no");
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
||||
* Source-Available Code / Dual-Licensed.
|
||||
*
|
||||
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||
* Commercial or production use requires a valid paid license.
|
||||
* See LICENSE file in repository root for details.
|
||||
*/
|
||||
|
||||
use super::*;
|
||||
use crate::{
|
||||
database::Database,
|
||||
state::AppState,
|
||||
storage::Storage,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
async fn logout_test_state() -> SharedState {
|
||||
let db = Database::connect("sqlite::memory:", 1)
|
||||
.await
|
||||
.expect("test database");
|
||||
crate::run_migrations(&db).await.expect("test migrations");
|
||||
Arc::new(AppState::new(
|
||||
db,
|
||||
"test".into(),
|
||||
Storage::Local {
|
||||
root: std::env::temp_dir().join("rustpad-logout-tests"),
|
||||
},
|
||||
1_000_000,
|
||||
true,
|
||||
1_000_000,
|
||||
0,
|
||||
None,
|
||||
None,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
"error".into(),
|
||||
7,
|
||||
7,
|
||||
7,
|
||||
None,
|
||||
))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn logout_only_revokes_current_browser_session_and_password_access() {
|
||||
let state = logout_test_state().await;
|
||||
sqlx::query(
|
||||
"INSERT INTO users (nickname, nickname_key, email, email_key, password_hash) VALUES (?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind("Logout Test")
|
||||
.bind("logout test")
|
||||
.bind("logout@example.test")
|
||||
.bind("logout@example.test")
|
||||
.bind("password-hash")
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let expires_at = (Utc::now() + Duration::days(7)).to_rfc3339();
|
||||
for session in ["current-session", "other-device-session"] {
|
||||
sqlx::query("INSERT INTO user_sessions (token, user_id, expires_at) VALUES (?, 1, ?)")
|
||||
.bind(session)
|
||||
.bind(&expires_at)
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let current_access_token = "a".repeat(64);
|
||||
let other_access_token = "b".repeat(64);
|
||||
for (token, slug) in [
|
||||
(current_access_token.as_str(), "current-pad"),
|
||||
(other_access_token.as_str(), "other-pad"),
|
||||
] {
|
||||
sqlx::query(
|
||||
"INSERT INTO resource_access_tokens (token_hash, resource_kind, resource_slug, expires_at) VALUES (?, 'pad', ?, ?)",
|
||||
)
|
||||
.bind(hash_token(token))
|
||||
.bind(slug)
|
||||
.bind(&expires_at)
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let access_cookie = crate::security::resource_cookie(
|
||||
"pad",
|
||||
"current-pad",
|
||||
¤t_access_token,
|
||||
7,
|
||||
);
|
||||
let access_cookie_name = access_cookie
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.split_once('=')
|
||||
.unwrap()
|
||||
.0
|
||||
.to_string();
|
||||
let share_cookie_name = "__Host-rustpad_share_0123456789abcdef01234567";
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
header::COOKIE,
|
||||
axum::http::HeaderValue::from_str(&format!(
|
||||
"{}=current-session; {}={}; {}=share-token",
|
||||
crate::security::SESSION_COOKIE,
|
||||
access_cookie_name,
|
||||
current_access_token,
|
||||
share_cookie_name,
|
||||
))
|
||||
.unwrap(),
|
||||
);
|
||||
|
||||
let response = match logout(State(state.clone()), headers).await {
|
||||
Ok(response) => response,
|
||||
Err(error) => panic!("logout failed: {}", error.message),
|
||||
};
|
||||
|
||||
let current_session_count: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM user_sessions WHERE token = ?")
|
||||
.bind("current-session")
|
||||
.fetch_one(state.db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
let other_session_count: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM user_sessions WHERE token = ?")
|
||||
.bind("other-device-session")
|
||||
.fetch_one(state.db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
let current_access_count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM resource_access_tokens WHERE token_hash = ?",
|
||||
)
|
||||
.bind(hash_token(¤t_access_token))
|
||||
.fetch_one(state.db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
let other_access_count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM resource_access_tokens WHERE token_hash = ?",
|
||||
)
|
||||
.bind(hash_token(&other_access_token))
|
||||
.fetch_one(state.db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
let user_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE id = 1")
|
||||
.fetch_one(state.db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(current_session_count, 0);
|
||||
assert_eq!(other_session_count, 1);
|
||||
assert_eq!(current_access_count, 0);
|
||||
assert_eq!(other_access_count, 1);
|
||||
assert_eq!(user_count, 1);
|
||||
|
||||
let set_cookies = response
|
||||
.headers()
|
||||
.get_all(header::SET_COOKIE)
|
||||
.iter()
|
||||
.map(|value| value.to_str().unwrap())
|
||||
.collect::<Vec<_>>();
|
||||
assert!(set_cookies.iter().any(|value| {
|
||||
value.starts_with(&format!("{}=;", crate::security::SESSION_COOKIE))
|
||||
}));
|
||||
assert!(set_cookies.iter().any(|value| {
|
||||
value.starts_with(&format!("{access_cookie_name}=;"))
|
||||
}));
|
||||
assert!(!set_cookies.iter().any(|value| value.starts_with(share_cookie_name)));
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
||||
* Source-Available Code / Dual-Licensed.
|
||||
*
|
||||
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||
* Commercial or production use requires a valid paid license.
|
||||
* See LICENSE file in repository root for details.
|
||||
*/
|
||||
|
||||
use super::*;
|
||||
|
||||
fn operation(components: Vec<OperationComponent>) -> TextOperation {
|
||||
TextOperation { components }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_insertions_have_stable_order() {
|
||||
let left = operation(vec![
|
||||
OperationComponent::Retain { count: 1 },
|
||||
OperationComponent::Insert {
|
||||
text: "X".into(),
|
||||
owners: Vec::new(),
|
||||
},
|
||||
OperationComponent::Retain { count: 1 },
|
||||
]);
|
||||
let right = operation(vec![
|
||||
OperationComponent::Retain { count: 1 },
|
||||
OperationComponent::Insert {
|
||||
text: "Y".into(),
|
||||
owners: Vec::new(),
|
||||
},
|
||||
OperationComponent::Retain { count: 1 },
|
||||
]);
|
||||
let left_prime = transform_operation(&left, &right, true).unwrap();
|
||||
let right_prime = transform_operation(&right, &left, false).unwrap();
|
||||
let after_right = apply_operation_to_document("aYb", "[]", &left_prime, &[])
|
||||
.unwrap()
|
||||
.0;
|
||||
let after_left = apply_operation_to_document("aXb", "[]", &right_prime, &[])
|
||||
.unwrap()
|
||||
.0;
|
||||
assert_eq!(after_right, "aXYb");
|
||||
assert_eq!(after_left, "aXYb");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf16_offsets_support_emoji() {
|
||||
let operation = operation(vec![
|
||||
OperationComponent::Retain { count: 3 },
|
||||
OperationComponent::Insert {
|
||||
text: "x".into(),
|
||||
owners: Vec::new(),
|
||||
},
|
||||
OperationComponent::Retain { count: 1 },
|
||||
]);
|
||||
let result = apply_operation_to_document("A😀B", "[]", &operation, &[])
|
||||
.unwrap()
|
||||
.0;
|
||||
assert_eq!(result, "A😀xB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operation_from_edit_preserves_utf16_boundaries() {
|
||||
let operation = operation_from_edit("A😀B", "A😀xB", "[]");
|
||||
let result = apply_operation_to_document("A😀B", "[]", &operation, &[])
|
||||
.unwrap()
|
||||
.0;
|
||||
assert_eq!(result, "A😀xB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acknowledgements_survive_history_compaction() {
|
||||
let mut document = CollaborativeDocument::new(String::new(), "[]".into(), 0);
|
||||
for update_id in 1..=MAX_OPERATION_HISTORY as u64 + 8 {
|
||||
let base_revision_id = document.revision_id;
|
||||
let revision_id = base_revision_id + 1;
|
||||
document.revision_id = revision_id;
|
||||
document.record(AppliedOperation {
|
||||
base_revision_id,
|
||||
revision_id,
|
||||
client_id: "client-123".into(),
|
||||
update_id,
|
||||
operation: TextOperation::default(),
|
||||
owner_replacements: Vec::new(),
|
||||
});
|
||||
}
|
||||
assert!(document.has_applied_update("client-123", 1));
|
||||
assert_eq!(
|
||||
document.acknowledged_updates("client-123"),
|
||||
vec![MAX_OPERATION_HISTORY as u64 + 8]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
||||
* Source-Available Code / Dual-Licensed.
|
||||
*
|
||||
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||
* Commercial or production use requires a valid paid license.
|
||||
* See LICENSE file in repository root for details.
|
||||
*/
|
||||
|
||||
use super::{megabytes_to_bytes, multipart_body_limit_bytes};
|
||||
|
||||
#[test]
|
||||
fn converts_upload_megabytes_to_bytes() {
|
||||
assert_eq!(megabytes_to_bytes("LIMIT", 5).unwrap(), 5 * 1024 * 1024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_overflowing_upload_limit() {
|
||||
assert!(megabytes_to_bytes("LIMIT", u64::MAX).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multipart_limit_uses_user_limit_when_guest_uploads_are_disabled() {
|
||||
assert_eq!(
|
||||
multipart_body_limit_bytes(20 * 1024 * 1024, false, 50 * 1024 * 1024),
|
||||
21 * 1024 * 1024
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multipart_limit_uses_larger_enabled_guest_limit() {
|
||||
assert_eq!(
|
||||
multipart_body_limit_bytes(20 * 1024 * 1024, true, 50 * 1024 * 1024),
|
||||
51 * 1024 * 1024
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
||||
* Source-Available Code / Dual-Licensed.
|
||||
*
|
||||
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||
* Commercial or production use requires a valid paid license.
|
||||
* See LICENSE file in repository root for details.
|
||||
*/
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn detects_security_from_standard_ports() {
|
||||
assert_eq!(smtp_security_for_port(25), SmtpSecurity::None);
|
||||
assert_eq!(smtp_security_for_port(465), SmtpSecurity::Tls);
|
||||
assert_eq!(smtp_security_for_port(587), SmtpSecurity::StartTls);
|
||||
assert_eq!(smtp_security_for_port(2525), SmtpSecurity::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_supported_security_modes() {
|
||||
assert_eq!(parse_smtp_security("none").unwrap(), SmtpSecurity::None);
|
||||
assert_eq!(
|
||||
parse_smtp_security("starttls").unwrap(),
|
||||
SmtpSecurity::StartTls
|
||||
);
|
||||
assert_eq!(parse_smtp_security("tls").unwrap(), SmtpSecurity::Tls);
|
||||
assert!(parse_smtp_security("auto").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_smtp_from() {
|
||||
assert_eq!(
|
||||
normalize_smtp_from(" \"RustPad <rustpad@notes.example>\" ".to_owned()).unwrap(),
|
||||
"RustPad <rustpad@notes.example>"
|
||||
);
|
||||
assert!(normalize_smtp_from("RustPad".to_owned()).is_err());
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
||||
* Source-Available Code / Dual-Licensed.
|
||||
*
|
||||
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||
* Commercial or production use requires a valid paid license.
|
||||
* See LICENSE file in repository root for details.
|
||||
*/
|
||||
|
||||
use super::*;
|
||||
|
||||
fn workspace(password_hash: Option<String>) -> Workspace {
|
||||
Workspace {
|
||||
id: 1,
|
||||
slug: "private-workspace".into(),
|
||||
title: "Private workspace".into(),
|
||||
password_hash,
|
||||
created_at: String::new(),
|
||||
updated_at: String::new(),
|
||||
is_private: 1,
|
||||
created_by_guest_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn pad(password_hash: Option<String>) -> Pad {
|
||||
Pad {
|
||||
id: 1,
|
||||
slug: "private-pad".into(),
|
||||
title: "Private pad".into(),
|
||||
content: String::new(),
|
||||
password_hash,
|
||||
created_at: String::new(),
|
||||
updated_at: String::new(),
|
||||
is_private: 1,
|
||||
created_by_guest_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_password_does_not_grant_password_access() {
|
||||
assert!(!verify_workspace_password(&workspace(None), None));
|
||||
assert!(!verify_workspace_password(
|
||||
&workspace(None),
|
||||
Some("anything")
|
||||
));
|
||||
assert!(!verify_pad_password(&pad(None), None));
|
||||
assert!(!verify_pad_password(&pad(None), Some("anything")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_password_is_verified() {
|
||||
let workspace = workspace(Some(hash_password("workspace-secret")));
|
||||
assert!(verify_workspace_password(
|
||||
&workspace,
|
||||
Some("workspace-secret")
|
||||
));
|
||||
assert!(!verify_workspace_password(&workspace, Some("wrong")));
|
||||
|
||||
let pad = pad(Some(hash_password("pad-secret")));
|
||||
assert!(verify_pad_password(&pad, Some("pad-secret")));
|
||||
assert!(!verify_pad_password(&pad, Some("wrong")));
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
||||
* Source-Available Code / Dual-Licensed.
|
||||
*
|
||||
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||
* Commercial or production use requires a valid paid license.
|
||||
* See LICENSE file in repository root for details.
|
||||
*/
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn normalizes_bare_domain_and_http_origins() {
|
||||
assert_eq!(
|
||||
normalize_public_base(Some("files.note.example.com".into())).unwrap(),
|
||||
Some("https://files.note.example.com".into())
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_public_base(Some("http://localhost:3001/".into())).unwrap(),
|
||||
Some("http://localhost:3001".into())
|
||||
);
|
||||
assert_eq!(normalize_public_base(Some(" ".into())).unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_origin_public_urls() {
|
||||
assert!(normalize_public_base(Some("ftp://files.example.com".into())).is_err());
|
||||
assert!(normalize_public_base(Some("https://files.example.com/path".into())).is_err());
|
||||
assert!(normalize_public_base(Some("https://user@files.example.com".into())).is_err());
|
||||
assert!(normalize_public_base(Some("files.example.com\\path".into())).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_canonical_path_from_relative_and_absolute_urls() {
|
||||
assert_eq!(
|
||||
canonical_file_path("/f/token/image.png"),
|
||||
Some("/f/token/image.png".into())
|
||||
);
|
||||
assert_eq!(
|
||||
canonical_file_path("https://files.example.com/f/token/image.png"),
|
||||
Some("/f/token/image.png".into())
|
||||
);
|
||||
assert_eq!(
|
||||
canonical_file_path("https://files.example.com/f/token/image.png?download=1"),
|
||||
Some("/f/token/image.png".into())
|
||||
);
|
||||
assert_eq!(canonical_file_path("/files/token/image.png"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switches_between_custom_origin_and_application_path() {
|
||||
let stored = "/f/token/manual.pdf";
|
||||
assert_eq!(public_file_url(None, stored), stored);
|
||||
assert_eq!(
|
||||
public_file_url(Some("https://files.example.com"), stored),
|
||||
"https://files.example.com/f/token/manual.pdf"
|
||||
);
|
||||
assert_eq!(
|
||||
public_file_url(None, "https://old.example.com/f/token/manual.pdf"),
|
||||
stored
|
||||
);
|
||||
assert_eq!(
|
||||
public_file_url(Some("https://files.example.com"), "/invalid/path"),
|
||||
"/invalid/path"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
||||
* Source-Available Code / Dual-Licensed.
|
||||
*
|
||||
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||
* Commercial or production use requires a valid paid license.
|
||||
* See LICENSE file in repository root for details.
|
||||
*/
|
||||
|
||||
use super::startup_credential;
|
||||
|
||||
#[test]
|
||||
fn startup_credential_contains_product_identity() {
|
||||
let credential = startup_credential();
|
||||
assert!(credential.contains(&format!("RustPad {}", env!("CARGO_PKG_VERSION"))));
|
||||
assert!(credential.contains("Mateusz Gruszczyński @linuxiarz.pl"));
|
||||
assert!(
|
||||
credential.contains("https://git.linuxiarz.pl/gru/rustpad/src/branch/master/LICENSE.md")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
||||
* Source-Available Code / Dual-Licensed.
|
||||
*
|
||||
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||
* Commercial or production use requires a valid paid license.
|
||||
* See LICENSE file in repository root for details.
|
||||
*/
|
||||
|
||||
use super::*;
|
||||
#[test]
|
||||
fn every_backend_has_explicit_queries() {
|
||||
for query in [
|
||||
Q001,
|
||||
Q003,
|
||||
Q004,
|
||||
Q011,
|
||||
Q021,
|
||||
Q033,
|
||||
USER_LIST_WORKSPACES,
|
||||
USER_LIST_PADS,
|
||||
RESOURCE_ACCESS_TOKENS_DELETE_BY_TOKEN_HASH,
|
||||
SHARE_LINK_SESSION_SOURCE,
|
||||
SHARE_SESSION_INSERT,
|
||||
SHARE_SESSION_PERMISSION,
|
||||
SHARE_SESSIONS_DELETE_BY_LINK,
|
||||
SHARE_SESSIONS_DELETE_EXPIRED,
|
||||
PAD_PUBLIC_PAGE_DISABLED,
|
||||
NOTE_PUBLIC_PAGE_DISABLED,
|
||||
] {
|
||||
assert!(!get(DatabaseKind::Sqlite, query).is_empty());
|
||||
assert!(!get(DatabaseKind::Postgres, query).is_empty());
|
||||
assert!(!get(DatabaseKind::MySql, query).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
||||
* Source-Available Code / Dual-Licensed.
|
||||
*
|
||||
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||
* Commercial or production use requires a valid paid license.
|
||||
* See LICENSE file in repository root for details.
|
||||
*/
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn boolean_projections_are_normalized_for_sqlx_any() {
|
||||
// MySQL BOOLEAN is TINYINT(1), which sqlx::Any 0.8 cannot map directly.
|
||||
for (query, expected_casts) in [
|
||||
(Query::RESOURCE_EDITOR_SETTINGS_SELECT, 1),
|
||||
(Query::EDITOR_PREFERENCES_SELECT_PAD, 5),
|
||||
(Query::EDITOR_PREFERENCES_SELECT_NOTE, 5),
|
||||
(Query::AUTH_USER_BY_EXTERNAL_ID, 1),
|
||||
(Query::AUTH_USER_BY_SESSION, 1),
|
||||
(Query::AUTH_USER_BY_NICKNAME, 1),
|
||||
(Query::AUTH_USER_BY_EMAIL, 1),
|
||||
(Query::AUTH_USER_BY_SHARE_IDENTIFIER, 1),
|
||||
(Query::USER_LIST_WORKSPACES, 6),
|
||||
(Query::USER_LIST_PADS, 6),
|
||||
(Query::PAD_PUBLIC_PAGE_DISABLED, 1),
|
||||
(Query::NOTE_PUBLIC_PAGE_DISABLED, 1),
|
||||
(Query::Q001, 1),
|
||||
(Query::Q003, 1),
|
||||
(Query::Q004, 1),
|
||||
(Query::Q011, 1),
|
||||
(Query::Q021, 1),
|
||||
(Query::Q033, 1),
|
||||
(Query::Q036, 1),
|
||||
(Query::Q038, 1),
|
||||
(Query::Q046, 1),
|
||||
(Query::Q044, 1),
|
||||
(Query::Q045, 1),
|
||||
(Query::Q048, 1),
|
||||
(Query::Q049, 1),
|
||||
] {
|
||||
let sql = get(query);
|
||||
assert_eq!(
|
||||
sql.matches("AS SIGNED").count(),
|
||||
expected_casts,
|
||||
"MySQL boolean projection is not normalized in {query:?}: {sql}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
||||
* Source-Available Code / Dual-Licensed.
|
||||
*
|
||||
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||
* Commercial or production use requires a valid paid license.
|
||||
* See LICENSE file in repository root for details.
|
||||
*/
|
||||
|
||||
use super::*;
|
||||
|
||||
fn websocket_headers(origin: &'static str, host: &'static str) -> HeaderMap {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(header::ORIGIN, HeaderValue::from_static(origin));
|
||||
headers.insert(header::HOST, HeaderValue::from_static(host));
|
||||
headers
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_sessions_are_cookie_only() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
header::AUTHORIZATION,
|
||||
HeaderValue::from_static("Bearer legacy-account-token"),
|
||||
);
|
||||
assert_eq!(session_token(&headers), None);
|
||||
assert_eq!(bearer_token(&headers), Some("legacy-account-token"));
|
||||
|
||||
headers.insert(
|
||||
header::COOKIE,
|
||||
HeaderValue::from_static("__Host-rustpad_session=cookie-token"),
|
||||
);
|
||||
assert_eq!(session_token(&headers), Some("cookie-token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefers_proxy_controlled_real_ip() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
axum::http::HeaderName::from_static("x-forwarded-for"),
|
||||
HeaderValue::from_static("203.0.113.10"),
|
||||
);
|
||||
headers.insert(
|
||||
axum::http::HeaderName::from_static("x-real-ip"),
|
||||
HeaderValue::from_static("198.51.100.20"),
|
||||
);
|
||||
assert_eq!(client_key(&headers), "ip:198.51.100.20");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_same_origin_websocket() {
|
||||
let headers = websocket_headers("https://pad.example.com", "pad.example.com");
|
||||
assert!(websocket_origin_allowed(&headers));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_cross_origin_websocket() {
|
||||
let headers = websocket_headers("https://evil.example", "pad.example.com");
|
||||
assert!(!websocket_origin_allowed(&headers));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_trust_forwarded_host_for_websocket_origin() {
|
||||
let mut headers = websocket_headers("https://evil.example", "pad.example.com");
|
||||
headers.insert(
|
||||
axum::http::HeaderName::from_static("x-forwarded-host"),
|
||||
HeaderValue::from_static("evil.example"),
|
||||
);
|
||||
assert!(!websocket_origin_allowed(&headers));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_origin_with_path() {
|
||||
let headers = websocket_headers("https://pad.example.com/other", "pad.example.com");
|
||||
assert!(!websocket_origin_allowed(&headers));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_missing_websocket_origin() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(header::HOST, HeaderValue::from_static("pad.example.com"));
|
||||
assert!(!websocket_origin_allowed(&headers));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secure_cookies_are_not_script_readable() {
|
||||
let value = session_cookie("abc123", 7).to_str().unwrap();
|
||||
assert!(value.contains("HttpOnly"));
|
||||
assert!(value.contains("Secure"));
|
||||
assert!(value.contains("SameSite=Lax"));
|
||||
assert!(value.starts_with("__Host-rustpad_session=abc123;"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn share_sessions_use_separate_scoped_opaque_cookies() {
|
||||
let value = share_session_cookie("workspace", "private-space", "opaque", 600)
|
||||
.to_str()
|
||||
.unwrap();
|
||||
assert!(value.starts_with("__Host-rustpad_share_"));
|
||||
assert!(value.contains("=opaque;"));
|
||||
assert!(value.contains("Max-Age=600"));
|
||||
assert!(value.contains("HttpOnly"));
|
||||
assert!(value.contains("Secure"));
|
||||
assert!(value.contains("SameSite=Lax"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logout_cookie_discovery_only_selects_password_access_cookies() {
|
||||
let valid_name = "__Host-rustpad_access_0123456789abcdef01234567";
|
||||
let valid_token = "a".repeat(64);
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
header::COOKIE,
|
||||
HeaderValue::from_str(&format!(
|
||||
"{SESSION_COOKIE}=session-token; {valid_name}={valid_token}; __Host-rustpad_share_0123456789abcdef01234567=share-token; __Host-rustpad_access_too-short={valid_token}"
|
||||
))
|
||||
.unwrap(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
resource_access_cookies(&headers),
|
||||
vec![(valid_name.to_string(), Some(valid_token))]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_password_access_token_is_cleared_but_not_revoked() {
|
||||
let name = "__Host-rustpad_access_0123456789abcdef01234567";
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
header::COOKIE,
|
||||
HeaderValue::from_str(&format!("{name}=not-a-valid-token")).unwrap(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
resource_access_cookies(&headers),
|
||||
vec![(name.to_string(), None)]
|
||||
);
|
||||
let cleared = clear_resource_access_cookie(name).unwrap();
|
||||
assert!(cleared.to_str().unwrap().starts_with(&format!("{name}=;")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csrf_requires_matching_cookie_and_header() {
|
||||
let token = "a".repeat(CSRF_TOKEN_BYTES * 2);
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
header::COOKIE,
|
||||
HeaderValue::from_str(&format!("{CSRF_COOKIE}={token}")).unwrap(),
|
||||
);
|
||||
headers.insert(
|
||||
axum::http::HeaderName::from_static(CSRF_HEADER),
|
||||
HeaderValue::from_str(&token).unwrap(),
|
||||
);
|
||||
assert!(csrf_request_is_valid(&headers));
|
||||
|
||||
headers.insert(
|
||||
axum::http::HeaderName::from_static(CSRF_HEADER),
|
||||
HeaderValue::from_static(
|
||||
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
),
|
||||
);
|
||||
assert!(!csrf_request_is_valid(&headers));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csrf_cookie_is_strict_and_script_readable() {
|
||||
let token = "a".repeat(CSRF_TOKEN_BYTES * 2);
|
||||
let value = csrf_cookie(&token).to_str().unwrap();
|
||||
assert!(value.contains("Secure"));
|
||||
assert!(value.contains("SameSite=Strict"));
|
||||
assert!(!value.contains("HttpOnly"));
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
||||
* Source-Available Code / Dual-Licensed.
|
||||
*
|
||||
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||
* Commercial or production use requires a valid paid license.
|
||||
* See LICENSE file in repository root for details.
|
||||
*/
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn workspace_password_event_reaches_the_workspace_page_and_its_notes() {
|
||||
let workspace_key = AppState::workspace_room_key("team");
|
||||
let note_prefix = "workspace:team/";
|
||||
|
||||
assert!(workspace_password_event_channel(
|
||||
"workspace:team",
|
||||
&workspace_key,
|
||||
note_prefix,
|
||||
));
|
||||
assert!(workspace_password_event_channel(
|
||||
"workspace:team/roadmap",
|
||||
&workspace_key,
|
||||
note_prefix,
|
||||
));
|
||||
assert!(!workspace_password_event_channel(
|
||||
"workspace:team-two/roadmap",
|
||||
&workspace_key,
|
||||
note_prefix,
|
||||
));
|
||||
assert!(!workspace_password_event_channel(
|
||||
"workspace:teams/roadmap",
|
||||
&workspace_key,
|
||||
note_prefix,
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
||||
* Source-Available Code / Dual-Licensed.
|
||||
*
|
||||
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||
* Commercial or production use requires a valid paid license.
|
||||
* See LICENSE file in repository root for details.
|
||||
*/
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn password_event_only_excludes_the_matching_connection() {
|
||||
assert!(password_event_excludes_connection(
|
||||
Some("client_owner"),
|
||||
"client_owner",
|
||||
));
|
||||
assert!(!password_event_excludes_connection(
|
||||
Some("client_owner"),
|
||||
"client_visitor",
|
||||
));
|
||||
assert!(!password_event_excludes_connection(None, "client_owner"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn password_event_client_id_is_validated() {
|
||||
assert_eq!(
|
||||
clean_collaboration_client_id(Some("client_owner_123".into())),
|
||||
Some("client_owner_123".into()),
|
||||
);
|
||||
assert_eq!(clean_collaboration_client_id(Some("short".into())), None);
|
||||
assert_eq!(
|
||||
clean_collaboration_client_id(Some("invalid client id!".into())),
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_watch_authentication_accepts_a_minimal_client_message() {
|
||||
let message = serde_json::from_str::<WorkspaceWatchClientMessage>(
|
||||
r#"{"type":"authenticate","access_token":null,"client_id":"workspace_watch_123"}"#,
|
||||
)
|
||||
.expect("workspace watch authentication should parse");
|
||||
|
||||
match message {
|
||||
WorkspaceWatchClientMessage::Authenticate {
|
||||
access_token,
|
||||
client_id,
|
||||
} => {
|
||||
assert!(access_token.is_none());
|
||||
assert_eq!(client_id.as_deref(), Some("workspace_watch_123"));
|
||||
}
|
||||
WorkspaceWatchClientMessage::Ping { .. } => panic!("unexpected ping message"),
|
||||
}
|
||||
}
|
||||
+728
-80
@@ -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, ¬e_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, ¬e_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, ¬e_slug);
|
||||
let channel = state.note_channel(&workspace_slug, ¬e_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,¬e_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
@@ -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
File diff suppressed because it is too large
Load Diff
+50
-19
@@ -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 H1–H4</span>__EXTRA_SHORTCUTS__
|
||||
list</span><kbd>Alt+1…4</kbd><span>Headings H1–H4</span><kbd>Tab</kbd><span>Indent by 2
|
||||
spaces</span><kbd>Shift+Tab</kbd><span>Remove indentation</span>__EXTRA_SHORTCUTS__
|
||||
</div>
|
||||
</div>
|
||||
</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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Mateusz Gruszczynski @linuxiarz.pl
|
||||
* Source-Available Code / Dual-Licensed.
|
||||
*
|
||||
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||
* Commercial or production use requires a valid paid license.
|
||||
* See LICENSE file in repository root for details.
|
||||
*/
|
||||
|
||||
import {
|
||||
applyOperationToDocument,
|
||||
compareOperationKeys,
|
||||
composeOperations,
|
||||
documentAfterPending,
|
||||
identityOperation,
|
||||
normalizeOperation,
|
||||
operationBaseLength,
|
||||
operationFromEdit,
|
||||
operationTargetLength,
|
||||
transformOperations,
|
||||
} from "@rustpad/collaboration";
|
||||
import { parseAuthorship } from "@rustpad/authorship";
|
||||
|
||||
function hasTextEffect(operation) {
|
||||
return normalizeOperation(operation).components.some(component => component.kind === "insert" || component.kind === "delete");
|
||||
}
|
||||
|
||||
function mergeOwnerReplacements(previous = [], next = []) {
|
||||
const replacements = new Map();
|
||||
for (const item of [...previous, ...next]) {
|
||||
const owner = String(item?.owner || "");
|
||||
const replacement = String(item?.replacement || "");
|
||||
if (owner && replacement) replacements.set(owner, { owner, replacement });
|
||||
}
|
||||
return [...replacements.values()];
|
||||
}
|
||||
|
||||
function pendingEnvelope(clientId, updateId, operation, ownerReplacements = []) {
|
||||
return {
|
||||
clientId,
|
||||
updateId,
|
||||
operation: normalizeOperation(operation),
|
||||
ownerReplacements: mergeOwnerReplacements([], ownerReplacements),
|
||||
};
|
||||
}
|
||||
|
||||
function serverEnvelope(message) {
|
||||
return {
|
||||
baseRevisionId: Number(message?.base_revision_id ?? message?.baseRevisionId),
|
||||
revisionId: Number(message?.revision_id ?? message?.revisionId),
|
||||
clientId: String(message?.client_id ?? message?.clientId ?? ""),
|
||||
updateId: Number(message?.update_id ?? message?.updateId ?? 0),
|
||||
operation: normalizeOperation(message?.operation),
|
||||
ownerReplacements: mergeOwnerReplacements([], message?.owner_replacements ?? message?.ownerReplacements ?? []),
|
||||
};
|
||||
}
|
||||
|
||||
export class CollaborationRevisionGapError extends Error {
|
||||
constructor(expected, actual) {
|
||||
super(`Collaborative revision gap: expected ${expected}, received ${actual}`);
|
||||
this.name = "CollaborationRevisionGapError";
|
||||
this.expected = expected;
|
||||
this.actual = actual;
|
||||
}
|
||||
}
|
||||
|
||||
export class CollaborationSession {
|
||||
constructor(clientId) {
|
||||
this.clientId = String(clientId || "");
|
||||
this.ready = false;
|
||||
this.serverContent = "";
|
||||
this.serverOwnerMap = "[]";
|
||||
this.revisionId = 0;
|
||||
this.outstanding = null;
|
||||
this.buffer = null;
|
||||
this.nextUpdateId = 1;
|
||||
}
|
||||
|
||||
initialize(content, ownerMap, revisionId, { clearPending = true } = {}) {
|
||||
this.serverContent = String(content || "");
|
||||
this.serverOwnerMap = ownerMap == null ? "[]" : String(ownerMap);
|
||||
this.revisionId = Number(revisionId) || 0;
|
||||
if (clearPending) {
|
||||
this.outstanding = null;
|
||||
this.buffer = null;
|
||||
}
|
||||
this.ready = true;
|
||||
}
|
||||
|
||||
localDocument() {
|
||||
return documentAfterPending(
|
||||
this.serverContent,
|
||||
this.serverOwnerMap,
|
||||
this.outstanding,
|
||||
this.buffer,
|
||||
);
|
||||
}
|
||||
|
||||
hasPending() {
|
||||
return Boolean(this.outstanding || this.buffer);
|
||||
}
|
||||
|
||||
queue(operation, ownerReplacements = []) {
|
||||
operation = normalizeOperation(operation);
|
||||
const replacements = mergeOwnerReplacements([], ownerReplacements);
|
||||
if (!hasTextEffect(operation) && !replacements.length) return null;
|
||||
|
||||
const localLength = this.localDocument().content.length;
|
||||
if (operationBaseLength(operation) !== localLength) {
|
||||
throw new Error("Local operation base length does not match the collaborative document");
|
||||
}
|
||||
|
||||
if (!this.buffer) {
|
||||
this.buffer = pendingEnvelope(this.clientId, this.nextUpdateId++, operation, replacements);
|
||||
} else {
|
||||
this.buffer.operation = composeOperations(this.buffer.operation, operation);
|
||||
this.buffer.ownerReplacements = mergeOwnerReplacements(this.buffer.ownerReplacements, replacements);
|
||||
}
|
||||
|
||||
if (operationTargetLength(this.buffer.operation) !== this.localDocument().content.length) {
|
||||
throw new Error("Buffered operation target length does not match the collaborative document");
|
||||
}
|
||||
return this.buffer;
|
||||
}
|
||||
|
||||
sendable() {
|
||||
if (!this.ready || this.outstanding || !this.buffer) return null;
|
||||
return {
|
||||
baseRevisionId: this.revisionId,
|
||||
updateId: this.buffer.updateId,
|
||||
operation: this.buffer.operation,
|
||||
ownerReplacements: this.buffer.ownerReplacements,
|
||||
};
|
||||
}
|
||||
|
||||
markSent(updateId) {
|
||||
if (this.outstanding || !this.buffer || this.buffer.updateId !== Number(updateId)) return false;
|
||||
this.outstanding = this.buffer;
|
||||
this.buffer = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
integrate(message) {
|
||||
const remote = serverEnvelope(message);
|
||||
if (!Number.isFinite(remote.baseRevisionId) || !Number.isFinite(remote.revisionId)) {
|
||||
throw new Error("Collaborative update is missing revision metadata");
|
||||
}
|
||||
if (remote.revisionId <= this.revisionId) return { duplicate: true, ownAck: false, remote: false };
|
||||
if (remote.baseRevisionId !== this.revisionId) {
|
||||
throw new CollaborationRevisionGapError(this.revisionId, remote.baseRevisionId);
|
||||
}
|
||||
|
||||
const nextServer = applyOperationToDocument(
|
||||
this.serverContent,
|
||||
this.serverOwnerMap,
|
||||
remote.operation,
|
||||
remote.ownerReplacements,
|
||||
);
|
||||
const ownAck = Boolean(
|
||||
this.outstanding
|
||||
&& remote.clientId === this.clientId
|
||||
&& remote.updateId === this.outstanding.updateId
|
||||
);
|
||||
|
||||
if (ownAck) {
|
||||
this.outstanding = null;
|
||||
} else {
|
||||
let remoteForPending = remote.operation;
|
||||
if (this.outstanding) {
|
||||
const outstandingBeforeRemote = compareOperationKeys(this.outstanding, remote) < 0;
|
||||
const [outstandingPrime, remotePrime] = transformOperations(
|
||||
this.outstanding.operation,
|
||||
remoteForPending,
|
||||
outstandingBeforeRemote,
|
||||
);
|
||||
this.outstanding.operation = outstandingPrime;
|
||||
remoteForPending = remotePrime;
|
||||
}
|
||||
if (this.buffer) {
|
||||
const bufferBeforeRemote = compareOperationKeys(this.buffer, remote) < 0;
|
||||
const [bufferPrime] = transformOperations(
|
||||
this.buffer.operation,
|
||||
remoteForPending,
|
||||
bufferBeforeRemote,
|
||||
);
|
||||
this.buffer.operation = bufferPrime;
|
||||
}
|
||||
}
|
||||
|
||||
this.serverContent = nextServer.content;
|
||||
this.serverOwnerMap = nextServer.ownerMap;
|
||||
this.revisionId = remote.revisionId;
|
||||
return { duplicate: false, ownAck, remote: !ownAck };
|
||||
}
|
||||
|
||||
resynchronize(message) {
|
||||
const canonicalContent = String(message?.content || "");
|
||||
const canonicalOwnerMap = message?.owner_map == null ? "[]" : String(message.owner_map);
|
||||
const canonicalRevisionId = Number(message?.revision_id) || 0;
|
||||
const outstanding = this.outstanding;
|
||||
const buffer = this.buffer;
|
||||
const local = this.localDocument();
|
||||
const acknowledgedIds = (message?.acknowledged_update_ids || [])
|
||||
.map(Number)
|
||||
.filter(Number.isFinite);
|
||||
const acknowledgedThrough = acknowledgedIds.length ? Math.max(...acknowledgedIds) : 0;
|
||||
const messageClientId = String(message?.client_id ?? message?.clientId ?? "");
|
||||
const messageUpdateId = Number(message?.update_id ?? message?.updateId ?? 0);
|
||||
const outstandingAcknowledged = Boolean(
|
||||
outstanding
|
||||
&& (
|
||||
acknowledgedThrough >= outstanding.updateId
|
||||
|| (messageClientId === this.clientId && messageUpdateId === outstanding.updateId)
|
||||
)
|
||||
);
|
||||
const pendingOwnerReplacements = mergeOwnerReplacements(
|
||||
outstanding?.ownerReplacements || [],
|
||||
buffer?.ownerReplacements || [],
|
||||
);
|
||||
let replayOperation = null;
|
||||
let replayOwnerReplacements = [];
|
||||
|
||||
if (this.ready && (outstanding || buffer)) {
|
||||
const canonicalAuthorship = parseAuthorship(canonicalContent, canonicalOwnerMap);
|
||||
if (outstandingAcknowledged) {
|
||||
if (buffer) {
|
||||
const afterOutstanding = applyOperationToDocument(
|
||||
this.serverContent,
|
||||
this.serverOwnerMap,
|
||||
outstanding.operation,
|
||||
outstanding.ownerReplacements,
|
||||
);
|
||||
const missedRemote = operationFromEdit(
|
||||
afterOutstanding.content,
|
||||
canonicalContent,
|
||||
canonicalAuthorship,
|
||||
);
|
||||
[replayOperation] = transformOperations(buffer.operation, missedRemote, false);
|
||||
replayOwnerReplacements = buffer.ownerReplacements;
|
||||
}
|
||||
} else {
|
||||
const localAuthorship = parseAuthorship(local.content, local.ownerMap);
|
||||
const localOperation = operationFromEdit(this.serverContent, local.content, localAuthorship);
|
||||
const missedRemote = operationFromEdit(
|
||||
this.serverContent,
|
||||
canonicalContent,
|
||||
canonicalAuthorship,
|
||||
);
|
||||
[replayOperation] = transformOperations(localOperation, missedRemote, false);
|
||||
replayOwnerReplacements = pendingOwnerReplacements;
|
||||
}
|
||||
}
|
||||
|
||||
this.initialize(canonicalContent, canonicalOwnerMap, canonicalRevisionId);
|
||||
if (replayOperation || replayOwnerReplacements.length) {
|
||||
this.queue(
|
||||
replayOperation || identityOperation(canonicalContent.length),
|
||||
replayOwnerReplacements,
|
||||
);
|
||||
}
|
||||
return {
|
||||
replayed: Boolean(this.buffer),
|
||||
outstandingAcknowledged,
|
||||
};
|
||||
}
|
||||
|
||||
adoptCanonicalSnapshot(content, ownerMap, revisionId) {
|
||||
revisionId = Number(revisionId) || 0;
|
||||
if (revisionId !== this.revisionId) {
|
||||
throw new CollaborationRevisionGapError(this.revisionId, revisionId);
|
||||
}
|
||||
if (String(content || "") !== this.serverContent) {
|
||||
throw new Error("Canonical collaborative content does not match applied operations");
|
||||
}
|
||||
this.serverOwnerMap = ownerMap == null ? "[]" : String(ownerMap);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Mateusz Gruszczynski @linuxiarz.pl
|
||||
* Source-Available Code / Dual-Licensed.
|
||||
*
|
||||
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||
* Commercial or production use requires a valid paid license.
|
||||
* See LICENSE file in repository root for details.
|
||||
*/
|
||||
|
||||
import { parseAuthorship, serializeAuthorship } from "@rustpad/authorship";
|
||||
|
||||
function normalizeOwnerSpans(spans, length) {
|
||||
const result = [];
|
||||
const sorted = [...(Array.isArray(spans) ? spans : [])].sort((left, right) => (Number(left?.start) || 0) - (Number(right?.start) || 0) || (Number(left?.end) || 0) - (Number(right?.end) || 0));
|
||||
for (const source of sorted) {
|
||||
const start = Math.max(0, Math.min(length, Number(source?.start) || 0));
|
||||
const end = Math.max(start, Math.min(length, Number(source?.end) || 0));
|
||||
const owner = String(source?.owner || "");
|
||||
if (!owner || end <= start) continue;
|
||||
const previous = result.at(-1);
|
||||
if (previous && previous.owner === owner && start <= previous.end) {
|
||||
previous.end = Math.max(previous.end, end);
|
||||
continue;
|
||||
}
|
||||
const clippedStart = previous && start < previous.end ? previous.end : start;
|
||||
if (end > clippedStart) result.push({ start: clippedStart, end, owner });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function sliceOwnerSpans(spans, start, length) {
|
||||
const end = start + length;
|
||||
return normalizeOwnerSpans((spans || []).flatMap(span => {
|
||||
const overlapStart = Math.max(start, span.start);
|
||||
const overlapEnd = Math.min(end, span.end);
|
||||
return overlapEnd > overlapStart
|
||||
? [{ start: overlapStart - start, end: overlapEnd - start, owner: span.owner }]
|
||||
: [];
|
||||
}), length);
|
||||
}
|
||||
|
||||
function shiftOwnerSpans(spans, offset) {
|
||||
return (spans || []).map(span => ({ start: span.start + offset, end: span.end + offset, owner: span.owner }));
|
||||
}
|
||||
|
||||
function appendComponent(components, component) {
|
||||
if (!component) return;
|
||||
if (component.kind === "retain" || component.kind === "delete") {
|
||||
const count = Number(component.count) || 0;
|
||||
if (count <= 0) return;
|
||||
const previous = components.at(-1);
|
||||
if (previous?.kind === component.kind) previous.count += count;
|
||||
else components.push({ kind: component.kind, count });
|
||||
return;
|
||||
}
|
||||
if (component.kind !== "insert") throw new Error("Unknown operation component");
|
||||
const text = String(component.text || "");
|
||||
if (!text) return;
|
||||
const owners = normalizeOwnerSpans(component.owners, text.length);
|
||||
const previous = components.at(-1);
|
||||
if (previous?.kind === "insert") {
|
||||
const offset = previous.text.length;
|
||||
previous.text += text;
|
||||
previous.owners = normalizeOwnerSpans([
|
||||
...(previous.owners || []),
|
||||
...shiftOwnerSpans(owners, offset),
|
||||
], previous.text.length);
|
||||
} else components.push({ kind: "insert", text, owners });
|
||||
}
|
||||
|
||||
export function normalizeOperation(operation) {
|
||||
const components = [];
|
||||
for (const component of operation?.components || []) appendComponent(components, component);
|
||||
return { components };
|
||||
}
|
||||
|
||||
export function operationBaseLength(operation) {
|
||||
return normalizeOperation(operation).components.reduce((length, component) =>
|
||||
length + (component.kind === "retain" || component.kind === "delete" ? component.count : 0), 0);
|
||||
}
|
||||
|
||||
export function operationTargetLength(operation) {
|
||||
return normalizeOperation(operation).components.reduce((length, component) =>
|
||||
length + (component.kind === "retain" ? component.count : component.kind === "insert" ? component.text.length : 0), 0);
|
||||
}
|
||||
|
||||
export function identityOperation(length) {
|
||||
return normalizeOperation({ components: length > 0 ? [{ kind: "retain", count: length }] : [] });
|
||||
}
|
||||
|
||||
function isUtf16Boundary(value, offset) {
|
||||
if (offset <= 0 || offset >= value.length) return true;
|
||||
const previous = value.charCodeAt(offset - 1);
|
||||
const next = value.charCodeAt(offset);
|
||||
return !(previous >= 0xd800 && previous <= 0xdbff && next >= 0xdc00 && next <= 0xdfff);
|
||||
}
|
||||
|
||||
export function operationFromEdit(previousText, nextText, nextAuthorship) {
|
||||
previousText = String(previousText || "");
|
||||
nextText = String(nextText || "");
|
||||
let prefix = 0;
|
||||
const shared = Math.min(previousText.length, nextText.length);
|
||||
while (prefix < shared && previousText.charCodeAt(prefix) === nextText.charCodeAt(prefix)) prefix++;
|
||||
while (prefix > 0 && (!isUtf16Boundary(previousText, prefix) || !isUtf16Boundary(nextText, prefix))) prefix--;
|
||||
|
||||
let oldSuffix = previousText.length;
|
||||
let newSuffix = nextText.length;
|
||||
while (oldSuffix > prefix && newSuffix > prefix && previousText.charCodeAt(oldSuffix - 1) === nextText.charCodeAt(newSuffix - 1)) {
|
||||
oldSuffix--;
|
||||
newSuffix--;
|
||||
}
|
||||
while (!isUtf16Boundary(previousText, oldSuffix) || !isUtf16Boundary(nextText, newSuffix)) {
|
||||
oldSuffix++;
|
||||
newSuffix++;
|
||||
}
|
||||
|
||||
const components = [];
|
||||
appendComponent(components, { kind: "retain", count: prefix });
|
||||
appendComponent(components, { kind: "delete", count: oldSuffix - prefix });
|
||||
const insertedText = nextText.slice(prefix, newSuffix);
|
||||
appendComponent(components, {
|
||||
kind: "insert",
|
||||
text: insertedText,
|
||||
owners: sliceOwnerSpans(nextAuthorship?.spans || [], prefix, insertedText.length),
|
||||
});
|
||||
appendComponent(components, { kind: "retain", count: previousText.length - oldSuffix });
|
||||
return { components };
|
||||
}
|
||||
|
||||
class OperationCursor {
|
||||
constructor(operation) {
|
||||
this.components = normalizeOperation(operation).components;
|
||||
this.index = 0;
|
||||
this.offset = 0;
|
||||
}
|
||||
|
||||
get current() { return this.components[this.index] || null; }
|
||||
get kind() { return this.current?.kind || null; }
|
||||
get remaining() {
|
||||
const component = this.current;
|
||||
if (!component) return 0;
|
||||
return (component.kind === "insert" ? component.text.length : component.count) - this.offset;
|
||||
}
|
||||
|
||||
take(count = this.remaining) {
|
||||
const component = this.current;
|
||||
if (!component || count <= 0 || count > this.remaining) throw new Error("Invalid operation cursor read");
|
||||
let part;
|
||||
if (component.kind === "insert") {
|
||||
part = {
|
||||
kind: "insert",
|
||||
text: component.text.slice(this.offset, this.offset + count),
|
||||
owners: sliceOwnerSpans(component.owners, this.offset, count),
|
||||
};
|
||||
} else part = { kind: component.kind, count };
|
||||
this.offset += count;
|
||||
if (this.offset === (component.kind === "insert" ? component.text.length : component.count)) {
|
||||
this.index++;
|
||||
this.offset = 0;
|
||||
}
|
||||
return part;
|
||||
}
|
||||
}
|
||||
|
||||
export function composeOperations(first, second) {
|
||||
first = normalizeOperation(first);
|
||||
second = normalizeOperation(second);
|
||||
if (operationTargetLength(first) !== operationBaseLength(second)) throw new Error("Cannot compose operations with different lengths");
|
||||
const left = new OperationCursor(first);
|
||||
const right = new OperationCursor(second);
|
||||
const components = [];
|
||||
|
||||
while (left.current || right.current) {
|
||||
if (right.kind === "insert") {
|
||||
appendComponent(components, right.take());
|
||||
continue;
|
||||
}
|
||||
if (left.kind === "delete") {
|
||||
appendComponent(components, left.take());
|
||||
continue;
|
||||
}
|
||||
if (!left.current || !right.current) throw new Error("Incomplete operation composition");
|
||||
const count = Math.min(left.remaining, right.remaining);
|
||||
const leftKind = left.kind;
|
||||
const rightKind = right.kind;
|
||||
if (leftKind === "retain" && rightKind === "retain") {
|
||||
appendComponent(components, { kind: "retain", count });
|
||||
left.take(count);
|
||||
right.take(count);
|
||||
} else if (leftKind === "retain" && rightKind === "delete") {
|
||||
appendComponent(components, { kind: "delete", count });
|
||||
left.take(count);
|
||||
right.take(count);
|
||||
} else if (leftKind === "insert" && rightKind === "retain") {
|
||||
appendComponent(components, left.take(count));
|
||||
right.take(count);
|
||||
} else if (leftKind === "insert" && rightKind === "delete") {
|
||||
left.take(count);
|
||||
right.take(count);
|
||||
} else throw new Error("Unsupported operation composition");
|
||||
}
|
||||
return { components };
|
||||
}
|
||||
|
||||
export function transformOperations(leftOperation, rightOperation, leftBeforeRight = true) {
|
||||
leftOperation = normalizeOperation(leftOperation);
|
||||
rightOperation = normalizeOperation(rightOperation);
|
||||
if (operationBaseLength(leftOperation) !== operationBaseLength(rightOperation)) throw new Error("Cannot transform operations with different base lengths");
|
||||
const left = new OperationCursor(leftOperation);
|
||||
const right = new OperationCursor(rightOperation);
|
||||
const leftPrime = [];
|
||||
const rightPrime = [];
|
||||
|
||||
while (left.current || right.current) {
|
||||
if (left.kind === "insert" && (right.kind !== "insert" || leftBeforeRight)) {
|
||||
const part = left.take();
|
||||
appendComponent(leftPrime, part);
|
||||
appendComponent(rightPrime, { kind: "retain", count: part.text.length });
|
||||
continue;
|
||||
}
|
||||
if (right.kind === "insert") {
|
||||
const part = right.take();
|
||||
appendComponent(leftPrime, { kind: "retain", count: part.text.length });
|
||||
appendComponent(rightPrime, part);
|
||||
continue;
|
||||
}
|
||||
if (!left.current || !right.current) throw new Error("Incomplete operation transform");
|
||||
const count = Math.min(left.remaining, right.remaining);
|
||||
if (left.kind === "retain" && right.kind === "retain") {
|
||||
appendComponent(leftPrime, { kind: "retain", count });
|
||||
appendComponent(rightPrime, { kind: "retain", count });
|
||||
} else if (left.kind === "delete" && right.kind === "retain") {
|
||||
appendComponent(leftPrime, { kind: "delete", count });
|
||||
} else if (left.kind === "retain" && right.kind === "delete") {
|
||||
appendComponent(rightPrime, { kind: "delete", count });
|
||||
} else if (left.kind !== "delete" || right.kind !== "delete") throw new Error("Unsupported operation transform");
|
||||
left.take(count);
|
||||
right.take(count);
|
||||
}
|
||||
return [{ components: leftPrime }, { components: rightPrime }];
|
||||
}
|
||||
|
||||
function copyRetainedSpans(target, spans, sourceStart, length, outputStart) {
|
||||
const sourceEnd = sourceStart + length;
|
||||
for (const span of spans || []) {
|
||||
const start = Math.max(sourceStart, span.start);
|
||||
const end = Math.min(sourceEnd, span.end);
|
||||
if (end > start) target.push({ start: outputStart + start - sourceStart, end: outputStart + end - sourceStart, owner: span.owner });
|
||||
}
|
||||
}
|
||||
|
||||
export function applyOperationToDocument(content, ownerMap, operation, ownerReplacements = []) {
|
||||
content = String(content || "");
|
||||
operation = normalizeOperation(operation);
|
||||
if (operationBaseLength(operation) !== content.length) throw new Error("Operation base length does not match document");
|
||||
const sourceModel = parseAuthorship(content, ownerMap);
|
||||
const outputSpans = [];
|
||||
let sourceOffset = 0;
|
||||
let outputOffset = 0;
|
||||
let nextContent = "";
|
||||
for (const component of operation.components) {
|
||||
if (component.kind === "retain") {
|
||||
nextContent += content.slice(sourceOffset, sourceOffset + component.count);
|
||||
copyRetainedSpans(outputSpans, sourceModel.spans, sourceOffset, component.count, outputOffset);
|
||||
sourceOffset += component.count;
|
||||
outputOffset += component.count;
|
||||
} else if (component.kind === "delete") sourceOffset += component.count;
|
||||
else {
|
||||
nextContent += component.text;
|
||||
outputSpans.push(...shiftOwnerSpans(component.owners, outputOffset));
|
||||
outputOffset += component.text.length;
|
||||
}
|
||||
}
|
||||
if (sourceOffset !== content.length) throw new Error("Operation did not consume the whole document");
|
||||
const replacementMap = new Map((ownerReplacements || []).map(item => [String(item?.owner || ""), String(item?.replacement || "")]));
|
||||
const replacedSpans = outputSpans.map(span => {
|
||||
const identity = String(span.owner || "").split("\u001f", 1)[0];
|
||||
const replacement = replacementMap.get(identity);
|
||||
return replacement ? { ...span, owner: replacement } : span;
|
||||
});
|
||||
const model = { version: 2, spans: normalizeOwnerSpans(replacedSpans, nextContent.length) };
|
||||
return { content: nextContent, ownerMap: serializeAuthorship(model, nextContent.length), authorship: model };
|
||||
}
|
||||
|
||||
export function compareOperationKeys(left, right) {
|
||||
const leftClient = String(left?.clientId || left?.client_id || "");
|
||||
const rightClient = String(right?.clientId || right?.client_id || "");
|
||||
if (leftClient !== rightClient) return leftClient < rightClient ? -1 : 1;
|
||||
const leftUpdate = Number(left?.updateId ?? left?.update_id ?? 0);
|
||||
const rightUpdate = Number(right?.updateId ?? right?.update_id ?? 0);
|
||||
return leftUpdate === rightUpdate ? 0 : leftUpdate < rightUpdate ? -1 : 1;
|
||||
}
|
||||
|
||||
export function documentAfterPending(serverContent, serverOwnerMap, outstanding, buffer) {
|
||||
let documentState = { content: serverContent, ownerMap: serverOwnerMap };
|
||||
for (const pending of [outstanding, buffer]) {
|
||||
if (!pending) continue;
|
||||
documentState = applyOperationToDocument(
|
||||
documentState.content,
|
||||
documentState.ownerMap,
|
||||
pending.operation,
|
||||
pending.ownerReplacements,
|
||||
);
|
||||
}
|
||||
return documentState;
|
||||
}
|
||||
@@ -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
@@ -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>`;
|
||||
|
||||
@@ -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
@@ -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 => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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();
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+226
-44
@@ -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})`;
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user