big update in share links

This commit is contained in:
Mateusz Gruszczyński
2026-08-03 09:56:47 +02:00
parent e3ee6319b9
commit fe5d00fcdd
27 changed files with 434 additions and 176 deletions
+15 -12
View File
@@ -60,6 +60,7 @@ 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")
@@ -74,14 +75,15 @@ def main() -> None:
share_hash = hashlib.sha256(raw_link.encode()).hexdigest()
db.execute(
"INSERT INTO resource_share_links "
"(token_hash, token, resource_kind, resource_slug, permission, expires_at, created_by) "
"VALUES (?, ?, 'workspace', 'private-space', 'ro', NULL, 1)",
(share_hash, raw_link),
"(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())
assert db.execute(
"SELECT token FROM resource_share_links WHERE token_hash = ?", (share_hash,)
).fetchone() == (None,), "migration must erase persisted plaintext share tokens"
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")
@@ -162,23 +164,24 @@ def main() -> None:
).rowcount
assert deleted == 1, "revoking a link must remove all derived sessions"
# Newly created links persist only the hash; the plaintext column stays NULL.
# 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, "workspace", "second-space", "rw", None, 1),
(second_hash, "QA link", "workspace", "second-space", "rw", None, 1),
)
assert db.execute(
"SELECT token, permission FROM resource_share_links WHERE token_hash = ?", (second_hash,)
).fetchone() == (None, "rw")
"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[3],
), "management listing must never return the plaintext share token"
listed_link[4],
), "management listing must return the label but never a plaintext share token"
db.execute(
insert_sql,
+60 -6
View File
@@ -25,7 +25,8 @@ def query_sql(backend: str, name: str) -> str:
def main() -> None:
names = {
"SHARE_LINK_INSERT": 6,
"SHARE_LINK_INSERT": 7,
"SHARE_LINK_UPDATE": 6,
"SHARE_LINK_PERMISSION": 3,
"SHARE_LINK_SESSION_SOURCE": 3,
"SHARE_SESSION_INSERT": 5,
@@ -43,6 +44,8 @@ def main() -> None:
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")]
@@ -55,10 +58,29 @@ def main() -> None:
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" in create_session
assert 'source.permission == "ro"' in create_session
assert '== Some("rw")' in create_session
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
@@ -68,7 +90,16 @@ def main() -> None:
access_tokens = read("src/api/access_tokens.rs")
assert "verify_password_access_token" in access_tokens
assert "share_access_permission" 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
@@ -79,6 +110,9 @@ def main() -> None:
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
@@ -95,20 +129,40 @@ def main() -> None:
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 "UPDATE resource_share_links SET token = NULL" in migration
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
+71 -48
View File
@@ -2,66 +2,89 @@ import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
globalThis.location = { origin: "https://pad.example" };
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 { editorResourceUrl, withShareToken } = await import(moduleUrl);
const { currentShareUrl, readEditorState, writeEditorState } = await import(moduleUrl);
function parsed(path) {
return new URL(path, "https://pad.example");
}
test("workspace share token is preserved when opening a note", () => {
const url = parsed(editorResourceUrl("/w/private/n/first", {
shareToken: "share-token-123",
view: "split",
mode: "markdown",
}));
assert.equal(url.pathname, "/w/private/n/first");
assert.equal(url.searchParams.get("share"), "share-token-123");
assert.equal(url.searchParams.get("view"), "split");
assert.equal(url.searchParams.get("mode"), "markdown");
test("editor state reads supported values", () => {
resetWindow("/w/demo/n/note?view=preview&mode=text");
assert.deepEqual(readEditorState(), { view: "preview", mode: "text" });
});
test("share token is preserved when returning to the workspace", () => {
const url = parsed(withShareToken("/w/private", "share-token-123"));
assert.equal(url.pathname, "/w/private");
assert.equal(url.searchParams.get("share"), "share-token-123");
test("editor state rejects unsupported values", () => {
resetWindow("/w/demo/n/note?view=invalid&mode=html");
assert.deepEqual(readEditorState(), { view: "split", mode: "markdown" });
});
test("existing query and hash survive share-aware navigation", () => {
const url = parsed(editorResourceUrl("/w/private/n/first?mode=text#section", {
shareToken: "new-token",
view: "preview",
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(url.searchParams.get("share"), "new-token");
assert.equal(url.searchParams.get("view"), "preview");
assert.equal(url.searchParams.get("mode"), "markdown");
assert.equal(url.hash, "#section");
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("ordinary workspace navigation does not gain or retain a share token", () => {
const url = parsed(editorResourceUrl("/w/public/n/first?share=stale-token", {
view: "split",
mode: "markdown",
}));
assert.equal(url.searchParams.has("share"), false);
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" }));
test("share-aware helpers reject external application URLs", () => {
assert.equal(withShareToken("https://example.com/steal", "secret"), "/");
});
test("share-aware helpers accept same-origin absolute URLs", () => {
const url = parsed(withShareToken("https://pad.example/w/private", "share-token-123"));
assert.equal(url.pathname, "/w/private");
assert.equal(url.searchParams.get("share"), "share-token-123");
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");
});