mobile fixes

This commit is contained in:
Mateusz Gruszczyński
2026-08-04 09:22:41 +02:00
parent 825edeab92
commit 83e10129c3
10 changed files with 12 additions and 666 deletions
Generated
+1 -1
View File
@@ -2581,7 +2581,7 @@ dependencies = [
[[package]]
name = "rustpad"
version = "0.2.28"
version = "0.2.29"
dependencies = [
"argon2",
"aws-config",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "rustpad"
version = "0.2.28"
version = "0.2.29"
edition = "2024"
rust-version = "1.94"
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
+4
View File
@@ -5104,6 +5104,10 @@ dialog::backdrop {
padding: 16px;
}
.pad-page:not(.hide-preview-line-numbers) .preview {
padding-left: 62px;
}
.pad-page .editor-footer {
align-items: center;
gap: 8px;
+5
View File
@@ -1059,10 +1059,15 @@ export function startNoteEditor(adapter) {
if (!event.matches) setHeaderMenuOpen(false);
});
const mobileEditorOptions = document.querySelector("#mobile-editor-options");
const markdownMore = document.querySelector(".markdown-more");
const mobileToolbarQuery = matchMedia("(max-width: 720px)");
document.addEventListener("pointerdown", event => {
if (mobileEditorOptions?.open && !event.target.closest("#mobile-editor-options")) {
mobileEditorOptions.open = false;
}
if (mobileToolbarQuery.matches && markdownMore?.open && !markdownMore.contains(event.target)) {
markdownMore.open = false;
}
}, { passive: true });
document.addEventListener("keydown", event => {
if (event.key === "Escape" && mobileEditorOptions?.open) mobileEditorOptions.open = false;
+1 -1
View File
@@ -47,7 +47,7 @@ function safeAttachmentUrl(value) {
: safePublicUrl(raw, { allowMailto: false });
}
export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, canUpload, toast, onFilesChanged = () => {} }) {
export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, canUpload, toast, onFilesChanged = () => { } }) {
const dialog = document.querySelector("#files-dialog");
const list = document.querySelector("#files-list");
const input = document.querySelector("#file-input");
-159
View File
@@ -1,159 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readFile } from "node:fs/promises";
async function importSource(path) {
const source = await readFile(new URL(path, import.meta.url), "utf8");
return import(`data:text/javascript;base64,${Buffer.from(source).toString("base64")}`);
}
class FakeEditor {
constructor(value, start = 0, end = start) {
this.value = value;
this.selectionStart = start;
this.selectionEnd = end;
this.selectionDirection = "none";
this.readOnly = false;
this.inputEvents = 0;
}
setRangeText(replacement, start, end, mode) {
this.value = `${this.value.slice(0, start)}${replacement}${this.value.slice(end)}`;
const caret = mode === "end" ? start + replacement.length : start;
this.selectionStart = caret;
this.selectionEnd = caret;
}
setSelectionRange(start, end, direction = "none") {
this.selectionStart = start;
this.selectionEnd = end;
this.selectionDirection = direction;
}
dispatchEvent(event) {
if (event.type === "input") this.inputEvents += 1;
return true;
}
}
const { applyIndentation } = await importSource("../static/js/editor-format.js");
const markdownSource = await readFile(new URL("../static/js/markdown.js", import.meta.url), "utf8");
const alignSource = markdownSource.match(/export function alignPreviewLineNumbers\(root\) \{[\s\S]*?\n\}/)?.[0];
assert.ok(alignSource, "alignPreviewLineNumbers source must exist");
const alignPreviewLineNumbers = new Function(`${alignSource.replace("export ", "")}; return alignPreviewLineNumbers;`)();
test("Tab inserts two spaces at the caret", () => {
const editor = new FakeEditor("abcd", 2);
assert.equal(applyIndentation(editor), true);
assert.equal(editor.value, "ab cd");
assert.deepEqual([editor.selectionStart, editor.selectionEnd], [4, 4]);
assert.equal(editor.inputEvents, 1);
});
test("Tab indents every selected line", () => {
const editor = new FakeEditor("alpha\nbeta\ngamma", 0, 10);
assert.equal(applyIndentation(editor), true);
assert.equal(editor.value, " alpha\n beta\ngamma");
assert.deepEqual([editor.selectionStart, editor.selectionEnd], [2, 14]);
});
test("Shift+Tab removes up to two spaces from selected lines", () => {
const editor = new FakeEditor(" alpha\n beta", 2, 13);
assert.equal(applyIndentation(editor, { outdent: true }), true);
assert.equal(editor.value, "alpha\nbeta");
assert.deepEqual([editor.selectionStart, editor.selectionEnd], [0, 10]);
});
test("Shift+Tab removes a leading tab", () => {
const editor = new FakeEditor("\talpha", 4);
assert.equal(applyIndentation(editor, { outdent: true }), true);
assert.equal(editor.value, "alpha");
assert.deepEqual([editor.selectionStart, editor.selectionEnd], [3, 3]);
});
test("preview line-number alignment ignores horizontal scroll", () => {
const previousGetComputedStyle = globalThis.getComputedStyle;
globalThis.getComputedStyle = () => ({ paddingLeft: "62px" });
let assigned;
const line = {
getBoundingClientRect: () => ({ left: 70 }),
style: { setProperty: (name, value) => { assigned = [name, value]; } },
};
const root = {
scrollLeft: 80,
getBoundingClientRect: () => ({ left: 100 }),
querySelectorAll: () => [line],
};
try {
alignPreviewLineNumbers(root);
assert.deepEqual(assigned, ["--preview-line-left", "-38px"]);
} finally {
globalThis.getComputedStyle = previousGetComputedStyle;
}
});
test("compact editor layout starts below 1500px", async () => {
const css = await readFile(new URL("../static/css/styles.css", import.meta.url), "utf8");
const editorSource = await readFile(new URL("../static/js/note-editor.js", import.meta.url), "utf8");
assert.equal((css.match(/@media \(max-width: 1499px\)/g) || []).length, 4);
assert.doesNotMatch(css, /@media \(max-width: 1920px\)/);
assert.match(editorSource, /compactLayoutQuery = window\.matchMedia\("\(max-width: 1499px\)"\)/);
assert.match(editorSource, /compactBubbleQuery = matchMedia\("\(max-width: 1499px\)"\)/);
});
test("read-only mode blocks every document write path", async () => {
const editorHtml = await readFile(new URL("../static/editor.html", import.meta.url), "utf8");
const editorSource = await readFile(new URL("../static/js/note-editor.js", import.meta.url), "utf8");
assert.match(editorHtml, /<textarea id="editor"[\s\S]*?readonly><\/textarea>/);
assert.match(editorSource, /function canEditDocument\(\) \{ return !editor\.readOnly; \}/);
assert.match(editorSource, /function scheduleDocumentSave\(\) \{[\s\S]*?if \(!canEditDocument\(\)\) \{[\s\S]*?saveState\.textContent = "Read only";[\s\S]*?return;/);
assert.match(editorSource, /function activatePreviewEdit\(target, offset = null\) \{\s*if \(!target \|\| !canEditDocument\(\)\) return;/);
assert.match(editorSource, /function commitPreviewEdit\(target,[\s\S]*?if \(!canEditDocument\(\)\) \{ render\(\); return; \}/);
assert.match(editorSource, /if \(socket && canEditDocument\(\)\) socket\.update/g);
assert.match(editorSource, /setDocumentReadOnly\(true, "Read only — changes not saved"\);[\s\S]*?applyRemote\(lastServerContent, lastServerOwnerMap\);[\s\S]*?queueMicrotask\(connect\);/);
});
test("narrow editor switches stay compact", async () => {
const css = await readFile(new URL("../static/css/styles.css", import.meta.url), "utf8");
assert.match(css, /\.pad-page #mode-toggle \{[\s\S]*?width:\s*auto;[\s\S]*?min-width:\s*0;[\s\S]*?white-space:\s*nowrap;/);
assert.match(css, /@media \(max-width: 760px\) \{[\s\S]*?\.pad-page \.editor-toolbar \{\s*grid-template-columns:\s*minmax\(0, 1fr\) auto;/);
assert.match(css, /@media \(max-width: 760px\) \{[\s\S]*?\.pad-page \.view-switch \{[\s\S]*?display:\s*inline-flex;[\s\S]*?width:\s*max-content;[\s\S]*?justify-self:\s*end;/);
assert.doesNotMatch(css, /\.pad-page \.view-switch \{\s*position:\s*static;\s*display:\s*grid;\s*width:\s*100%;/);
});
test("Full HD scaling keeps Preview visible without compacting the whole editor", async () => {
const css = await readFile(new URL("../static/css/styles.css", import.meta.url), "utf8");
assert.match(css, /@media \(min-width: 1500px\) and \(max-width: 1699px\) \{[\s\S]*?\.pad-page \.toolbar-group \{[\s\S]*?flex:\s*1 1 auto;[\s\S]*?overflow-x:\s*auto;/);
assert.match(css, /@media \(min-width: 1500px\) and \(max-width: 1699px\) \{[\s\S]*?\.pad-page \.toolbar-fill \{\s*display:\s*none;/);
assert.match(css, /@media \(min-width: 1500px\) and \(max-width: 1699px\) \{[\s\S]*?\.pad-page :is\(\.editor-controls, \.toolbar-action, \.line-toggle, \.view-switch\) \{\s*flex:\s*0 0 auto;/);
});
test("Markdown/Text uses the same toolbar action styling as More", async () => {
const html = await readFile(new URL("../static/editor.html", import.meta.url), "utf8");
const css = await readFile(new URL("../static/css/styles.css", import.meta.url), "utf8");
const editorSource = await readFile(new URL("../static/js/note-editor.js", import.meta.url), "utf8");
assert.match(html, /id="mode-toggle" class="toolbar-action active"/);
assert.doesNotMatch(css, /\.markdown-toggle/);
assert.match(css, /\.markdown-more>summary,\s*\.pad-page \.toolbar-action \{/);
assert.match(editorSource, /modeToggle\.setAttribute\("aria-pressed", String\(markdown\)\)/);
});
test("zoomed editor uses compact labels without hiding any view", async () => {
const html = await readFile(new URL("../static/editor.html", import.meta.url), "utf8");
const css = await readFile(new URL("../static/css/styles.css", import.meta.url), "utf8");
const editorSource = await readFile(new URL("../static/js/note-editor.js", import.meta.url), "utf8");
assert.match(html, /id="mode-toggle"[\s\S]*?control-label-full">Markdown<[\s\S]*?control-label-short" aria-hidden="true">M</);
assert.match(html, /data-view="edit"[\s\S]*?>E<[\s\S]*?data-view="split"[\s\S]*?>S<[\s\S]*?data-view="preview"[\s\S]*?>P</);
assert.match(css, /@media \(max-width: 1699px\) \{[\s\S]*?\.pad-page :is\(#mode-toggle, \.view-switch\) \.control-label-full \{\s*display:\s*none;/);
assert.match(css, /@media \(max-width: 760px\) \{[\s\S]*?grid-template-columns:\s*minmax\(0, 1fr\) auto auto;[\s\S]*?#mode-toggle \{\s*display:\s*inline-flex;[\s\S]*?button\[data-view="split"\][\s\S]*?display:\s*inline-flex;/);
assert.match(editorSource, /modeToggle\.querySelector\("\.control-label-short"\)\.textContent = markdown \? "M" : "T";/);
});
test("history panel wraps long content without horizontal scrolling", async () => {
const css = await readFile(new URL("../static/css/styles.css", import.meta.url), "utf8");
assert.match(css, /\.history-list \{[\s\S]*?overflow-x:\s*hidden;[\s\S]*?overflow-y:\s*auto;/);
assert.match(css, /\.revision \{[\s\S]*?grid-template-columns:\s*12px minmax\(0, 1fr\);/);
assert.match(css, /\.revision__meta strong,[\s\S]*?\.revision__preview \{[\s\S]*?overflow-wrap:\s*anywhere;[\s\S]*?word-break:\s*break-word;/);
assert.match(css, /\.revision__preview \{[\s\S]*?overflow-x:\s*hidden;[\s\S]*?white-space:\s*pre-wrap;/);
});
-29
View File
@@ -1,29 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readFile } from "node:fs/promises";
const css = await readFile(new URL("../static/css/styles.css", import.meta.url), "utf8");
function rule(selector) {
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const match = css.match(new RegExp(`${escaped}\\s*\\{([^}]*)\\}`));
assert.ok(match, `Missing CSS rule: ${selector}`);
return match[1];
}
test("top-level page headers share one compact height", () => {
assert.match(css, /--top-header-height:\s*54px;/);
for (const selector of [".site-header", ".app-header", ".public-header"]) {
assert.match(rule(selector), /min-height:\s*var\(--top-header-height\)/);
}
});
test("header actions use the shared compact control height", () => {
assert.match(css, /--top-header-control-height:\s*34px;/);
assert.match(css, /\.app-header \.secondary-button,[\s\S]*?min-height:\s*var\(--top-header-control-height\)/);
assert.match(css, /\.public-header \.secondary-button,[\s\S]*?min-height:\s*var\(--top-header-control-height\)/);
});
test("small screens retain taller header controls", () => {
assert.match(css, /@media \(max-width: 720px\)[\s\S]*?\.public-header \.secondary-button,[\s\S]*?min-height:\s*36px;/);
});
-210
View File
@@ -1,210 +0,0 @@
#!/usr/bin/env python3
from __future__ import annotations
import hashlib
import re
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
MIGRATIONS = ROOT / "migrations" / "sqlite"
SQLITE_QUERIES = ROOT / "src" / "queries" / "sqlite.rs"
def query(name: str) -> str:
source = SQLITE_QUERIES.read_text()
pattern = rf'Query::{re.escape(name)}\s*=>\s*\{{\s*r#"(.*?)"#\s*\}}'
match = re.search(pattern, source, re.S)
if not match:
raise AssertionError(f"Query::{name} not found")
return match.group(1)
def parse_rfc3339(value: str) -> datetime:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
if parsed.tzinfo is None:
raise ValueError("timestamp must include timezone")
return parsed.astimezone(timezone.utc)
def active_permission(row: tuple[str, str, str | None] | None, now: datetime) -> str | None:
if row is None:
return None
permission, session_expires_at, link_expires_at = row
try:
if parse_rfc3339(session_expires_at) <= now:
return None
if link_expires_at is not None and parse_rfc3339(link_expires_at) <= now:
return None
except ValueError:
return None
return permission if permission in {"ro", "rw"} else None
def link_active(expires_at: str | None, now: datetime) -> bool:
if expires_at is None:
return True
try:
return parse_rfc3339(expires_at) > now
except ValueError:
return False
def apply(connection: sqlite3.Connection, paths: list[Path]) -> None:
for path in paths:
connection.executescript(path.read_text())
def main() -> None:
migrations = sorted(MIGRATIONS.glob("*.sql"))
before_sessions = [path for path in migrations if path.name < "0026_share_link_sessions.sql"]
session_migration = MIGRATIONS / "0026_share_link_sessions.sql"
label_migration = MIGRATIONS / "0027_share_link_labels.sql"
db = sqlite3.connect(":memory:")
db.execute("PRAGMA foreign_keys = ON")
apply(db, before_sessions)
db.execute(
"INSERT INTO users (nickname, nickname_key, email, email_key, password_hash) "
"VALUES (?, ?, ?, ?, ?)",
("Owner", "owner", "owner@example.test", "owner@example.test", "hash"),
)
raw_link = "legacy-share-token"
share_hash = hashlib.sha256(raw_link.encode()).hexdigest()
db.execute(
"INSERT INTO resource_share_links "
"(token_hash, resource_kind, resource_slug, permission, expires_at, created_by) "
"VALUES (?, 'workspace', 'private-space', 'ro', NULL, 1)",
(share_hash,),
)
db.executescript(session_migration.read_text())
db.executescript(label_migration.read_text())
columns = {row[1] for row in db.execute("PRAGMA table_info(resource_share_links)")}
assert "token" not in columns, "fresh schema must not contain a plaintext token column"
assert "label" in columns, "share links must support labels"
source_sql = query("SHARE_LINK_SESSION_SOURCE")
insert_sql = query("SHARE_SESSION_INSERT")
permission_sql = query("SHARE_SESSION_PERMISSION")
revoke_sessions_sql = query("SHARE_SESSIONS_DELETE_BY_LINK")
delete_expired_sql = query("SHARE_SESSIONS_DELETE_EXPIRED")
link_insert_sql = query("SHARE_LINK_INSERT")
sharing_list_sql = query("RESOURCE_SHARING_LINKS")
now = datetime(2026, 8, 2, 23, 0, tzinfo=timezone.utc)
source = db.execute(source_sql, (share_hash, "workspace", "private-space")).fetchone()
assert source == (share_hash, "ro", None)
session_raw = "opaque-browser-session"
session_hash = hashlib.sha256(session_raw.encode()).hexdigest()
db.execute(
insert_sql,
(
session_hash,
share_hash,
"workspace",
"private-space",
"2026-08-03T00:00:00+00:00",
),
)
def permission(slug: str = "private-space") -> str | None:
row = db.execute(permission_sql, (session_hash, "workspace", slug)).fetchone()
return active_permission(row, now)
assert permission() == "ro", "ro share session must remain read-only"
assert permission("another-space") is None, "session must be scoped to one resource"
db.execute(
"UPDATE resource_share_links SET permission = 'rw' WHERE token_hash = ?", (share_hash,)
)
assert permission() == "rw", "ro -> rw change must affect active sessions immediately"
db.execute(
"UPDATE resource_share_links SET permission = 'ro' WHERE token_hash = ?", (share_hash,)
)
assert permission() == "ro", "rw -> ro change must remove write access immediately"
db.execute(
"UPDATE resource_share_sessions SET expires_at = ? WHERE session_token_hash = ?",
("2026-08-02T22:59:59+00:00", session_hash),
)
assert permission() is None, "expired browser session must be denied"
db.execute(
"UPDATE resource_share_sessions SET expires_at = ? WHERE session_token_hash = ?",
("2026-08-03T00:00:00+00:00", session_hash),
)
# Offset timestamps are parsed chronologically rather than compared as text.
db.execute(
"UPDATE resource_share_links SET expires_at = ? WHERE token_hash = ?",
("2026-08-03T01:30:00+02:00", share_hash),
)
row = db.execute(permission_sql, (session_hash, "workspace", "private-space")).fetchone()
assert active_permission(row, now) == "ro"
later = datetime(2026, 8, 2, 23, 31, tzinfo=timezone.utc)
assert active_permission(row, later) is None, "expired link must invalidate its sessions"
db.execute(
"UPDATE resource_share_links SET expires_at = 'not-a-date' WHERE token_hash = ?",
(share_hash,),
)
row = db.execute(permission_sql, (session_hash, "workspace", "private-space")).fetchone()
assert active_permission(row, now) is None, "malformed expiration must fail closed"
assert not link_active("not-a-date", now)
db.execute(
"UPDATE resource_share_links SET expires_at = NULL, revoked_at = ? WHERE token_hash = ?",
("2026-08-02T23:05:00+00:00", share_hash),
)
assert db.execute(permission_sql, (session_hash, "workspace", "private-space")).fetchone() is None
deleted = db.execute(
revoke_sessions_sql, (share_hash, "workspace", "private-space")
).rowcount
assert deleted == 1, "revoking a link must remove all derived sessions"
# Newly created links persist only the hash and an optional identifying label.
second_raw = "one-time-returned-token"
second_hash = hashlib.sha256(second_raw.encode()).hexdigest()
db.execute(
link_insert_sql,
(second_hash, "QA link", "workspace", "second-space", "rw", None, 1),
)
assert db.execute(
"SELECT label, permission FROM resource_share_links WHERE token_hash = ?", (second_hash,)
).fetchone() == ("QA link", "rw")
listed_link = db.execute(sharing_list_sql, ("workspace", "second-space")).fetchone()
assert listed_link == (
second_hash,
"QA link",
"rw",
None,
listed_link[4],
), "management listing must return the label but never a plaintext share token"
db.execute(
insert_sql,
(
hashlib.sha256(b"expired-row").hexdigest(),
second_hash,
"workspace",
"second-space",
"2026-08-02T22:00:00+00:00",
),
)
db.execute(delete_expired_sql, ("2026-08-02T23:00:00+00:00",))
assert db.execute(
"SELECT COUNT(*) FROM resource_share_sessions WHERE share_token_hash = ?", (second_hash,)
).fetchone() == (0,), "expired session cleanup must remove stale rows"
db.execute("DELETE FROM resource_share_links WHERE token_hash = ?", (second_hash,))
assert db.execute(
"SELECT COUNT(*) FROM resource_share_sessions WHERE share_token_hash = ?", (second_hash,)
).fetchone() == (0,), "link deletion must cascade to sessions"
print("share session SQL regression tests: 17 passed")
if __name__ == "__main__":
main()
-175
View File
@@ -1,175 +0,0 @@
#!/usr/bin/env python3
from __future__ import annotations
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def read(path: str) -> str:
return (ROOT / path).read_text()
def query_sql(backend: str, name: str) -> str:
source = read(f"src/queries/{backend}.rs")
match = re.search(
rf'Query::{re.escape(name)}\s*=>\s*\{{\s*r#"(.*?)"#\s*\}}',
source,
re.S,
)
if not match:
raise AssertionError(f"{backend}: Query::{name} missing")
return match.group(1)
def main() -> None:
names = {
"SHARE_LINK_INSERT": 7,
"SHARE_LINK_UPDATE": 6,
"SHARE_LINK_PERMISSION": 3,
"SHARE_LINK_SESSION_SOURCE": 3,
"SHARE_SESSION_INSERT": 5,
"SHARE_SESSION_PERMISSION": 3,
"SHARE_SESSIONS_DELETE_BY_LINK": 3,
"SHARE_SESSIONS_DELETE_EXPIRED": 1,
}
for backend in ("sqlite", "mysql"):
for name, expected in names.items():
sql = query_sql(backend, name)
assert sql.count("?") == expected, (backend, name, sql)
for name, expected in names.items():
sql = query_sql("postgres", name)
parameters = [int(value) for value in re.findall(r"\$(\d+)", sql)]
assert sorted(set(parameters)) == list(range(1, expected + 1)), (name, sql)
auth = read("src/auth/mod.rs")
register_response = auth[auth.index("pub struct RegisterResponse"):auth.index("pub async fn identity")]
assert "token:" not in register_response
assert ".bind(&token)" not in auth[auth.index("pub async fn create_share_link"):auth.index("pub async fn update_share_link")]
assert '"token":row.token' not in auth
assert "token: Option<String>" not in auth[auth.index("struct SharingLinkRow"):auth.index("struct ShareLinkSessionSource")]
assert "normalize_share_expiration" in auth
assert "SHARE_SESSIONS_DELETE_BY_LINK" in auth
assert "share_session_permission" in auth
assert "invalid share session expiration in database" in auth
assert "invalid share session permission in database" in auth
assert "valid_share_token" in auth
assert 'format!("share-session-client:{client_key}")' in auth
create_link = auth[auth.index("pub async fn create_share_link"):auth.index("pub async fn update_share_link")]
assert "no-store, max-age=0" in create_link
assert '"token_hash":token_hash' in create_link
assert '"token":token' not in create_link
assert '"label":label' in create_link
create_session = auth[auth.index("pub async fn create_share_session"):auth.index("async fn share_session_permission")]
assert "existing_session_token" not in create_session
assert 'source.permission == "ro"' not in create_session
assert "resource_is_public_unprotected(state, kind, slug).await?" in create_session
assert create_session.index("resource_is_public_unprotected") < create_session.index("valid_share_token")
create_link = auth[auth.index("pub async fn create_share_link"):auth.index("pub async fn update_share_link")]
update_link = auth[auth.index("pub async fn update_share_link"):auth.index("pub async fn revoke_share_link")]
revoke_link = auth[auth.index("pub async fn revoke_share_link"):auth.index("fn validate_permission")]
for management_handler in (create_link, update_link, revoke_link):
assert "ensure_share_links_enabled" in management_handler
assert "Direct share links are disabled for public resources without a password." in auth
share_access = auth[auth.index("pub async fn share_access_permission"):auth.index("pub async fn share_link_permission")]
assert "resource_is_public_unprotected(state, kind, slug).await?" in share_access
sharing = auth[auth.index("pub async fn resource_sharing"):auth.index("pub async fn create_share_link")]
assert '"share_links_enabled":share_links_enabled' in sharing
assert "if share_links_enabled" in sharing
assert "Vec::new()" in sharing
privacy = auth[auth.index("pub async fn set_resource_privacy"):auth.index("pub async fn share_resource_users")]
assert "SHARE_LINK" not in privacy
assert "SHARE_SESSION" not in privacy
api = read("src/api/mod.rs")
assert "combined_token_access_level" not in api
request_access = api[api.index("async fn request_access_level"):api.index("fn access_level_name")]
for required in ("share_session_token", "resource_token", "authorization_token", "account_token_access_level"):
assert required in request_access
access_tokens = read("src/api/access_tokens.rs")
assert "verify_password_access_token" in access_tokens
assert "verify_resource_access_token" not in access_tokens
assert "workspace.password_hash.is_some()" in access_tokens
assert "pad.password_hash.is_some()" in access_tokens
database = read("src/db/mod.rs")
workspace_password = database[database.index("pub fn verify_workspace_password"):database.index("pub async fn list_notes")]
pad_password = database[database.index("pub fn verify_pad_password"):database.index("pub async fn save_pad_revision")]
assert "(None, _) => false" in workspace_password
assert "(None, _) => false" in pad_password
assert "missing_password_does_not_grant_password_access" in database
websocket = read("src/websocket/mod.rs") + read("src/websocket/pad.rs")
assert "cookie_share_session_token" in websocket
assert "cookie_password_token" in websocket
assert "explicit_access_token.or(" not in websocket
assert "verify_password_access_token" in websocket
assert "Access expired or revoked" in websocket
assert "access_refresh" in websocket
assert websocket.count("update=updates.recv()=>{") == 2
assert websocket.count('message:"Access expired or revoked"') >= 6
current_access = read("src/websocket/mod.rs")[read("src/websocket/mod.rs").index("async fn current_resource_access"):read("src/websocket/mod.rs").index("// Merged from note.rs")]
assert "resource_is_public_unprotected(state, kind, slug)" in current_access
assert "public_unprotected: bool" not in current_access
pages = read("src/app/pages.rs")
assert "canonical_resource_url" in pages
assert "RawQuery" in pages
assert "share_token_from_query" in pages
assert "decode_query_component" in pages
assert "share_session_cookie" in pages
assert "Ok(None) => None" in pages
assert "clear_share_session_cookie" not in pages
assert "no-store, max-age=0" in pages
assert "no-referrer" in pages
assert 'decode_query_component(name).as_deref() != Some("share")' in pages
security = read("src/security.rs")
assert "__Host-rustpad_share_" in security
assert "HttpOnly; Secure; SameSite=Lax" in security
assert "clear_share_session_cookie" not in security
for backend in ("sqlite", "postgres", "mysql"):
sharing_list = query_sql(backend, "RESOURCE_SHARING_LINKS")
assert not re.search(r"(?:^|,)\s*(?:CAST\()?token\b", sharing_list)
legacy_migration = read(f"migrations/{backend}/0012_share_link_tokens.sql")
assert "ADD COLUMN token" not in legacy_migration
migration = read(f"migrations/{backend}/0026_share_link_sessions.sql")
assert "resource_share_links SET token" not in migration
assert "session_token_hash" in migration
assert "ON DELETE CASCADE" in migration
label_migration = read(f"migrations/{backend}/0027_share_link_labels.sql")
assert "ADD COLUMN label" in label_migration
home_js = read("static/js/home.js")
assert "createdLinkValues" in home_js
assert "result.token_hash" in home_js
assert "data-copy-link" in home_js
assert "The full address is not stored" in home_js
assert 'name="label"' in home_js
assert "token_hash: linkRow.dataset.linkTokenHash" in home_js
assert "Link created, displayed below and copied." in home_js
assert "data-direct-links-disabled" in home_js
assert "d.share_links_enabled !== false" in home_js
assert "Existing links are preserved and become active again" in home_js
workspace_js = read("static/js/workspace.js")
note_js = read("static/js/note-editor.js")
assert 'info.access_level === "none"' in workspace_js
assert note_js.count('info.access_level === "none"') >= 2
main_rs = read("src/main.rs")
assert "https://git.linuxiarz.pl/gru/rustpad/src/branch/master/LICENSE.md" in main_rs
app = read("src/app/mod.rs")
assert "PathOnlyMakeSpan" in app
assert "request.uri().path()" in app
assert "TraceLayer::new_for_http().make_span_with(PathOnlyMakeSpan)" in app
print("share session static regression tests: passed")
if __name__ == "__main__":
main()
-90
View File
@@ -1,90 +0,0 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
const origin = "https://pad.example";
let currentUrl;
let historyCall;
let dispatchedEvent;
globalThis.CustomEvent = class CustomEvent {
constructor(type, init = {}) {
this.type = type;
this.detail = init.detail;
}
};
globalThis.window = {
location: null,
history: {
pushState(state, _title, url) {
historyCall = { method: "pushState", state };
setLocation(url);
},
replaceState(state, _title, url) {
historyCall = { method: "replaceState", state };
setLocation(url);
},
},
dispatchEvent(event) {
dispatchedEvent = event;
},
};
function setLocation(value) {
currentUrl = new URL(value, origin);
window.location = {
get href() { return currentUrl.href; },
get search() { return currentUrl.search; },
};
}
function resetWindow(value) {
setLocation(value);
historyCall = undefined;
dispatchedEvent = undefined;
}
const source = await readFile(new URL("../static/js/url-state.js", import.meta.url), "utf8");
const moduleUrl = `data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
const { currentShareUrl, readEditorState, writeEditorState } = await import(moduleUrl);
test("editor state reads supported values", () => {
resetWindow("/w/demo/n/note?view=preview&mode=text");
assert.deepEqual(readEditorState(), { view: "preview", mode: "text" });
});
test("editor state rejects unsupported values", () => {
resetWindow("/w/demo/n/note?view=invalid&mode=html");
assert.deepEqual(readEditorState(), { view: "split", mode: "markdown" });
});
test("writing editor state preserves unrelated query and hash", () => {
resetWindow("/w/demo/n/note?filter=recent#section");
const result = new URL(writeEditorState({ view: "edit", mode: "text" }));
assert.equal(result.searchParams.get("filter"), "recent");
assert.equal(result.searchParams.get("view"), "edit");
assert.equal(result.searchParams.get("mode"), "text");
assert.equal(result.hash, "#section");
assert.equal(historyCall.method, "pushState");
assert.deepEqual(historyCall.state, { view: "edit", mode: "text" });
assert.equal(dispatchedEvent.type, "rustpad:urlchange");
});
test("replace mode uses replaceState", () => {
resetWindow("/p/demo");
writeEditorState({ view: "split", mode: "markdown" }, { replace: true });
assert.equal(historyCall.method, "replaceState");
});
test("current share URL adds view state without inventing access tokens", () => {
resetWindow("/w/demo/n/note?filter=recent#section");
const result = new URL(currentShareUrl({ view: "preview", mode: "markdown" }));
assert.equal(result.searchParams.get("filter"), "recent");
assert.equal(result.searchParams.get("view"), "preview");
assert.equal(result.searchParams.get("mode"), "markdown");
assert.equal(result.searchParams.has("share"), false);
assert.equal(result.hash, "#section");
});