diff --git a/Cargo.toml b/Cargo.toml index fd83a57..9ee49e2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/static/js/note-api.js b/static/js/note-api.js index 61a622b..cc34a5d 100644 --- a/static/js/note-api.js +++ b/static/js/note-api.js @@ -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)); }, }; } diff --git a/static/js/note-editor.js b/static/js/note-editor.js index 1916212..b3b88e8 100644 --- a/static/js/note-editor.js +++ b/static/js/note-editor.js @@ -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; diff --git a/static/js/url-state.js b/static/js/url-state.js index 6704f65..864cf90 100644 --- a/static/js/url-state.js +++ b/static/js/url-state.js @@ -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); diff --git a/static/js/workspace.js b/static/js/workspace.js index d31d2df..2ec3a16 100644 --- a/static/js/workspace.js +++ b/static/js/workspace.js @@ -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 = `
${notes.map(note => ` - + @@ -99,7 +103,7 @@ function renderNotes(notes = notesCache) { } notesList.innerHTML = notes.map(note => `
- +

${escapeHtml(note.title)}

${note.protected ? 'Protected' : ''}
Created by: ${escapeHtml(note.created_by || "Unknown")}${noteStats(note)}Updated: ${formatDate(note.updated_at)}
@@ -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 => { diff --git a/tests/share_url_test.mjs b/tests/share_url_test.mjs new file mode 100644 index 0000000..8da27d7 --- /dev/null +++ b/tests/share_url_test.mjs @@ -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"); +});
NameCreated byParticipantsFilesRevisionsStatusUpdatedActions
${escapeHtml(note.title)}${escapeHtml(note.title)} ${escapeHtml(note.created_by || "Unknown")} ${Number(note.participant_count) || 0} ${Number(note.file_count) || 0} (${formatBytes(note.file_size_bytes)})