fix1 tokens

This commit is contained in:
Mateusz Gruszczyński
2026-08-03 01:34:13 +02:00
parent 13c1aba161
commit 49dad1a5f4
6 changed files with 115 additions and 6 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "rustpad"
version = "0.2.21"
version = "0.2.23"
edition = "2024"
rust-version = "1.94"
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
+3 -1
View File
@@ -10,6 +10,7 @@
import { api } from "@rustpad/api";
import { askConfirm } from "@rustpad/modal";
import { NoteSocket, PadSocket } from "@rustpad/socket";
import { withShareToken } from "@rustpad/url-state";
function encode(value) {
return encodeURIComponent(value);
@@ -59,6 +60,7 @@ export function createWorkspaceNoteAdapter() {
const workspaceSlug = parts[1];
const noteSlug = parts[3];
const base = `/api/workspaces/${encode(workspaceSlug)}/notes/${encode(noteSlug)}`;
const shareToken = new URLSearchParams(location.search).get("share");
return {
access: { kind: "workspace", key: workspaceSlug },
@@ -104,7 +106,7 @@ export function createWorkspaceNoteAdapter() {
method: "DELETE",
body: JSON.stringify({ access_token: accessToken || null }),
});
location.assign(`/w/${encode(workspaceSlug)}`);
location.assign(withShareToken(`/w/${encode(workspaceSlug)}`, shareToken));
},
};
}
+3 -1
View File
@@ -19,7 +19,7 @@ import { alignPreviewLineNumbers, renderMarkdown, setMarkdownFiles, unresolvedMa
import { getNickname, getGuestId, getAuthToken, getAccessToken, setAccessToken } from "@rustpad/session";
import { bindIdentityDialog, validateCurrentSession } from "@rustpad/auth-ui";
import { bindNoteFiles } from "@rustpad/note-files";
import { currentShareUrl, readEditorState, writeEditorState } from "@rustpad/url-state";
import { currentShareUrl, readEditorState, withShareToken, writeEditorState } from "@rustpad/url-state";
import { toast } from "@rustpad/toast";
import { getTheme } from "@rustpad/theme";
@@ -31,6 +31,8 @@ export function startNoteEditor(adapter) {
const compactToggle = document.querySelector("#compact-toggle"), lineLinksToggle = document.querySelector("#line-links-toggle"), authorshipColorsToggle = document.querySelector("#authorship-colors-toggle"), authorshipColorsLabel = document.querySelector("#authorship-colors-label"), publicPageEnabled = document.querySelector("#public-page-enabled"), publicTaskUpdates = document.querySelector("#public-task-updates"), unprotectPublicPage = document.querySelector("#unprotect-public-page"), participantBadges = document.querySelector("#participant-badges"), fontFamily = document.querySelector("#font-family"), fontSize = document.querySelector("#font-size"), currentUser = document.querySelector("#current-user"), userColorPicker = document.querySelector("#user-color-picker"), mobileColorPicker = document.querySelector("#mobile-color-picker"), useGlobalColorButton = document.querySelector("#use-global-color");
const mobileFontFamily = document.querySelector("#mobile-font-family"), mobileFontSize = document.querySelector("#mobile-font-size"), mobileLineToggle = document.querySelector("#mobile-line-numbers-toggle"), mobilePreviewLineToggle = document.querySelector("#mobile-preview-line-numbers-toggle"), mobileCompactToggle = document.querySelector("#mobile-compact-toggle"), mobileLineLinksToggle = document.querySelector("#mobile-line-links-toggle");
const shareToken = new URLSearchParams(location.search).get("share");
const parentLink = document.querySelector("#resource-parent-link");
if (parentLink && shareToken) parentLink.href = withShareToken(parentLink.getAttribute("href") || "/", shareToken);
const notePreferenceKey = name => `rustpad:${name}:${location.pathname}`;
let accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key), password = "", nickname = getNickname(), info, socket, saveTimer, applyingRemote = false, applyingHistory = false, resourceUnlocked = false, uiState = readEditorState(), authorship = parseAuthorship("", "[]"), previousContent = "", globalColor = "", noteColor = "", presenceUsers = [], authorshipMode = "simple", authorshipColorsEnabled = true, lastRevealedLineHash = "";
let editorSettingsSaveTimer, editorSettingsSaveInFlight = false, pendingPersonalSettingsSave = false, pendingAuthorshipSettingsSave = false, connectionNoticeTimer = 0, connectionWasInterrupted = false;
+34
View File
@@ -9,6 +9,40 @@
const VIEWS = new Set(["edit", "split", "preview"]);
const MODES = new Set(["markdown", "text"]);
const APP_URL_BASE = globalThis.location?.origin || "https://rustpad.invalid";
function appUrl(path) {
try {
const url = new URL(path, APP_URL_BASE);
return url.origin === APP_URL_BASE ? url : null;
} catch {
return null;
}
}
function relativeUrl(url) {
return `${url.pathname}${url.search}${url.hash}`;
}
export function withShareToken(path, shareToken) {
const url = appUrl(path);
if (!url) return "/";
const token = typeof shareToken === "string" ? shareToken.trim() : "";
if (token) url.searchParams.set("share", token);
else url.searchParams.delete("share");
return relativeUrl(url);
}
export function editorResourceUrl(path, { shareToken = "", view = "split", mode = "markdown" } = {}) {
const url = appUrl(path);
if (!url) return "/";
if (VIEWS.has(view)) url.searchParams.set("view", view);
if (MODES.has(mode)) url.searchParams.set("mode", mode);
const token = typeof shareToken === "string" ? shareToken.trim() : "";
if (token) url.searchParams.set("share", token);
else url.searchParams.delete("share");
return relativeUrl(url);
}
export function readEditorState() {
const params = new URLSearchParams(window.location.search);
+7 -3
View File
@@ -17,6 +17,7 @@ import { bindIdentityDialog, validateCurrentSession } from "@rustpad/auth-ui";
import { askConfirm } from "@rustpad/modal";
import { safeAppUrl } from "@rustpad/security";
import { toast } from "@rustpad/toast";
import { editorResourceUrl } from "@rustpad/url-state";
const parts = location.pathname.split("/").filter(Boolean);
const slug = parts[1];
@@ -70,6 +71,9 @@ function setNotesView(view) {
button.setAttribute("aria-pressed", String(active));
});
}
function noteEditorUrl(path) {
return safeAppUrl(editorResourceUrl(safeAppUrl(path), { shareToken }));
}
function deleteButton(note, inline = false) {
const disabled = note.protected;
const classes = `note-delete-button${inline ? " note-delete-button--inline" : ""}`;
@@ -86,7 +90,7 @@ function renderNotes(notes = notesCache) {
if (notesView === "table") {
notesList.innerHTML = `<div class="notes-table-scroll"><table><thead><tr><th>Name</th><th>Created by</th><th>Participants</th><th>Files</th><th>Revisions</th><th>Status</th><th>Updated</th><th class="notes-table-actions">Actions</th></tr></thead><tbody>${notes.map(note => `
<tr>
<td><a class="note-table-link" href="${escapeHtml(safeAppUrl(`${note.url}?view=split&mode=markdown`))}">${escapeHtml(note.title)}</a></td>
<td><a class="note-table-link" href="${escapeHtml(noteEditorUrl(note.url))}">${escapeHtml(note.title)}</a></td>
<td class="note-author">${escapeHtml(note.created_by || "Unknown")}</td>
<td>${Number(note.participant_count) || 0}</td>
<td>${Number(note.file_count) || 0} <span class="note-status">(${formatBytes(note.file_size_bytes)})</span></td>
@@ -99,7 +103,7 @@ function renderNotes(notes = notesCache) {
}
notesList.innerHTML = notes.map(note => `
<article class="note-card-wrap">
<a class="note-card" href="${escapeHtml(safeAppUrl(`${note.url}?view=split&mode=markdown`))}">
<a class="note-card" href="${escapeHtml(noteEditorUrl(note.url))}">
<div class="note-card-title"><h3>${escapeHtml(note.title)}</h3>${note.protected ? '<span class="protect-badge">Protected</span>' : ''}</div>
<div class="note-card-meta"><span>Created by: ${escapeHtml(note.created_by || "Unknown")}</span>${noteStats(note)}<span>Updated: ${formatDate(note.updated_at)}</span></div>
</a>
@@ -179,7 +183,7 @@ document.querySelector("#note-form").addEventListener("submit", async e => {
method: "POST",
body: JSON.stringify({ name: document.querySelector("#note-name").value, access_token: accessToken || null, protect: document.querySelector("#note-protect").checked, created_by: nickname || null })
});
location.assign(safeAppUrl(`${note.url}?view=split&mode=markdown`));
location.assign(noteEditorUrl(note.url));
} catch (err) { error.textContent = err.message; }
});
notesList.addEventListener("click", async event => {
+67
View File
@@ -0,0 +1,67 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
globalThis.location = { origin: "https://pad.example" };
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);
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("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("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",
}));
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");
});
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("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");
});