fix(share): encode and escape the share id when building links

A shareAlias is free-form text, so a share id can carry spaces, quotes
and path separators. buildShareLink() percent-encodes it into a single
path segment the /share/:shareId route decodes back, the share info bar
escapes the link it renders, and the share index escapes the href it
builds for each child.

Also implements the SBranch TODO in buildShareNote() so a mocked note's
children reach getChildNotes(), which the index test needs.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Elian Doran
2026-08-21 13:39:02 +03:00
co-authored by Claude Opus 5
parent 44f7bfaee8
commit 3975ef7146
7 changed files with 92 additions and 8 deletions
@@ -35,4 +35,20 @@ describe("buildShareLink", () => {
setGlob({ httpBaseUrl: "http://127.0.0.1:37742" });
expect(buildShareLink("", undefined)).toBe("http://127.0.0.1:37742/share/");
});
it("percent-encodes a shareId carrying characters that are not path-safe", () => {
// A shareAlias is free-form text, so it can hold spaces, quotes, separators and anything
// else a title can. Each branch must produce one path segment the /share/:shareId route
// decodes back to the alias.
const shareId = `my alias"/?x`;
const encoded = "my%20alias%22%2F%3Fx";
const syncServerHost = "https://notes.example.com";
setGlob({ httpBaseUrl: "http://127.0.0.1:37742" });
expect(buildShareLink(shareId, syncServerHost)).toBe(`${syncServerHost}/share/${encoded}`);
expect(buildShareLink(shareId, undefined)).toBe(`http://127.0.0.1:37742/share/${encoded}`);
setGlob({});
expect(buildShareLink(shareId, null)).toBe(`http://localhost:3000/share/${encoded}`);
});
});
+7 -3
View File
@@ -8,13 +8,17 @@
* origin (which is correct for the server build and for a browser hitting the desktop).
*/
export function buildShareLink(shareId: string, syncServerHost: string | null | undefined): string {
// A shareId is either a noteId or a free-form shareAlias, so it can hold spaces, quotes and
// path separators. Encoding keeps it a single segment the /share/:shareId route decodes back.
const encodedShareId = encodeURIComponent(shareId);
if (syncServerHost) {
return new URL(`/share/${shareId}`, syncServerHost).href;
return new URL(`/share/${encodedShareId}`, syncServerHost).href;
}
if (window.glob.httpBaseUrl) {
return new URL(`/share/${shareId}`, window.glob.httpBaseUrl).href;
return new URL(`/share/${encodedShareId}`, window.glob.httpBaseUrl).href;
}
return `${location.protocol}//${location.host}${location.pathname}share/${shareId}`;
return `${location.protocol}//${location.host}${location.pathname}share/${encodedShareId}`;
}
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { buildShareLinkHtml } from "./shared_info";
describe("buildShareLinkHtml", () => {
it("keeps the whole link in the anchor's href and text, whatever characters it carries", () => {
const link = `http://host/share/my alias"x`;
const anchor = parseAnchor(buildShareLinkHtml(link));
expect(anchor.getAttribute("href")).toBe(link);
expect(anchor.textContent).toBe(link);
expect(anchor.getAttributeNames().sort()).toEqual([ "class", "href" ]);
});
});
function parseAnchor(html: string) {
const container = document.createElement("div");
container.innerHTML = html;
const anchor = container.querySelector("a");
if (!anchor) {
throw new Error(`No anchor in ${html}`);
}
return anchor;
}
+8 -2
View File
@@ -6,7 +6,7 @@ import FNote from "../entities/fnote";
import attributes from "../services/attributes";
import { t } from "../services/i18n";
import { buildShareLink } from "../services/share_link";
import { isElectron } from "../services/utils";
import { escapeHtml, isElectron } from "../services/utils";
import HelpButton from "./react/HelpButton";
import { useNoteContext, useTriliumEvent, useTriliumOption } from "./react/hooks";
import InfoBar from "./react/InfoBar";
@@ -43,7 +43,7 @@ export function useShareInfo(note: FNote | null | undefined) {
const link = buildShareLink(getShareId(note), syncServerHost);
setLink(`<a href="${link}" class="external tn-link">${link}</a>`);
setLink(buildShareLinkHtml(link));
setLinkHref(link);
}
@@ -63,6 +63,12 @@ export function useShareInfo(note: FNote | null | undefined) {
};
}
export function buildShareLinkHtml(link: string) {
const escapedLink = escapeHtml(link);
return `<a href="${escapedLink}" class="external tn-link">${escapedLink}</a>`;
}
function getShareId(note: FNote) {
if (note.hasOwnedLabel("shareRoot")) {
return "";
@@ -4,6 +4,7 @@ import { beforeAll, describe, expect, it, vi } from "vitest";
import { buildShareNote, buildShareNotes } from "../test/shaca_mocking.js";
import { getContent, renderCode, type Result, shouldSyntaxHighlight } from "./content_renderer.js";
import shareRoot from "./share_root.js";
describe("content_renderer", () => {
beforeAll(() => {
@@ -533,4 +534,26 @@ describe("content_renderer", () => {
expect(shouldSyntaxHighlight("x".repeat(50_001))).toBe(false);
});
});
describe("Share index", () => {
it("points each index entry at the child's shareId, whatever characters it carries", () => {
buildShareNote({
id: shareRoot.SHARE_ROOT_NOTE_ID,
children: [
{ "id": "child1", "title": "Child", "#shareAlias": `my alias"x` }
]
});
const note = buildShareNote({
"id": "indexNote",
"content": "<p>Index</p>",
"#shareIndex": ""
});
const result = getContent(note);
const anchor = parse(String(result.content)).querySelector("#index a");
expect(anchor?.getAttribute("href")).toBe(`./my alias"x`);
expect(Object.keys(anchor?.attributes ?? {}).sort()).toEqual([ "class", "href" ]);
});
});
});
+1 -1
View File
@@ -340,7 +340,7 @@ function renderIndex(result: Result) {
for (const childNote of rootNote.getChildNotes()) {
const isExternalLink = childNote.hasLabel("shareExternalLink");
const rawHref = childNote.getLabelValue("shareExternalLink") ?? "";
const href = isExternalLink ? escapeHtml(sanitize.sanitizeUrl(rawHref)) : `./${childNote.shareId}`;
const href = escapeHtml(isExternalLink ? sanitize.sanitizeUrl(rawHref) : `./${childNote.shareId}`);
const target = isExternalLink ? `target="_blank" rel="noopener noreferrer"` : "";
result.content += `<li><a class="${childNote.type}" href="${href}" ${target}>${childNote.escapedTitle}</a></li>`;
}
+11 -2
View File
@@ -1,6 +1,7 @@
import utils from "../services/utils.js";
import SAttachment from "../share/shaca/entities/sattachment.js";
import SAttribute from "../share/shaca/entities/sattribute.js";
import SBranch from "../share/shaca/entities/sbranch.js";
import SNote from "../share/shaca/entities/snote.js";
import shaca from "../share/shaca/shaca.js";
@@ -87,12 +88,20 @@ export function buildShareNote(noteDef: NoteDefinition) {
}
}
// Handle children.
// Handle children. The SBranch constructor is what links the two notes, so getChildNotes()
// and getVisibleChildNotes() only see a child once its branch exists.
if (noteDef.children) {
for (const childDef of noteDef.children) {
const childNote = buildShareNote(childDef);
// TODO: Create corresponding SBranch.
new SBranch([
utils.randomString(12),
childNote.noteId,
note.noteId,
"", // prefix
"", // isExpanded
false // utcDateModified
]);
}
}